{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "fluid-map", "title": "Fluid Map", "description": "A dotted world map with clockwise swirl physics on mouse interaction. Dots animate with spring-damping and settle like dust.", "dependencies": [ "svg-dotted-map", "clsx", "tailwind-merge" ], "files": [ { "path": "registry/default/fluid-map/fluid-map.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { createMap } from \"svg-dotted-map\";\nimport { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nfunction cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n\nexport interface Marker {\n lat: number;\n lng: number;\n size?: number;\n pulse?: boolean;\n}\n\ntype MapMarker = Omit & {\n x: number;\n y: number;\n};\n\nexport interface FluidMapProps<\n M extends Marker = Marker,\n> extends React.SVGProps {\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 /** Enable swirl physics on mouse interaction */\n fluid?: boolean;\n /** Radius of the influence area around the cursor (in SVG units) */\n fluidRadius?: number;\n /** Strength of the swirl force */\n fluidStrength?: number;\n renderMarkerOverlay?: (args: {\n marker: MapMarker;\n index: number;\n x: number;\n y: number;\n r: number;\n }) => React.ReactNode;\n}\n\nexport function FluidMap({\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 fluid = true,\n fluidRadius = 20,\n fluidStrength = 0.4,\n renderMarkerOverlay,\n className,\n style,\n ...svgProps\n}: FluidMapProps) {\n const svgRef = React.useRef(null);\n const dotsGroupRef = React.useRef(null);\n\n const { points, processedMarkers, xStep, yToRowIndex } = React.useMemo(() => {\n const { points: pts, addMarkers } = createMap({ width, height, mapSamples });\n const pm = addMarkers(markers);\n\n const sorted = [...pts].sort((a, b) => a.y - b.y || a.x - b.x);\n const rowMap = new Map();\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 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 { points: pts, processedMarkers: pm, xStep: step || 1, yToRowIndex: rowMap };\n }, [width, height, mapSamples, markers]);\n\n React.useEffect(() => {\n if (!fluid) return;\n const svg = svgRef.current;\n const dotsGroup = dotsGroupRef.current;\n if (!svg || !dotsGroup) return;\n\n const dots = dotsGroup.children;\n const count = dots.length;\n\n const dx = new Float32Array(count);\n const dy = new Float32Array(count);\n const vx = new Float32Array(count);\n const vy = new Float32Array(count);\n const ox = new Float32Array(count);\n const oy = new Float32Array(count);\n\n for (let i = 0; i < count; i++) {\n const el = dots[i] as SVGCircleElement;\n ox[i] = parseFloat(el.getAttribute(\"data-ox\") || \"0\");\n oy[i] = parseFloat(el.getAttribute(\"data-oy\") || \"0\");\n }\n\n const mouse = { x: 0, y: 0, active: false, wasActive: false };\n const activeSet = new Set();\n\n const SPRING = 0.05;\n const DAMPING = 0.78;\n const SNAP = 0.002;\n const LEAVE_DAMPING = 0.92;\n const R = fluidRadius;\n const R2 = R * R;\n const STRENGTH = fluidStrength;\n\n function screenToSVG(clientX: number, clientY: number) {\n const ctm = svg!.getScreenCTM();\n if (!ctm) return null;\n const inv = ctm.inverse();\n return {\n x: inv.a * clientX + inv.c * clientY + inv.e,\n y: inv.b * clientX + inv.d * clientY + inv.f,\n };\n }\n\n function onPointerMove(e: PointerEvent) {\n const rect = svg!.getBoundingClientRect();\n if (\n e.clientX >= rect.left &&\n e.clientX <= rect.right &&\n e.clientY >= rect.top &&\n e.clientY <= rect.bottom\n ) {\n const pos = screenToSVG(e.clientX, e.clientY);\n if (pos) {\n mouse.x = pos.x;\n mouse.y = pos.y;\n mouse.active = true;\n }\n } else {\n mouse.active = false;\n }\n }\n\n function onPointerLeave() {\n mouse.wasActive = mouse.active;\n mouse.active = false;\n }\n\n document.addEventListener(\"pointermove\", onPointerMove);\n document.addEventListener(\"pointerleave\", onPointerLeave);\n\n let rafId: number;\n\n function animate() {\n if (mouse.active) {\n const scanR2 = R * 1.5 * (R * 1.5);\n for (let i = 0; i < count; i++) {\n const distX = ox[i] - mouse.x;\n const distY = oy[i] - mouse.y;\n if (distX * distX + distY * distY < scanR2) activeSet.add(i);\n }\n }\n\n const toRemove: number[] = [];\n\n for (const i of activeSet) {\n if (mouse.active) {\n const curX = ox[i] + dx[i];\n const curY = oy[i] + dy[i];\n const distX = curX - mouse.x;\n const distY = curY - mouse.y;\n const dist2 = distX * distX + distY * distY;\n\n if (dist2 < R2 && dist2 > 0.0001) {\n const dist = Math.sqrt(dist2);\n const t = 1 - dist / R;\n const force = STRENGTH * t * t;\n\n // Repulsive force pushing dots away from cursor\n vx[i] += (distX / dist) * force;\n vy[i] += (distY / dist) * force;\n }\n }\n\n vx[i] += -SPRING * dx[i];\n vy[i] += -SPRING * dy[i];\n const currentDamping = mouse.active ? DAMPING : LEAVE_DAMPING;\n vx[i] *= currentDamping;\n vy[i] *= currentDamping;\n dx[i] += vx[i];\n dy[i] += vy[i];\n\n const disp = Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]);\n\n if (disp < SNAP && Math.abs(vx[i]) < SNAP && Math.abs(vy[i]) < SNAP) {\n dx[i] = dy[i] = vx[i] = vy[i] = 0;\n const el = dots[i] as SVGCircleElement;\n el.setAttribute(\"cx\", String(ox[i]));\n el.setAttribute(\"cy\", String(oy[i]));\n toRemove.push(i);\n } else {\n const el = dots[i] as SVGCircleElement;\n el.setAttribute(\"cx\", String(ox[i] + dx[i]));\n el.setAttribute(\"cy\", String(oy[i] + dy[i]));\n }\n }\n\n for (const i of toRemove) activeSet.delete(i);\n\n rafId = requestAnimationFrame(animate);\n }\n\n rafId = requestAnimationFrame(animate);\n\n return () => {\n cancelAnimationFrame(rafId);\n document.removeEventListener(\"pointermove\", onPointerMove);\n document.removeEventListener(\"pointerleave\", onPointerLeave);\n for (let i = 0; i < count; i++) {\n const el = dots[i] as SVGCircleElement;\n el.setAttribute(\"cx\", String(ox[i]));\n el.setAttribute(\"cy\", String(oy[i]));\n }\n };\n }, [fluid, fluidRadius, fluidStrength, dotColor, dotRadius]);\n\n return (\n \n \n \n \n \n \n \n \n \n \n \n\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 const cx = point.x + offsetX;\n const cy = point.y;\n return (\n \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 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 \n \n {shouldPulse && (\n \n \n \n \n \n \n \n \n \n \n )}\n {renderMarkerOverlay?.({\n marker: { ...marker, x, y },\n index,\n x,\n y,\n r,\n })}\n \n );\n })}\n \n );\n}\n\nexport default FluidMap;\n", "type": "registry:component" } ], "type": "registry:component" }