diff --git a/capra-fagradar/src/radar/index.tsx b/capra-fagradar/src/radar/index.tsx index f1a75b4..44a00fd 100644 --- a/capra-fagradar/src/radar/index.tsx +++ b/capra-fagradar/src/radar/index.tsx @@ -6,16 +6,16 @@ import { Tooltip, TooltipRefProps } from "react-tooltip"; import { useRadarStore } from "./radar-store"; export interface Blip { - id: number; - name: string; - blipNumber: number - logo?: string - depth: number; - is_new: boolean; - element: React.ReactElement; - quadrant: string - x: number; - y: number; + id: number; + name: string; + blipNumber: number; + logo?: string; + depth: number; + is_new: boolean; + element: React.ReactElement; + quadrant: string; + x: number; + y: number; } type Color = string; // TODO: is there an html color / css color type? @@ -23,496 +23,476 @@ type Color = string; // TODO: is there an html color / css color type? type QuadrantType = "top-left" | "top-right" | "bottom-left" | "bottom-right"; type BlipProps = { - blip: Blip; - color?: Color; + blip: Blip; + color?: Color; }; const RadarBlip: React.FC = ({ blip, color }) => { - const className = styles.blip + (blip.is_new ? ` ${styles.circleOutline}` : ""); - - const ref = useRef() - - if (ref) ref.current - - const { selectBlip, highlightBlip, highlightedBlip } = useRadarStore() - - return ( -
-
highlightBlip(blip)} - onMouseLeave={() => highlightBlip(undefined)} - onClick={() => selectBlip(blip)} - > - {blip.blipNumber} - {/* */} -
-
- ); + const className = + styles.blip + (blip.is_new ? ` ${styles.circleOutline}` : ""); + + const ref = useRef(); + + if (ref) ref.current; + + const { selectBlip, highlightBlip, highlightedBlip } = useRadarStore(); + + return ( +
+
highlightBlip(blip)} + onMouseLeave={() => highlightBlip(undefined)} + onClick={() => selectBlip(blip)} + > + {blip.blipNumber} + {/* */} +
+
+ ); }; type ArcProps = { - orientation: QuadrantType; - outerRadius: number; - size: number; + orientation: QuadrantType; + outerRadius: number; + size: number; }; const Arc: React.FC = ({ orientation, outerRadius, size }) => { - const getTransform = (orientation: QuadrantType, x: number, y: number) => { - switch (orientation) { - case "top-left": - return `rotate(-90, ${x}, ${y}) translate(0, ${size})`; - case "top-right": - return `rotate(0, ${x}, ${y})`; - case "bottom-left": - return `rotate(180, ${x}, ${y}) translate(-${size}, ${size})`; - case "bottom-right": - return `rotate(90, ${x}, ${y}) translate(-${size}, 0)`; - default: - return `rotate(0, ${x}, ${y})`; - } - }; - - const moveTo = (x: number, y: number) => `M ${x},${y}`; - const lineTo = (x: number, y: number) => `L ${x},${y}`; - const arcTo = ( - rx: number, - ry: number, - xAxisRotation: number, - largeArcFlag: number, - sweepFlag: number, - x: number, - y: number, - ) => `A ${rx},${ry} ${xAxisRotation} ${largeArcFlag},${sweepFlag} ${x},${y}`; - - const drawArc = (_orientation: QuadrantType, outerRadius: number) => { - const centerX = 0; - const centerY = size; - return ` + const getTransform = (orientation: QuadrantType, x: number, y: number) => { + switch (orientation) { + case "top-left": + return `rotate(-90, ${x}, ${y}) translate(0, ${size})`; + case "top-right": + return `rotate(0, ${x}, ${y})`; + case "bottom-left": + return `rotate(180, ${x}, ${y}) translate(-${size}, ${size})`; + case "bottom-right": + return `rotate(90, ${x}, ${y}) translate(-${size}, 0)`; + default: + return `rotate(0, ${x}, ${y})`; + } + }; + + const moveTo = (x: number, y: number) => `M ${x},${y}`; + const lineTo = (x: number, y: number) => `L ${x},${y}`; + const arcTo = ( + rx: number, + ry: number, + xAxisRotation: number, + largeArcFlag: number, + sweepFlag: number, + x: number, + y: number, + ) => `A ${rx},${ry} ${xAxisRotation} ${largeArcFlag},${sweepFlag} ${x},${y}`; + + const drawArc = (_orientation: QuadrantType, outerRadius: number) => { + const centerX = 0; + const centerY = size; + return ` ${moveTo(centerX, centerY)} ${lineTo(centerX, centerY - outerRadius)} ${arcTo(outerRadius, outerRadius, 0, 0, 1, centerX + outerRadius, centerY)} Z `; - }; - - return ( - - - - ) -} + }; + + return ( + + + + ); +}; interface RadarChartProps { - name: string; - blipColor?: Color; - blips: Blip[]; - orientation: QuadrantType; - maxDepth: number; - size: number; + name: string; + blipColor?: Color; + blips: Blip[]; + orientation: QuadrantType; + maxDepth: number; + size: number; } const Quadrant: React.FC = ({ - name, - blipColor, - blips, - orientation, - size, + name, + blipColor, + blips, + orientation, + size, }) => { - const margin = 4; /* px */ - - // Define the custom cumulative fractions for each arc level - const cumulativeFractions = [0, 0.5, 0.7, 0.90, 1]; - - const distributeBlips = ( - blips: Blip[], - depth: number, - size: number, - quadrant: QuadrantType, - ) => { - const degreeToRadians = (degrees: number) => (degrees / 360) * 2 * Math.PI; - - // Define the angle range for the quadrant - const angleStart = 0; - const angleEnd = Math.PI / 2; // 90 degrees in radians - - // Adjust radius calculation using custom fractions - const innerFraction = cumulativeFractions[depth - 1]; - const outerFraction = cumulativeFractions[depth]; - - const innerRadius = innerFraction * (size - margin); - const outerRadius = outerFraction * (size - margin); - - // Blip properties - const blipSize = 30; // blip size in pixels - const minSpacing = 5; // minimum spacing between blips in pixels - - if (depth === 1) { - // For depth === 1, arrange blips in two rows - // Calculate the radii for the two rows - const rowRadii = [ - innerRadius + (outerRadius - innerRadius) / 2.5, - innerRadius + (1.5 * outerRadius - innerRadius) / 2.5, - innerRadius + (2 * (outerRadius - innerRadius)) / 2.5, - ]; - - // Determine how many blips can fit in each row without overlapping - // Calculate the circumference for each row arc - const rowCircumferences = rowRadii.map( - (radius) => radius * (angleEnd - angleStart), - ); - - // Approximate number of blips that can fit in each row - const blipsPerRow = rowCircumferences.map((circumference) => - Math.floor(circumference / (blipSize + minSpacing)), - ); - - // Total blips that can be placed without overlapping - const totalCapacity = blipsPerRow.reduce((a, b) => a + b, 0); - - // If we have more blips than capacity, we need to adjust - const totalBlips = blips.length; - const blipsPerRowAdjusted = blipsPerRow.map((capacity) => - Math.floor((capacity / totalCapacity) * totalBlips), - ); - - // Adjust for any rounding errors - let adjustedTotal = blipsPerRowAdjusted.reduce((a, b) => a + b, 0); - let diff = totalBlips - adjustedTotal; - let rowIndex = 0; - while (diff > 0) { - blipsPerRowAdjusted[rowIndex % 2]++; - adjustedTotal++; - diff--; - rowIndex++; - } - - // Now distribute blips into the two rows - const blipsInRows: Array> = [[], [], []]; - let blipIndex = 0; - for (let i = 0; i < 3; i++) { - for (let j = 0; j < blipsPerRowAdjusted[i]; j++) { - blipsInRows[i].push(blips[blipIndex]); - blipIndex++; - } - } - - // Now compute positions for blips in each row - const positionedBlips = []; - for (let row = 0; row < 3; row++) { - const radius = rowRadii[row]; - const numBlipsInRow = blipsInRows[row].length; - const angleSpacing = - (angleEnd - angleStart) / numBlipsInRow; - - for (let i = 0; i < numBlipsInRow; i++) { - const blip = blipsInRows[row][i]; - const angle = - angleStart + angleSpacing * (i + 0.5); // Offset by 0.5 to center blips - - const x = radius * Math.cos(angle); - const y = radius * Math.sin(angle); - - let adjustedX, adjustedY; - switch (quadrant) { - case "top-left": - adjustedX = size - x; - adjustedY = size - y; - break; - case "top-right": - adjustedX = 0 + x; - adjustedY = size - y; - break; - case "bottom-left": - adjustedX = size - x; - adjustedY = 0 + y; - break; - case "bottom-right": - adjustedX = 0 + x; - adjustedY = 0 + y; - break; - } - - positionedBlips.push({ - ...blip, - x: adjustedX, - y: adjustedY, - }); - } - } - - return positionedBlips; - } else { - // For other depths, keep existing distribution logic - const angleMargin = degreeToRadians(20 / depth); - const angleScale = scaleLinear() - .domain([0, blips.length - 1]) - .range([angleStart + angleMargin, angleEnd - angleMargin]); - - return blips.map((blip, index) => { - const angle = angleScale(index); - const radius = (innerRadius + outerRadius) / 2; - - const x = radius * Math.cos(angle); - const y = radius * Math.sin(angle); - - let adjustedX, adjustedY; - switch (quadrant) { - case "top-left": - adjustedX = size - x; - adjustedY = size - y; - break; - case "top-right": - adjustedX = 0 + x; - adjustedY = size - y; - break; - case "bottom-left": - adjustedX = size - x; - adjustedY = 0 + y; - break; - case "bottom-right": - adjustedX = 0 + x; - adjustedY = 0 + y; - break; - } - - return { - ...blip, - x: adjustedX, - y: adjustedY, - }; - }); - } - }; - - const groupedBlips = Object.groupBy( - blips, - ({ depth }: { depth: number }) => depth, - ); - - const distributedBlips = Object.keys(groupedBlips).map((depth: string) => { - return distributeBlips( - groupedBlips[Number(depth)] as any, - Number(depth), - size, - orientation, - ); - }); - - const flattenedArrayBlips = Object.keys(distributedBlips).flatMap( - (depth: string) => distributedBlips[Number(depth)], - ); - - const quadrantSize = size - margin; - - // Use custom cumulative fractions to define arcs - const arcs = cumulativeFractions.slice(1).reverse(); - - return ( -
- - {arcs.map((fraction, i) => ( - - ))} - - {(flattenedArrayBlips || []).map((blip, i) => ( - - ))} - - - {name} - -
- ); + const margin = 4; /* px */ + + // Define the custom cumulative fractions for each arc level + const cumulativeFractions = [0, 0.5, 0.7, 0.9, 1]; + + const distributeBlips = ( + blips: Blip[], + depth: number, + size: number, + quadrant: QuadrantType, + ) => { + const degreeToRadians = (degrees: number) => (degrees / 360) * 2 * Math.PI; + + // Define the angle range for the quadrant + const angleStart = 0; + const angleEnd = Math.PI / 2; // 90 degrees in radians + + // Adjust radius calculation using custom fractions + const innerFraction = cumulativeFractions[depth - 1]; + const outerFraction = cumulativeFractions[depth]; + + const innerRadius = innerFraction * (size - margin); + const outerRadius = outerFraction * (size - margin); + + // Blip properties + const blipSize = 30; // blip size in pixels + const minSpacing = 5; // minimum spacing between blips in pixels + + if (depth === 1) { + // For depth === 1, arrange blips in two rows + // Calculate the radii for the two rows + const rowRadii = [ + innerRadius + (outerRadius - innerRadius) / 2.5, + innerRadius + (1.5 * outerRadius - innerRadius) / 2.5, + innerRadius + (2 * (outerRadius - innerRadius)) / 2.5, + ]; + + // Determine how many blips can fit in each row without overlapping + // Calculate the circumference for each row arc + const rowCircumferences = rowRadii.map( + (radius) => radius * (angleEnd - angleStart), + ); + + // Approximate number of blips that can fit in each row + const blipsPerRow = rowCircumferences.map((circumference) => + Math.floor(circumference / (blipSize + minSpacing)), + ); + + // Total blips that can be placed without overlapping + const totalCapacity = blipsPerRow.reduce((a, b) => a + b, 0); + + // If we have more blips than capacity, we need to adjust + const totalBlips = blips.length; + const blipsPerRowAdjusted = blipsPerRow.map((capacity) => + Math.floor((capacity / totalCapacity) * totalBlips), + ); + + // Adjust for any rounding errors + let adjustedTotal = blipsPerRowAdjusted.reduce((a, b) => a + b, 0); + let diff = totalBlips - adjustedTotal; + let rowIndex = 0; + while (diff > 0) { + blipsPerRowAdjusted[rowIndex % 2]++; + adjustedTotal++; + diff--; + rowIndex++; + } + + // Now distribute blips into the two rows + const blipsInRows: Array> = [[], [], []]; + let blipIndex = 0; + for (let i = 0; i < 3; i++) { + for (let j = 0; j < blipsPerRowAdjusted[i]; j++) { + blipsInRows[i].push(blips[blipIndex]); + blipIndex++; + } + } + + // Now compute positions for blips in each row + const positionedBlips = []; + for (let row = 0; row < 3; row++) { + const radius = rowRadii[row]; + const numBlipsInRow = blipsInRows[row].length; + const angleSpacing = (angleEnd - angleStart) / numBlipsInRow; + + for (let i = 0; i < numBlipsInRow; i++) { + const blip = blipsInRows[row][i]; + const angle = angleStart + angleSpacing * (i + 0.5); // Offset by 0.5 to center blips + + const x = radius * Math.cos(angle); + const y = radius * Math.sin(angle); + + let adjustedX, adjustedY; + switch (quadrant) { + case "top-left": + adjustedX = size - x; + adjustedY = size - y; + break; + case "top-right": + adjustedX = 0 + x; + adjustedY = size - y; + break; + case "bottom-left": + adjustedX = size - x; + adjustedY = 0 + y; + break; + case "bottom-right": + adjustedX = 0 + x; + adjustedY = 0 + y; + break; + } + + positionedBlips.push({ + ...blip, + x: adjustedX, + y: adjustedY, + }); + } + } + + return positionedBlips; + } else { + // For other depths, keep existing distribution logic + const angleMargin = degreeToRadians(20 / depth); + const angleScale = scaleLinear() + .domain([0, blips.length - 1]) + .range([angleStart + angleMargin, angleEnd - angleMargin]); + + return blips.map((blip, index) => { + const angle = angleScale(index); + const radius = (innerRadius + outerRadius) / 2; + + const x = radius * Math.cos(angle); + const y = radius * Math.sin(angle); + + let adjustedX, adjustedY; + switch (quadrant) { + case "top-left": + adjustedX = size - x; + adjustedY = size - y; + break; + case "top-right": + adjustedX = 0 + x; + adjustedY = size - y; + break; + case "bottom-left": + adjustedX = size - x; + adjustedY = 0 + y; + break; + case "bottom-right": + adjustedX = 0 + x; + adjustedY = 0 + y; + break; + } + + return { + ...blip, + x: adjustedX, + y: adjustedY, + }; + }); + } + }; + + const groupedBlips = Object.groupBy( + blips, + ({ depth }: { depth: number }) => depth, + ); + + const distributedBlips = Object.keys(groupedBlips).map((depth: string) => { + return distributeBlips( + groupedBlips[Number(depth)] as any, + Number(depth), + size, + orientation, + ); + }); + + const flattenedArrayBlips = Object.keys(distributedBlips).flatMap( + (depth: string) => distributedBlips[Number(depth)], + ); + + const quadrantSize = size - margin; + + // Use custom cumulative fractions to define arcs + const arcs = cumulativeFractions.slice(1).reverse(); + + return ( +
+ + {arcs.map((fraction, i) => ( + + ))} + + {(flattenedArrayBlips || []).map((blip, i) => ( + + ))} + + + {name} + +
+ ); }; - - export type Quadrant = { - name: string; - orientation: QuadrantType; - blipColor: Color; - blips: Blip[]; + name: string; + orientation: QuadrantType; + blipColor: Color; + blips: Blip[]; }; -const RightAnchoredShelf: React.FC = ({ children }) => { - return ( -
- {children} -
- ); -} +const RightAnchoredShelf: React.FC = ({ + children, +}) => { + return
{children}
; +}; type LabelProps = React.PropsWithChildren; const Label: React.FC = ({ children }) => { - return ( -
{children}
- ); -} + return
{children}
; +}; type BlipInfoProps = { - blip: Blip; -} + blip: Blip; +}; const BlipInfo: React.FC = ({ blip }) => { - const { - selectBlip - } = useRadarStore() - return ( - -

{blip.name} {" "}{blip.logo ? : null}

- -
- - {blip.is_new && ()} -
-
{blip.element}
- -
- ); -} - + const { selectBlip } = useRadarStore(); + return ( + +

+ {blip.name}{" "} + {blip.logo ? ( + + ) : null} +

+ +
+ + {blip.is_new && } +
+
{blip.element}
+ +
+ ); +}; interface QuadrantListProps { - name: string; - orientation: QuadrantType; - blips: Blip[]; + name: string; + orientation: QuadrantType; + blips: Blip[]; } const QuadrantList: React.FC = ({ - name, - orientation, - blips, + name, + orientation, + blips, }) => { - const groupedBlips = Object.groupBy( - blips, - ({ depth }: { depth: number }) => depth - ); - - const { selectBlip, highlightBlip, highlightedBlip } = useRadarStore() - return ( -
-

{name}

-
- {Object.keys(groupedBlips).map(depth => { - const blips = (groupedBlips[Number(depth)] || []) as Blip[]; - - return ( -
-

{depth}

-
    - {blips.map((blip, i) => ( -
  • selectBlip(blip)} - onMouseEnter={() => highlightBlip(blip)} - onMouseLeave={() => highlightBlip(undefined)} - style={{ - listStyle: 'none', - textDecoration: blip.blipNumber === highlightedBlip?.blipNumber ? 'underline' : '', - }} - - > - {blip.blipNumber} - {blip.name} -
  • - ))} -
-
- ); - })} -
-
- ); -} + const groupedBlips = Object.groupBy( + blips, + ({ depth }: { depth: number }) => depth, + ); + + const { selectBlip, highlightBlip, highlightedBlip } = useRadarStore(); + return ( +
+

{name}

+
+ {Object.keys(groupedBlips).map((depth) => { + const blips = (groupedBlips[Number(depth)] || []) as Blip[]; + + return ( +
+

{depth}

+
    + {blips.map((blip, i) => ( +
  • selectBlip(blip)} + onMouseEnter={() => highlightBlip(blip)} + onMouseLeave={() => highlightBlip(undefined)} + style={{ + listStyle: "none", + textDecoration: + blip.blipNumber === highlightedBlip?.blipNumber + ? "underline" + : "", + }} + > + {blip.blipNumber} - {blip.name} +
  • + ))} +
+
+ ); + })} +
+
+ ); +}; type Props = { - /* - * List of 4 quadrants - */ - quadrants: [Quadrant, Quadrant, Quadrant, Quadrant]; + /* + * List of 4 quadrants + */ + quadrants: [Quadrant, Quadrant, Quadrant, Quadrant]; }; export const Radar: React.FC = ({ quadrants }) => { - const { - currentBlip, - } = useRadarStore() - - const maxDepth = 4; - const size = 480; - - - return ( - <> -
- - - - - - - - - - - - - - - -
- - {currentBlip && ( - - )} - - ); + const { currentBlip } = useRadarStore(); + + const maxDepth = 4; + const size = 480; + + return ( + <> +
+ + + + + + + + + + + + + + + +
+ + {currentBlip && } + + ); };