diff --git a/src/components/Radar/Chart.module.css b/src/components/Radar/Chart.module.css new file mode 100644 index 0000000..65438d6 --- /dev/null +++ b/src/components/Radar/Chart.module.css @@ -0,0 +1,9 @@ +.ringLabels { + text-transform: uppercase; +} + +@media (max-width: 767px) { + .ringLabels { + display: none; + } +} diff --git a/src/components/Radar/Chart.tsx b/src/components/Radar/Chart.tsx new file mode 100644 index 0000000..c7311d6 --- /dev/null +++ b/src/components/Radar/Chart.tsx @@ -0,0 +1,184 @@ +import Link from "next/link"; +import React, { FC, Fragment, memo } from "react"; + +import styles from "./Chart.module.css"; + +import { Blip } from "@/components/Radar/Blip"; +import { Item, Quadrant, Ring } from "@/lib/types"; + +export interface ChartProps { + size?: number; + quadrants: Quadrant[]; + rings: Ring[]; + items: Item[]; + className?: string; +} + +const _Chart: FC = ({ + size = 800, + quadrants = [], + rings = [], + items = [], + className, +}) => { + const viewBoxSize = size; + const center = size / 2; + const startAngles = [270, 0, 180, 90]; // Corresponding to positions 1, 2, 3, and 4 respectively + + // Helper function to convert polar coordinates to cartesian + const polarToCartesian = ( + radius: number, + angleInDegrees: number, + ): { x: number; y: number } => { + const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; + return { + x: Math.round(center + radius * Math.cos(angleInRadians)), + y: Math.round(center + radius * Math.sin(angleInRadians)), + }; + }; + + // Function to generate the path for a ring segment + const describeArc = (radiusPercentage: number, position: number): string => { + // Define the start and end angles based on the quadrant position + const startAngle = startAngles[position - 1]; + const endAngle = startAngle + 90; + + const radius = radiusPercentage * center; // Convert percentage to actual radius + const start = polarToCartesian(radius, endAngle); + const end = polarToCartesian(radius, startAngle); + + // prettier-ignore + return [ + "M", start.x, start.y, + "A", radius, radius, 0, 0, 0, end.x, end.y, + ].join(" "); + }; + + const renderGlow = (position: number, color: string) => { + const gradientId = `glow-${position}`; + + const cx = position === 1 || position === 3 ? 1 : 0; + const cy = position === 1 || position === 2 ? 1 : 0; + + const x = position === 1 || position === 3 ? 0 : center; + const y = position === 1 || position === 2 ? 0 : center; + return ( + <> + + + + + + + + + ); + }; + + // Function to place items inside their rings and quadrants + const renderItem = (item: Item) => { + const ring = rings.find((r) => r.id === item.ring); + const quadrant = quadrants.find((q) => q.id === item.quadrant); + if (!ring || !quadrant) return null; // If no ring or quadrant, don't render item + + const padding = 15; // Padding in pixels + const paddingAngle = 10; // Padding in degrees + + // Random factors to determine position within the ring + const [randomRadius, randomAngleFactor] = item.random || [ + Math.sqrt(Math.random()), + Math.random(), + ]; + const innerRadius = + (rings[rings.indexOf(ring) - 1]?.radius || 0) + padding / center; // Add inner padding + const outerRadius = (ring.radius || 1) - padding / center; // Subtract outer padding + const ringWidth = (outerRadius - innerRadius) * center; // Width of the ring in the SVG + + // Calculate the position within the ring + const itemRadius = innerRadius * center + randomRadius * ringWidth; + // Calculate the angle with padding offset, avoiding the exact edges + const startAngle = startAngles[quadrant.position - 1] + paddingAngle; + const endAngle = startAngle + 90 - 2 * paddingAngle; // Subtract padding from both sides + const itemAngle = startAngle + (endAngle - startAngle) * randomAngleFactor; + + // Convert polar coordinates to cartesian for the item's position + const { x, y } = polarToCartesian(itemRadius, itemAngle); + return ( + + + + ); + }; + + const renderRingLabels = () => { + return rings.map((ring, index) => { + const outerRadius = ring.radius || 1; + const innerRadius = rings[index - 1]?.radius || 0; + const position = ((outerRadius + innerRadius) / 2) * center; + + return ( + + + {ring.title} + + + {ring.title} + + + ); + }); + }; + + return ( + + {quadrants.map((quadrant) => ( + + {renderGlow(quadrant.position, quadrant.color)} + {rings.map((ring) => ( + + ))} + + ))} + {items.map((item) => renderItem(item))} + {renderRingLabels()} + + ); +}; + +export const Chart = memo(_Chart); diff --git a/src/components/Radar/Radar.module.css b/src/components/Radar/Radar.module.css index c3be43f..77f4cbf 100644 --- a/src/components/Radar/Radar.module.css +++ b/src/components/Radar/Radar.module.css @@ -4,7 +4,7 @@ position: relative; } -.svg { +.chart { display: block; max-width: 100%; height: auto; @@ -12,13 +12,57 @@ fill: currentColor; } -.ringLabels { - text-transform: uppercase; +.tooltip { + background-color: var(--tooltip, var(--background)); + color: var(--foreground); + font-size: 14px; + padding: 4px 8px; + height: fit-content; + width: fit-content; + border-radius: 6px; + position: absolute; + text-align: center; + opacity: 0; + transform: translate(-50%, -90%) scale(0.7); + transform-origin: 50% 100%; + transition: + all 100ms ease-in-out, + left 0ms, + top 0ms; + box-shadow: + 0 4px 14px 0 rgba(0, 0, 0, 0.2), + 0 0 0 1px rgba(0, 0, 0, 0.05); + pointer-events: none; + z-index: 1; + + &:before { + content: ""; + display: block; + position: absolute; + z-index: 2; + bottom: -1px; + left: 50%; + margin-left: -8px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid var(--tooltip, var(--background)); + transition: bottom 100ms ease-in-out; + } + + &.isShown { + opacity: 1; + transform: translate(-50%, -130%) scale(1); + + &:before { + bottom: -7px; + } + } } @media (max-width: 767px) { - .labels, - .ringLabels { + .labels { display: none; } } diff --git a/src/components/Radar/Radar.tsx b/src/components/Radar/Radar.tsx index 77fed81..e5b7da3 100644 --- a/src/components/Radar/Radar.tsx +++ b/src/components/Radar/Radar.tsx @@ -1,11 +1,22 @@ -import Link from "next/link"; -import React, { FC, Fragment } from "react"; +import React, { + CSSProperties, + FC, + MouseEvent, + useMemo, + useRef, + useState, +} from "react"; import styles from "./Radar.module.css"; -import { Blip } from "@/components/Radar/Blip"; +import { Chart } from "@/components/Radar/Chart"; import { Label } from "@/components/Radar/Label"; +import { getChartConfig } from "@/lib/data"; import { Item, Quadrant, Ring } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +const { blipSize } = getChartConfig(); +const halfBlipSize = blipSize / 2; export interface RadarProps { size?: number; @@ -20,163 +31,81 @@ export const Radar: FC = ({ rings = [], items = [], }) => { - const viewBoxSize = size; - const center = size / 2; - const startAngles = [270, 0, 180, 90]; // Corresponding to positions 1, 2, 3, and 4 respectively + const radarRef = useRef(null); + const [tooltip, setTooltip] = useState({ + show: false, + text: "", + color: "", + x: 0, + y: 0, + }); - // Helper function to convert polar coordinates to cartesian - const polarToCartesian = ( - radius: number, - angleInDegrees: number, - ): { x: number; y: number } => { - const angleInRadians = ((angleInDegrees - 90) * Math.PI) / 180.0; - return { - x: Math.round(center + radius * Math.cos(angleInRadians)), - y: Math.round(center + radius * Math.sin(angleInRadians)), - }; + const tooltipStyle = useMemo( + () => + ({ + left: tooltip.x, + top: tooltip.y, + ...(tooltip.color ? { "--tooltip": tooltip.color } : undefined), + }) as CSSProperties, + [tooltip], + ); + + const handleMouseMove = (e: MouseEvent) => { + const link = + e.target instanceof Element && e.target.closest("a[data-tooltip]"); + if (link) { + const text = link.getAttribute("data-tooltip") || ""; + const color = link.getAttribute("data-tooltip-color") || ""; + const linkRect = link.getBoundingClientRect(); + const radarRect = radarRef.current!.getBoundingClientRect(); + + // Adjusting tooltip position to be relative to the radar container + const x = linkRect.left - radarRect.left + halfBlipSize; + const y = linkRect.top - radarRect.top; + + setTooltip({ + text, + color, + show: !!text, + x, + y, + }); + } else { + if (tooltip.show) { + setTooltip({ ...tooltip, show: false }); + } + } }; - // Function to generate the path for a ring segment - const describeArc = (radiusPercentage: number, position: number): string => { - // Define the start and end angles based on the quadrant position - const startAngle = startAngles[position - 1]; - const endAngle = startAngle + 90; - - const radius = radiusPercentage * center; // Convert percentage to actual radius - const start = polarToCartesian(radius, endAngle); - const end = polarToCartesian(radius, startAngle); - - // prettier-ignore - return [ - "M", start.x, start.y, - "A", radius, radius, 0, 0, 0, end.x, end.y, - ].join(" "); - }; - - const renderGlow = (position: number, color: string) => { - const gradientId = `glow-${position}`; - - const cx = position === 1 || position === 3 ? 1 : 0; - const cy = position === 1 || position === 2 ? 1 : 0; - - const x = position === 1 || position === 3 ? 0 : center; - const y = position === 1 || position === 2 ? 0 : center; - return ( - <> - - - - - - - - - ); - }; - - // Function to place items inside their rings and quadrants - const renderItem = (item: Item) => { - const ring = rings.find((r) => r.id === item.ring); - const quadrant = quadrants.find((q) => q.id === item.quadrant); - if (!ring || !quadrant) return null; // If no ring or quadrant, don't render item - - const padding = 15; // Padding in pixels - const paddingAngle = 10; // Padding in degrees - - // Random factors to determine position within the ring - const [randomRadius, randomAngleFactor] = item.random || [ - Math.sqrt(Math.random()), - Math.random(), - ]; - const innerRadius = - (rings[rings.indexOf(ring) - 1]?.radius || 0) + padding / center; // Add inner padding - const outerRadius = (ring.radius || 1) - padding / center; // Subtract outer padding - const ringWidth = (outerRadius - innerRadius) * center; // Width of the ring in the SVG - - // Calculate the position within the ring - const itemRadius = innerRadius * center + randomRadius * ringWidth; - // Calculate the angle with padding offset, avoiding the exact edges - const startAngle = startAngles[quadrant.position - 1] + paddingAngle; - const endAngle = startAngle + 90 - 2 * paddingAngle; // Subtract padding from both sides - const itemAngle = startAngle + (endAngle - startAngle) * randomAngleFactor; - - // Convert polar coordinates to cartesian for the item's position - const { x, y } = polarToCartesian(itemRadius, itemAngle); - return ( - - - - ); - }; - - const renderRingLabels = () => { - return rings.map((ring, index) => { - const outerRadius = ring.radius || 1; - const innerRadius = rings[index - 1]?.radius || 0; - const position = ((outerRadius + innerRadius) / 2) * center; - - return ( - - - {ring.title} - - - {ring.title} - - - ); - }); + const handleMouseLeave = () => { + setTooltip({ ...tooltip, show: false }); }; return ( -
- - {quadrants.map((quadrant) => ( - - {renderGlow(quadrant.position, quadrant.color)} - {rings.map((ring) => ( - - ))} - - ))} - {items.map((item) => renderItem(item))} - {renderRingLabels()} - +
+
{quadrants.map((quadrant) => (
+ + {tooltip.text} +
); };