+ "content": "import * as React from \"react\"\nimport { createMap } from \"svg-dotted-map\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport interface Marker {\n lat: number\n lng: number\n size?: number\n pulse?: boolean\n}\n\n/** addMarkers returns markers with lat/lng removed; only x, y and other props (e.g. size) remain */\ntype MapMarker<M extends Marker> = Omit<M, \"lat\" | \"lng\"> & {\n x: number\n y: number\n}\n\nexport interface DottedMapProps<\n M extends Marker = Marker,\n> extends React.SVGProps<SVGSVGElement> {\n width?: number\n height?: number\n mapSamples?: number\n markers?: M[]\n dotColor?: string\n markerColor?: string\n dotRadius?: number\n stagger?: boolean\n pulse?: boolean\n\n renderMarkerOverlay?: (args: {\n marker: MapMarker<M>\n index: number\n x: number\n y: number\n r: number\n }) => React.ReactNode\n}\n\nexport function DottedMap<M extends Marker = Marker>({\n width = 150,\n height = 75,\n mapSamples = 5000,\n markers = [],\n dotColor = \"currentColor\",\n markerColor = \"#FF6900\",\n dotRadius = 0.2,\n stagger = true,\n pulse = false,\n renderMarkerOverlay,\n className,\n style,\n ...svgProps\n}: DottedMapProps<M>) {\n const { points, addMarkers } = createMap({\n width,\n height,\n mapSamples,\n })\n const processedMarkers = addMarkers(markers)\n\n // Compute stagger helpers in a single, simple pass\n const { xStep, yToRowIndex } = React.useMemo(() => {\n const sorted = [...points].sort((a, b) => a.y - b.y || a.x - b.x)\n const rowMap = new Map<number, number>()\n let step = 0\n let prevY = Number.NaN\n let prevXInRow = Number.NaN\n\n for (const p of sorted) {\n if (p.y !== prevY) {\n // new row\n prevY = p.y\n prevXInRow = Number.NaN\n if (!rowMap.has(p.y)) rowMap.set(p.y, rowMap.size)\n }\n if (!Number.isNaN(prevXInRow)) {\n const delta = p.x - prevXInRow\n if (delta > 0) step = step === 0 ? delta : Math.min(step, delta)\n }\n prevXInRow = p.x\n }\n\n return { xStep: step || 1, yToRowIndex: rowMap }\n }, [points])\n\n return (\n <svg\n viewBox={`0 0 ${width} ${height}`}\n className={cn(\"text-gray-500 dark:text-gray-500\", className)}\n style={{ width: \"100%\", height: \"100%\", ...style }}\n {...svgProps}\n >\n {points.map((point, index) => {\n const rowIndex = yToRowIndex.get(point.y) ?? 0\n const offsetX = stagger && rowIndex % 2 === 1 ? xStep / 2 : 0\n return (\n <circle\n cx={point.x + offsetX}\n cy={point.y}\n r={dotRadius}\n fill={dotColor}\n key={`${point.x}-${point.y}-${index}`}\n />\n )\n })}\n\n {processedMarkers.map((marker, index) => {\n const rowIndex = yToRowIndex.get(marker.y) ?? 0\n const offsetX = stagger && rowIndex % 2 === 1 ? xStep / 2 : 0\n\n const x = marker.x + offsetX\n const y = marker.y\n const r = marker.size ?? dotRadius\n const shouldPulse = pulse\n ? marker.pulse !== false\n : marker.pulse === true\n const pulseTo = r * 2.8\n\n return (\n <g key={`${marker.x}-${marker.y}-${index}`}>\n <circle cx={x} cy={y} r={r} fill={markerColor} />\n\n {shouldPulse ? (\n <g pointerEvents=\"none\">\n <circle\n cx={x}\n cy={y}\n r={r}\n fill=\"none\"\n stroke={markerColor}\n strokeOpacity={1}\n strokeWidth={0.35}\n >\n <animate\n attributeName=\"r\"\n values={`${r};${pulseTo}`}\n dur=\"1.4s\"\n repeatCount=\"indefinite\"\n />\n <animate\n attributeName=\"opacity\"\n values=\"1;0\"\n dur=\"1.4s\"\n repeatCount=\"indefinite\"\n />\n </circle>\n <circle\n cx={x}\n cy={y}\n r={r}\n fill=\"none\"\n stroke={markerColor}\n strokeOpacity={0.9}\n strokeWidth={0.3}\n >\n <animate\n attributeName=\"r\"\n values={`${r};${pulseTo}`}\n dur=\"1.4s\"\n begin=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n <animate\n attributeName=\"opacity\"\n values=\"0.9;0\"\n dur=\"1.4s\"\n begin=\"0.7s\"\n repeatCount=\"indefinite\"\n />\n </circle>\n </g>\n ) : null}\n\n {renderMarkerOverlay?.({\n marker: { ...marker, x, y },\n index,\n x,\n y,\n r,\n })}\n </g>\n )\n })}\n </svg>\n )\n}\n",
0 commit comments