Created
August 28, 2025 16:22
-
-
Save connormcmk/d905896de74ca581aeeac84be3e01cea to your computer and use it in GitHub Desktop.
Point Edges
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; | |
| import { Eye, EyeOff } from "lucide-react"; | |
| import { motion } from "framer-motion"; | |
| /** | |
| * Demo: Two PointCards connected by a band. | |
| * - Drag the bottom card OR use the angle slider to move it along an arc. | |
| * - Modes: | |
| * 1) Designer (manual attenuation) | |
| * 2) Min-surface (catenary-like profile from surface-tension vs axial tension) | |
| * 3) Viscous thinning (slender-jet inspired, viscosity + surface-tension + time) | |
| * - Anchors at the **geometric center** of each card's inner bubble. | |
| * - Marching dots toward the midpoint with two mapping modes. | |
| * - Respects prefers-reduced-motion: dots stop and density increases instead of speed. | |
| */ | |
| export default function Demo() { | |
| const topRef = useRef<HTMLDivElement | null>(null); | |
| const bottomRef = useRef<HTMLDivElement | null>(null); | |
| const containerRef = useRef<HTMLDivElement | null>(null); | |
| // Centers (in container coordinates) | |
| const [topCenter] = useState<{ x: number; y: number }>({ x: 520, y: 150 }); | |
| const [bottomCenter, setBottomCenter] = useState<{ x: number; y: number }>({ x: 560, y: 420 }); | |
| const [epoch, setEpoch] = useState(0); // bump when bottom moves so connector recomputes | |
| // Angle control (for arc test) | |
| const [angleDeg, setAngleDeg] = useState(45); | |
| const radius = useMemo( | |
| () => Math.hypot(bottomCenter.x - topCenter.x, bottomCenter.y - topCenter.y), | |
| [bottomCenter, topCenter] | |
| ); | |
| // --- Mode & controls --- | |
| type Mode = "designer" | "min" | "viscous"; | |
| const [mode, setMode] = useState<Mode>("viscous"); | |
| // Shared visual controls | |
| const [widthPx, setWidthPx] = useState(14); // used when NOT conserving volume | |
| const [conserveVol, setConserveVol] = useState(true); // area conservation toggle | |
| const [areaPx, setAreaPx] = useState(2600); // target area when conserving | |
| // Designer attenuation controls | |
| const [attenDepth, setAttenDepth] = useState(0.7); // 0..1 -> how thin at midpoint | |
| const [attenStart, setAttenStart] = useState(0.4); // 0.05..3.0 -> where attenuation starts (spread) | |
| const [attenSteep, setAttenSteep] = useState(1.0); // 0.25..8 -> steepness of the attenuation curve | |
| // Min-surface parameters (catenary-like) | |
| const [sigmaMin, setSigmaMin] = useState(1.0); // surface tension (arb. units) | |
| const [tension, setTension] = useState(4.0); // axial tension (arb. units) | |
| // Viscous thinning parameters (slender-jet inspired) | |
| const [sigma, setSigma] = useState(1.0); // surface tension | |
| const [eta, setEta] = useState(5.0); // viscosity | |
| const [timeSec, setTimeSec] = useState(1.0); // time dial | |
| // --- Marching dots params --- | |
| const [topScore, setTopScore] = useState(60); | |
| const [bottomScore, setBottomScore] = useState(40); | |
| const [relevance, setRelevance] = useState(70); | |
| type FlowMode = "A_widthByRelevance" | "B_widthByScore"; | |
| const [flowMode, setFlowMode] = useState<FlowMode>("B_widthByScore"); | |
| const moveByAngle = (deg: number) => { | |
| if (!containerRef.current) return; | |
| const rad = (deg * Math.PI) / 180; | |
| const x = topCenter.x + radius * Math.cos(rad); | |
| const y = topCenter.y + radius * Math.sin(rad); | |
| setBottomCenter({ x, y }); | |
| setEpoch((e) => e + 1); | |
| }; | |
| // Drag handling for bottom card (center-anchored) | |
| const dragState = useRef<{ active: boolean; dx: number; dy: number } | null>(null); | |
| const onPointerDown = (e: React.PointerEvent) => { | |
| if (!containerRef.current) return; | |
| const rect = containerRef.current.getBoundingClientRect(); | |
| const cx = e.clientX - rect.left; | |
| const cy = e.clientY - rect.top; | |
| dragState.current = { active: true, dx: bottomCenter.x - cx, dy: bottomCenter.y - cy }; | |
| (e.target as Element).setPointerCapture?.(e.pointerId); | |
| }; | |
| const onPointerMove = (e: React.PointerEvent) => { | |
| if (!containerRef.current) return; | |
| if (!dragState.current?.active) return; | |
| const rect = containerRef.current.getBoundingClientRect(); | |
| const cx = e.clientX - rect.left; | |
| const cy = e.clientY - rect.top; | |
| setBottomCenter({ x: cx + dragState.current.dx, y: cy + dragState.current.dy }); | |
| setEpoch((v) => v + 1); | |
| }; | |
| const endDrag = () => { | |
| if (dragState.current) dragState.current.active = false; | |
| }; | |
| // --- Smoke tests ("test cases") --- | |
| useEffect(() => { | |
| console.groupCollapsed("Elastic band smoke tests"); | |
| console.assert(!!containerRef.current, "Container ref should be set"); | |
| console.assert(!!topRef.current && !!bottomRef.current, "PointCard refs should be set"); | |
| const svg = document.querySelector("svg[preserveAspectRatio='none']"); | |
| console.assert(!!svg, "Connector SVG should exist"); | |
| // Verify we hit the geometric centers (±2px) | |
| const svgEl = document.querySelector('svg[data-start][data-end]') as SVGSVGElement | null; | |
| if (svgEl && containerRef.current && topRef.current && bottomRef.current) { | |
| const [sx, sy] = (svgEl.getAttribute('data-start') || '0,0').split(',').map(Number); | |
| const [ex, ey] = (svgEl.getAttribute('data-end') || '0,0').split(',').map(Number); | |
| const c = containerRef.current.getBoundingClientRect(); | |
| const getCenter = (root: HTMLElement) => { | |
| const box = (root.querySelector('[data-anchor-box]') as HTMLElement | null)?.getBoundingClientRect() || root.getBoundingClientRect(); | |
| return { x: box.left - c.left + box.width / 2, y: box.top - c.top + box.height / 2 }; | |
| }; | |
| const a = getCenter(topRef.current!); | |
| const b = getCenter(bottomRef.current!); | |
| const near = (x: number, y: number, X: number, Y: number) => Math.hypot(x - X, y - Y) <= 2.1; | |
| console.assert(near(sx, sy, a.x, a.y), 'Start must hit geometric center'); | |
| console.assert(near(ex, ey, b.x, b.y), 'End must hit geometric center'); | |
| } | |
| const hasPath = !!document.querySelector('path[d]'); | |
| console.assert(hasPath, 'Connector path(s) should be present'); | |
| console.groupEnd(); | |
| }, [epoch, widthPx, conserveVol, areaPx, attenDepth, attenStart, attenSteep, sigmaMin, tension, sigma, eta, timeSec, mode, topScore, bottomScore, relevance, flowMode]); | |
| return ( | |
| <div className="min-h-screen w-full bg-white [background-image:radial-gradient(black_1px,transparent_1px)] [background-size:24px_24px] flex items-center justify-center p-10"> | |
| <div ref={containerRef} className="relative w-full max-w-5xl h-[700px] rounded-xl"> | |
| {/* Controls */} | |
| <div className="absolute right-2 top-2 z-20 rounded-lg border border-slate-200 bg-white/90 p-3 shadow-sm backdrop-blur space-y-3"> | |
| <div className="text-sm font-medium text-slate-700">Experiment</div> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Mode</span> | |
| <select className="border rounded px-2 py-1" value={mode} onChange={(e) => setMode(e.target.value as Mode)}> | |
| <option value="designer">Designer</option> | |
| <option value="min">Min surface</option> | |
| <option value="viscous">Viscous thinning</option> | |
| </select> | |
| </label> | |
| {/* Marching dots mapping */} | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Flow mapping</span> | |
| <select className="border rounded px-2 py-1" value={flowMode} onChange={(e) => setFlowMode(e.target.value as FlowMode)}> | |
| <option value="A_widthByRelevance">A: width ⇐ relevance; speed ⇐ score</option> | |
| <option value="B_widthByScore">B: width ⇐ score; speed ⇐ relevance</option> | |
| </select> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Top score</span> | |
| <input type="range" min={0} max={100} value={topScore} onChange={(e) => setTopScore(Number(e.target.value))} /> | |
| <span className="tabular-nums w-10 text-right">{topScore}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Bottom score</span> | |
| <input type="range" min={0} max={100} value={bottomScore} onChange={(e) => setBottomScore(Number(e.target.value))} /> | |
| <span className="tabular-nums w-10 text-right">{bottomScore}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Relevance</span> | |
| <input type="range" min={0} max={100} value={relevance} onChange={(e) => setRelevance(Number(e.target.value))} /> | |
| <span className="tabular-nums w-10 text-right">{relevance}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Angle</span> | |
| <input type="range" min={0} max={360} value={angleDeg} onChange={(e) => { const v = Number(e.target.value); setAngleDeg(v); moveByAngle(v); }} /> | |
| <span className="tabular-nums w-12 text-right">{angleDeg}°</span> | |
| </label> | |
| {mode !== 'viscous' && ( | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Width (px)</span> | |
| <input type="range" min={2} max={40} value={widthPx} onChange={(e) => setWidthPx(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{widthPx}</span> | |
| </label> | |
| )} | |
| {/* Designer controls */} | |
| {mode === 'designer' && ( | |
| <div className="space-y-2"> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Atten depth</span> | |
| <input type="range" min={0} max={1} step={0.01} value={attenDepth} onChange={(e) => setAttenDepth(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{attenDepth.toFixed(2)}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Atten start</span> | |
| <input type="range" min={0.05} max={3.0} step={0.01} value={attenStart} onChange={(e) => setAttenStart(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{attenStart.toFixed(2)}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Atten steepness</span> | |
| <input type="range" min={0.25} max={8} step={0.05} value={attenSteep} onChange={(e) => setAttenSteep(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{attenSteep.toFixed(2)}</span> | |
| </label> | |
| </div> | |
| )} | |
| {/* Min-surface controls */} | |
| {mode === 'min' && ( | |
| <div className="space-y-2"> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Surface tension σ</span> | |
| <input type="range" min={0.2} max={5} step={0.1} value={sigmaMin} onChange={(e) => setSigmaMin(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{sigmaMin.toFixed(1)}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Axial tension T</span> | |
| <input type="range" min={0.2} max={10} step={0.1} value={tension} onChange={(e) => setTension(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{tension.toFixed(1)}</span> | |
| </label> | |
| </div> | |
| )} | |
| {/* Viscous thinning controls */} | |
| {mode === 'viscous' && ( | |
| <div className="space-y-2"> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Surface tension σ</span> | |
| <input type="range" min={0.2} max={5} step={0.1} value={sigma} onChange={(e) => setSigma(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{sigma.toFixed(1)}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Viscosity η</span> | |
| <input type="range" min={0.2} max={20} step={0.1} value={eta} onChange={(e) => setEta(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{eta.toFixed(1)}</span> | |
| </label> | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Time</span> | |
| <input type="range" min={0} max={5} step={0.05} value={timeSec} onChange={(e) => setTimeSec(Number(e.target.value))} /> | |
| <span className="tabular-nums w-12 text-right">{timeSec.toFixed(2)}s</span> | |
| </label> | |
| </div> | |
| )} | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <input type="checkbox" checked={conserveVol} onChange={(e) => setConserveVol(e.target.checked)} /> | |
| <span>Conserve volume (area)</span> | |
| </label> | |
| {conserveVol && ( | |
| <label className="flex items-center gap-3 text-sm text-slate-600"> | |
| <span className="min-w-28">Area (px²)</span> | |
| <input type="range" min={200} max={16000} step={10} value={areaPx} onChange={(e) => setAreaPx(Number(e.target.value))} /> | |
| <span className="tabular-nums w-16 text-right">{areaPx}</span> | |
| </label> | |
| )} | |
| <div className="mt-1 text-xs text-slate-500">Drag the bottom card too. Honors your OS “reduced motion” setting.</div> | |
| </div> | |
| {/* Cards (absolute, center-anchored) */} | |
| <PointCard | |
| refEl={topRef} | |
| text="Here I have made a new point" | |
| className="absolute -translate-x-1/2 -translate-y-1/2" | |
| style={{ left: topCenter.x, top: topCenter.y }} | |
| /> | |
| <PointCard | |
| refEl={bottomRef} | |
| text="This is a counterpoint that challenges the claim" | |
| variant="counterpoint" | |
| className="absolute -translate-x-1/2 -translate-y-1/2 cursor-grab active:cursor-grabbing select-none" | |
| style={{ left: bottomCenter.x, top: bottomCenter.y }} | |
| onPointerDown={onPointerDown} | |
| onPointerMove={onPointerMove} | |
| onPointerUp={endDrag} | |
| onPointerCancel={endDrag} | |
| /> | |
| {/* Connector overlay */} | |
| <ElasticConnector | |
| containerRef={containerRef} | |
| fromRef={topRef} | |
| toRef={bottomRef} | |
| epoch={epoch} | |
| widthPx={widthPx} | |
| conserveVol={conserveVol} | |
| areaPx={areaPx} | |
| // Designer | |
| attenDepth={attenDepth} | |
| attenStart={attenStart} | |
| attenSteep={attenSteep} | |
| // Min-surface | |
| sigmaMin={sigmaMin} | |
| tension={tension} | |
| // Viscous | |
| sigma={sigma} | |
| eta={eta} | |
| timeSec={timeSec} | |
| mode={mode} | |
| // Dots | |
| topScore={topScore} | |
| bottomScore={bottomScore} | |
| relevance={relevance} | |
| flowMode={flowMode} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| /** Connector draws a variable-width band along a STRAIGHT centerline, anchored at geometric centers */ | |
| function ElasticConnector({ | |
| containerRef, | |
| fromRef, | |
| toRef, | |
| epoch, | |
| widthPx, | |
| conserveVol, | |
| areaPx, | |
| // Designer | |
| attenDepth, | |
| attenStart, | |
| attenSteep, | |
| // Min-surface | |
| sigmaMin, | |
| tension, | |
| // Viscous | |
| sigma, | |
| eta, | |
| timeSec, | |
| mode, | |
| // Dots | |
| topScore, | |
| bottomScore, | |
| relevance, | |
| flowMode, | |
| }: { | |
| containerRef: React.MutableRefObject<HTMLDivElement | null>; | |
| fromRef: React.MutableRefObject<HTMLDivElement | null>; | |
| toRef: React.MutableRefObject<HTMLDivElement | null>; | |
| epoch?: number; | |
| widthPx: number; // used when not conserving volume | |
| conserveVol: boolean; | |
| areaPx: number; | |
| // Designer | |
| attenDepth: number; | |
| attenStart: number; | |
| attenSteep: number; | |
| // Min-surface | |
| sigmaMin: number; // surface tension (arb.) | |
| tension: number; // axial tension (arb.) | |
| // Viscous | |
| sigma: number; | |
| eta: number; | |
| timeSec: number; | |
| mode: "designer" | "min" | "viscous"; | |
| // Dots | |
| topScore: number; | |
| bottomScore: number; | |
| relevance: number; | |
| flowMode: "A_widthByRelevance" | "B_widthByScore"; | |
| }) { | |
| const [box, setBox] = useState<{ width: number; height: number } | null>(null); | |
| const [shapePath, setShapePath] = useState<string | null>(null); | |
| const [dbg, setDbg] = useState<{ sx: number; sy: number; ex: number; ey: number } | null>(null); | |
| const [meta, setMeta] = useState<null | { sx:number; sy:number; ex:number; ey:number; L:number; ux:number; uy:number; w:number[]; N:number }>(null); | |
| const [tick, setTick] = useState(0); | |
| // Reduced motion detection | |
| const [reduced, setReduced] = useState(false); | |
| useEffect(() => { | |
| const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); | |
| const update = () => setReduced(mq.matches); | |
| update(); | |
| mq.addEventListener('change', update); | |
| return () => mq.removeEventListener('change', update); | |
| }, []); | |
| // Animation clock for marching dots; pause when reduced | |
| useEffect(() => { | |
| if (reduced) return; // no loop for reduced motion | |
| let raf = 0; | |
| const loop = () => { setTick((t) => (t + 1) % 1_000_000); raf = requestAnimationFrame(loop); }; | |
| raf = requestAnimationFrame(loop); | |
| return () => cancelAnimationFrame(raf); | |
| }, [reduced]); | |
| // Shared constants | |
| const NECK_PX = 24; // distance from each anchor over which we ramp from 0 to full width (in px) | |
| const MIN_CORE_PX = 2.0; // hard floor for the strap away from the anchors | |
| const MAX_W = 28; // safety clamp to avoid spikes when nodes are close | |
| const compute = () => { | |
| if (!containerRef.current || !fromRef.current || !toRef.current) return; | |
| const aRoot = fromRef.current as HTMLElement; | |
| const bRoot = toRef.current as HTMLElement; | |
| const c = containerRef.current.getBoundingClientRect(); | |
| const aBoxEl = aRoot.querySelector('[data-anchor-box]') as HTMLElement | null; | |
| const bBoxEl = bRoot.querySelector('[data-anchor-box]') as HTMLElement | null; | |
| const aBox = aBoxEl?.getBoundingClientRect() || aRoot.getBoundingClientRect(); | |
| const bBox = bBoxEl?.getBoundingClientRect() || bRoot.getBoundingClientRect(); | |
| // geometric centers of bubbles | |
| let sx = aBox.left + aBox.width / 2; | |
| let sy = aBox.top + aBox.height / 2; | |
| let ex = bBox.left + bBox.width / 2; | |
| let ey = bBox.top + bBox.height / 2; | |
| // Convert to container coords | |
| sx -= c.left; sy -= c.top; ex -= c.left; ey -= c.top; | |
| // Straight centerline between anchors | |
| const dx = ex - sx, dy = ey - sy; | |
| const L = Math.hypot(dx, dy) || 1; | |
| const ux = dx / L, uy = dy / L; // unit direction | |
| const nx = -dy / L, ny = dx / L; // unit normal | |
| if (L < 4) { | |
| setBox({ width: c.width, height: c.height }); | |
| setShapePath(null); | |
| setDbg({ sx, sy, ex, ey }); | |
| setMeta(null); | |
| return; | |
| } | |
| const N = 128; // high for smoother integral | |
| const topPts: Array<[number, number]> = []; | |
| const botPts: Array<[number, number]> = []; | |
| // --- Build normalized shape g(t) in [0,1] depending on mode --- | |
| const g: number[] = []; | |
| const tStep = 1 / N; | |
| // Smoothstep utility (single definition) | |
| const smoothStep = (x: number) => (x <= 0 ? 0 : x >= 1 ? 1 : x * x * (3 - 2 * x)); | |
| if (mode === "designer") { | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const r1 = smoothStep((L * t) / NECK_PX); | |
| const r2 = smoothStep((L * (1 - t)) / NECK_PX); | |
| const base = Math.min(1, r1) * Math.min(1, r2); | |
| const bellBase = Math.exp(-Math.pow((t - 0.5) / attenStart, 2)); | |
| const bell = Math.pow(bellBase, attenSteep); | |
| g.push(Math.max(0, base * (1 - attenDepth * bell))); | |
| } | |
| } else if (mode === "min") { | |
| const lam = Math.max(0.05, Math.min(1.5, (sigmaMin / Math.max(0.1, tension)) * 0.4)); | |
| const C = Math.cosh(0.5 / lam); | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const val = (C - Math.cosh((t - 0.5) / lam)) / (C - 1); | |
| const r1 = smoothStep((L * t) / NECK_PX); | |
| const r2 = smoothStep((L * (1 - t)) / NECK_PX); | |
| const base = Math.min(1, r1) * Math.min(1, r2); | |
| g.push(Math.max(0, Math.max(0, val) * base)); | |
| } | |
| } else { | |
| const k = (sigma / Math.max(0.2, eta)) * timeSec * 0.25; // tune factor 0.25 for UX range | |
| const depthPhys = Math.max(0, Math.min(1, k)); | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const r1 = smoothStep((L * t) / NECK_PX); | |
| const r2 = smoothStep((L * (1 - t)) / NECK_PX); | |
| const base = Math.min(1, r1) * Math.min(1, r2); | |
| const bellBase = Math.exp(-Math.pow((t - 0.5) / Math.max(0.1, attenStart), 2)); | |
| const bell = Math.pow(bellBase, Math.max(0.25, attenSteep)); | |
| g.push(Math.max(0, base * (1 - depthPhys * bell))); | |
| } | |
| } | |
| // Integral of g(t) dt over [0,1] | |
| let gInt = 0; | |
| for (let i = 1; i <= N; i++) gInt += 0.5 * (g[i - 1] + g[i]) * tStep; // trapezoid | |
| // Determine width scale | |
| let scale = conserveVol ? areaPx / (L * Math.max(1e-6, gInt)) : widthPx; | |
| if (conserveVol) scale = Math.min(scale, MAX_W); | |
| // Apply mapping for width | |
| const mapScore = (s: number) => 0.5 + s / 100; // 0..100 ⇒ 0.5..1.5 | |
| const relScale = mapScore(relevance); | |
| const topScale = mapScore(topScore); | |
| const botScale = mapScore(bottomScore); | |
| const wArr: number[] = []; | |
| const edgeSigma = 0.08; // for bulbs | |
| const bump: number[] = []; | |
| let bumpMax = 0; | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const b = Math.exp(-Math.pow(t / edgeSigma, 2)) + Math.exp(-Math.pow((1 - t) / edgeSigma, 2)); | |
| bump.push(b); | |
| if (b > bumpMax) bumpMax = b; | |
| } | |
| for (let i = 0; i <= N; i++) bump[i] = bump[i] / (bumpMax || 1); | |
| const coreMask: number[] = []; | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const r1 = smoothStep((L * t) / NECK_PX); | |
| const r2 = smoothStep((L * (1 - t)) / NECK_PX); | |
| coreMask.push(Math.min(1, r1) * Math.min(1, r2)); | |
| } | |
| const midIndex = Math.floor(N / 2); | |
| const wMidPred = scale * g[midIndex]; | |
| const bulbGain = Math.max(0, MIN_CORE_PX - wMidPred) * 1.25; | |
| for (let i = 0; i <= N; i++) { | |
| const t = i / N; | |
| const cx = sx + dx * t; | |
| const cy = sy + dy * t; | |
| // base width | |
| let w = Math.max(0, scale * g[i]); | |
| // mapping | |
| if (flowMode === "A_widthByRelevance") { | |
| w *= relScale; // global widen by relevance | |
| } else { | |
| const kSide = topScale * (1 - t) + botScale * t; // lerp per side scores | |
| w *= kSide; | |
| } | |
| // floors + bulbs | |
| w = Math.max(w, MIN_CORE_PX * coreMask[i]); | |
| w += bulbGain * bump[i]; | |
| wArr.push(w); | |
| const half = w / 2; | |
| topPts.push([cx + nx * half, cy + ny * half]); | |
| botPts.push([cx - nx * half, cy - ny * half]); | |
| } | |
| // Build path | |
| let d = `M ${topPts[0][0]},${topPts[0][1]}`; | |
| for (let i = 1; i <= N; i++) d += ` L ${topPts[i][0]},${topPts[i][1]}`; | |
| for (let i = N; i >= 0; i--) d += ` L ${botPts[i][0]},${botPts[i][1]}`; | |
| d += " Z"; | |
| setBox({ width: c.width, height: c.height }); | |
| setShapePath(d); | |
| setDbg({ sx, sy, ex, ey }); | |
| setMeta({ sx, sy, ex, ey, L, ux, uy, w: wArr, N }); | |
| }; | |
| useLayoutEffect(() => { | |
| const update = () => compute(); | |
| update(); | |
| const ro = new ResizeObserver(update); | |
| if (containerRef.current) ro.observe(containerRef.current); | |
| if (fromRef.current) ro.observe(fromRef.current); | |
| if (toRef.current) ro.observe(toRef.current); | |
| window.addEventListener("resize", update); | |
| window.addEventListener("scroll", update, { passive: true }); | |
| return () => { | |
| ro.disconnect(); | |
| window.removeEventListener("resize", update); | |
| window.removeEventListener("scroll", update); | |
| }; | |
| }, [containerRef, fromRef, toRef]); | |
| // Recompute when position/params change | |
| useLayoutEffect(() => { | |
| compute(); | |
| }, [epoch, widthPx, conserveVol, areaPx, attenDepth, attenStart, attenSteep, sigmaMin, tension, sigma, eta, timeSec, mode, topScore, bottomScore, relevance, flowMode]); | |
| if (!box || !shapePath || !meta) return null; | |
| // --- Dots helpers --- | |
| const widthAt = (u: number) => { | |
| const { w, N } = meta; | |
| const i = Math.max(0, Math.min(N, u * N)); | |
| const i0 = Math.floor(i); | |
| const i1 = Math.min(N, i0 + 1); | |
| const t = i - i0; | |
| return w[i0] * (1 - t) + w[i1] * t; | |
| }; | |
| const posAt = (u: number) => { | |
| const { sx, sy, ux, uy, L } = meta; | |
| return { x: sx + ux * L * u, y: sy + uy * L * u }; | |
| }; | |
| // Speed / density mapping | |
| const baseSpeed = 60; // px/s when moving | |
| const mapSpeed = (s: number) => baseSpeed * (0.5 + s / 100); // 0..100 ⇒ 0.5..1.5× | |
| const topSpeed = reduced ? 0 : (flowMode === "A_widthByRelevance" ? mapSpeed(topScore) : mapSpeed(relevance)); | |
| const bottomSpeed = reduced ? 0 : (flowMode === "A_widthByRelevance" ? mapSpeed(bottomScore) : mapSpeed(relevance)); | |
| const spacingBase = 28; // base px between dots | |
| const densityFactor = (v: number) => 0.5 + v / 100; // 0.5..1.5 | |
| let spacingTop = spacingBase; | |
| let spacingBot = spacingBase; | |
| if (reduced) { | |
| if (flowMode === "A_widthByRelevance") { | |
| spacingTop = spacingBase / densityFactor(topScore); | |
| spacingBot = spacingBase / densityFactor(bottomScore); | |
| } else { | |
| const f = densityFactor(relevance); | |
| spacingTop = spacingBase / f; | |
| spacingBot = spacingBase / f; | |
| } | |
| } | |
| const halfLen = meta.L * 0.5; | |
| const now = reduced ? 0 : tick / 60; // seconds | |
| const nTop = Math.max(3, Math.ceil(halfLen / (reduced ? spacingTop : spacingBase))); | |
| const nBot = nTop; | |
| const topDots = Array.from({ length: nTop }).map((_, j) => { | |
| const dist = reduced ? (j + 0.5) * spacingTop : (now * topSpeed + j * spacingBase) % halfLen; // px from start | |
| const u = Math.min(0.5, dist / meta.L); | |
| const p = posAt(u); | |
| const w = widthAt(u); | |
| const r = Math.max(1.6, Math.min(7, w * 0.28)); | |
| return { key: `t${j}`, cx: p.x, cy: p.y, r }; | |
| }); | |
| const botDots = Array.from({ length: nBot }).map((_, j) => { | |
| const dist = reduced ? (j + 0.5) * spacingBot : (now * bottomSpeed + j * spacingBase) % halfLen; // px from end | |
| const u = Math.max(0.5, 1 - dist / meta.L); | |
| const p = posAt(u); | |
| const w = widthAt(u); | |
| const r = Math.max(1.6, Math.min(7, w * 0.28)); | |
| return { key: `b${j}`, cx: p.x, cy: p.y, r }; | |
| }); | |
| return ( | |
| <svg | |
| className="pointer-events-none absolute inset-0 z-0 w-full h-full" | |
| viewBox={`0 0 ${box.width} ${box.height}`} | |
| preserveAspectRatio="none" | |
| data-start={`${Math.round(meta.sx)},${Math.round(meta.sy)}`} | |
| data-end={`${Math.round(meta.ex)},${Math.round(meta.ey)}`} | |
| data-mincore={2} | |
| > | |
| <defs> | |
| <linearGradient id="strapFill" x1="0" y1="0" x2="0" y2="1"> | |
| <stop offset="0%" stopColor="#111827" /> | |
| <stop offset="100%" stopColor="#374151" /> | |
| </linearGradient> | |
| <filter id="dotShadow" x="-50%" y="-50%" width="200%" height="200%"> | |
| <feDropShadow dx="0" dy="0" stdDeviation="0.7" floodOpacity="0.2" /> | |
| </filter> | |
| </defs> | |
| {/* Strap */} | |
| <path d={shapePath!} fill="rgba(0,0,0,0.15)" /> | |
| <path d={shapePath!} fill="url(#strapFill)" /> | |
| <path d={shapePath!} fill="none" stroke="rgba(255,255,255,0.12)" strokeWidth={1} /> | |
| {/* Marching dots */} | |
| <g filter="url(#dotShadow)"> | |
| {topDots.map((d) => ( | |
| <circle key={d.key} cx={d.cx} cy={d.cy} r={d.r} fill="#fff" stroke="#0b1220" strokeWidth={1.5} /> | |
| ))} | |
| {botDots.map((d) => ( | |
| <circle key={d.key} cx={d.cx} cy={d.cy} r={d.r} fill="#fff" stroke="#0b1220" strokeWidth={1.5} /> | |
| ))} | |
| </g> | |
| </svg> | |
| ); | |
| } | |
| /** Card component */ | |
| export function PointCard({ | |
| refEl, | |
| text, | |
| variant = "point", | |
| onToggleHidden, | |
| className, | |
| style, | |
| onPointerDown, | |
| onPointerMove, | |
| onPointerUp, | |
| onPointerCancel, | |
| }: { | |
| refEl?: React.MutableRefObject<HTMLDivElement | null>; | |
| text: string; | |
| variant?: "point" | "counterpoint"; | |
| onToggleHidden?: (hidden: boolean) => void; | |
| className?: string; | |
| style?: React.CSSProperties; | |
| onPointerDown?: React.PointerEventHandler<HTMLDivElement>; | |
| onPointerMove?: React.PointerEventHandler<HTMLDivElement>; | |
| onPointerUp?: React.PointerEventHandler<HTMLDivElement>; | |
| onPointerCancel?: React.PointerEventHandler<HTMLDivElement>; | |
| }) { | |
| const [hidden, setHidden] = useState(false); | |
| const accent = variant === "counterpoint" ? "red" : "blue"; | |
| return ( | |
| <motion.div | |
| initial={{ y: 2, opacity: 0 }} | |
| animate={{ y: 0, opacity: 1 }} | |
| whileHover={{ y: -1 }} | |
| className={`group relative z-10 ${className ?? ""}`} | |
| style={style} | |
| ref={refEl as any} | |
| onPointerDown={onPointerDown} | |
| onPointerMove={onPointerMove} | |
| onPointerUp={onPointerUp} | |
| onPointerCancel={onPointerCancel} | |
| > | |
| <div | |
| className={`absolute -left-2 top-3 bottom-3 w-1 rounded-full bg-gradient-to-b from-${accent}-400 to-${accent}-600 opacity-70`} | |
| aria-hidden | |
| /> | |
| <div data-anchor-box className="relative inline-block w-auto max-w-[45ch] rounded-2xl border border-slate-200 bg-gradient-to-br from-white to-slate-50 p-5 pr-12 text-slate-800 shadow ring-1 ring-slate-100/60"> | |
| <p className="text-lg leading-relaxed break-words"> | |
| {hidden ? <span className="italic text-slate-400">(Hidden)</span> : text} | |
| </p> | |
| <button | |
| type="button" | |
| aria-label={hidden ? "Show point" : "Hide point"} | |
| className="absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100" | |
| onClick={() => { | |
| setHidden((h) => { | |
| const next = !h; | |
| onToggleHidden?.(next); | |
| return next; | |
| }); | |
| }} | |
| > | |
| <span className="group/eye inline-flex items-center justify-center rounded-full border border-slate-200 bg-white/80 p-1.5 shadow-sm backdrop-blur transition-transform hover:scale-105"> | |
| <Eye className="h-4 w-4 text-slate-600 transition-opacity group-hover/eye:opacity-0" aria-hidden /> | |
| <EyeOff className="h-4 w-4 -ml-4 text-slate-700 opacity-0 transition-opacity group-hover/eye:opacity-100" aria-hidden /> | |
| </span> | |
| </button> | |
| <span | |
| className={`pointer-events-none absolute inset-0 rounded-2xl ring-0 ring-${accent}-500/0 transition-[box-shadow,ring] group-hover:ring-4 group-hover:ring-${accent}-500/10`} | |
| /> | |
| </div> | |
| {/* Center marker (for debugging/visual alignment) */} | |
| <span data-anchor-center className="pointer-events-none absolute left-1/2 top-1/2 w-0 h-0 -translate-x-1/2 -translate-y-1/2" /> | |
| </motion.div> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment