diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index cd5de18..1db0d6b 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -1,5 +1,5 @@ import * as d3 from "d3"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { createNoise2D, createNoise3D } from "simplex-noise"; export interface Peak { @@ -92,6 +92,9 @@ export function Flowfield({ timeSpeed = 0.0025, driftAmplitude = 150, smoothing = 0, + cursorRadius = 200, + cursorStrength = 50, + cursorTrail = 0.95, }: { /** SVG viewBox width. */ width?: number; @@ -113,11 +116,51 @@ export function Flowfield({ driftAmplitude?: number; /** Number of blur passes applied to the grid before contouring (0 = no smoothing). */ smoothing?: number; + /** Influence radius of cursor interactions in viewBox units. */ + cursorRadius?: number; + /** Maximum displacement strength applied at the cursor position. */ + cursorStrength?: number; + /** Dissipation factor per frame for the cursor trail (0 = no trail / instant, 0.99 = long trail). When `0`, the displacement grid is skipped entirely for zero overhead. */ + cursorTrail?: number; }) { const svgRef = useRef(null); const noise2DRef = useRef>(null); const noise3DRef = useRef>(null); const timeRef = useRef(0); + const pointersRef = useRef< + Map + >(new Map()); + + const toViewBox = useCallback((clientX: number, clientY: number) => { + const svg = svgRef.current; + if (!svg) return null; + const pt = svg.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + const ctm = svg.getScreenCTM(); + if (!ctm) return null; + const svgPt = pt.matrixTransform(ctm.inverse()); + return { x: svgPt.x, y: svgPt.y }; + }, []); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { + const pos = toViewBox(e.clientX, e.clientY); + if (!pos) return; + const prev = pointersRef.current.get(e.pointerId); + const vx = prev ? pos.x - prev.x : 0; + const vy = prev ? pos.y - prev.y : 0; + pointersRef.current.set(e.pointerId, { ...pos, vx, vy }); + }, + [toViewBox], + ); + + const handlePointerLeave = useCallback( + (e: React.PointerEvent) => { + pointersRef.current.delete(e.pointerId); + }, + [], + ); useEffect(() => { const svgEl = svgRef.current; @@ -179,6 +222,10 @@ export function Flowfield({ new Array(gridW * gridH).fill(0), ); const rawElev = new Array(runtimePeaks.length).fill(0); + const displaceX = + cursorTrail > 0 ? new Array(gridW * gridH).fill(0) : null; + const displaceY = + cursorTrail > 0 ? new Array(gridW * gridH).fill(0) : null; const baseThresholds = Object.keys(baseColors) .map(Number) .sort((a, b) => a - b); @@ -190,6 +237,54 @@ export function Flowfield({ isPeakBase: boolean; } + function applyPointerDisplacement( + pos: { x: number; y: number; vx: number; vy: number }, + dx: number[], + dy: number[], + ) { + const speed = Math.sqrt(pos.vx * pos.vx + pos.vy * pos.vy); + if (speed < 0.01) return; + const nx = pos.vx / speed; + const ny = pos.vy / speed; + const minI = Math.max(0, Math.floor((pos.x - cursorRadius) / gridScale)); + const maxI = Math.min( + gridW - 1, + Math.ceil((pos.x + cursorRadius) / gridScale), + ); + const minJ = Math.max(0, Math.floor((pos.y - cursorRadius) / gridScale)); + const maxJ = Math.min( + gridH - 1, + Math.ceil((pos.y + cursorRadius) / gridScale), + ); + for (let j = minJ; j <= maxJ; ++j) { + for (let i = minI; i <= maxI; ++i) { + const cx = i * gridScale - pos.x; + const cy = j * gridScale - pos.y; + const dist = Math.sqrt(cx * cx + cy * cy); + if (dist < cursorRadius) { + const factor = 1 - dist / cursorRadius; + const strength = cursorStrength * factor * factor; + const idx = j * gridW + i; + dx[idx] = (dx[idx] ?? 0) + nx * strength; + dy[idx] = (dy[idx] ?? 0) + ny * strength; + } + } + } + pos.vx = 0; + pos.vy = 0; + } + + function updateCursorHeat() { + if (!displaceX || !displaceY) return; + for (let i = 0; i < displaceX.length; i++) { + displaceX[i] = (displaceX[i] ?? 0) * cursorTrail; + displaceY[i] = (displaceY[i] ?? 0) * cursorTrail; + } + for (const pos of pointersRef.current.values()) { + applyPointerDisplacement(pos, displaceX, displaceY); + } + } + function computeGrid() { for (const p of runtimePeaks) { p.x = p.baseX + noise2D(p.seed, timeRef.current * 0.3) * driftAmplitude; @@ -198,6 +293,8 @@ export function Flowfield({ noise2D(p.seed + 100, timeRef.current * 0.3) * driftAmplitude; } + updateCursorHeat(); + for (let j = 0; j < gridH; ++j) { for (let i = 0; i < gridW; ++i) { computeCell(i * gridScale, j * gridScale, j * gridW + i); @@ -206,13 +303,18 @@ export function Flowfield({ } function computeCell(realX: number, realY: number, idx: number) { + // Apply cursor displacement to sampling coordinates + const sampleX = displaceX ? realX - (displaceX[idx] as number) : realX; + const sampleY = displaceY ? realY - (displaceY[idx] as number) : realY; + let maxElev = 0; for (let k = 0; k < runtimePeaks.length; k++) { const peak = runtimePeaks[k] as RuntimePeak; - const elev = getRawElevation(peak, realX, realY, timeRef.current); + const elev = getRawElevation(peak, sampleX, sampleY, timeRef.current); rawElev[k] = elev; if (elev > maxElev) maxElev = elev; } + baseValues[idx] = maxElev; const pow = 8; @@ -287,7 +389,7 @@ export function Flowfield({ paths .enter() .append("path") - // .style("pointer-events", "none") + .style("pointer-events", "none") .style("stroke-linejoin", "round") .merge(paths) .attr("d", (d) => @@ -319,6 +421,9 @@ export function Flowfield({ timeSpeed, driftAmplitude, smoothing, + cursorRadius, + cursorStrength, + cursorTrail, ]); return ( @@ -326,7 +431,15 @@ export function Flowfield({ ref={svgRef} viewBox={`0 0 ${width} ${height}`} preserveAspectRatio="xMidYMid slice" - style={{ width: "100%", height: "100%", display: "block" }} + style={{ + width: "100%", + height: "100%", + display: "block", + touchAction: "none", + }} + onPointerDown={handlePointerMove} + onPointerMove={handlePointerMove} + onPointerLeave={handlePointerLeave} /> ); } diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index fb23aff..4f93d46 100644 --- a/src/Mcu.stories.tsx +++ b/src/Mcu.stories.tsx @@ -85,6 +85,136 @@ type Story = StoryObj; const customColor1 = "#00D68A"; const customColor2 = "#FFE16B"; +// +// ███████ ██ ██████ ██ ██ ███████ ██ ███████ ██ ██████ +// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ +// █████ ██ ██ ██ ██ █ ██ █████ ██ █████ ██ ██ ██ +// ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ +// ██ ███████ ██████ ███ ███ ██ ██ ███████ ███████ ██████ +// + +function FlowfieldScene(props: ComponentProps) { + const { allPalettes } = useMcu(); + + const baseColors = useMemo>( + () => ({ + 100: "var(--md-sys-color-surface-container-lowest)", + 200: "var(--md-sys-color-surface-container-low)", + 300: "var(--md-sys-color-surface-container)", + 400: "var(--md-sys-color-surface-container-high)", + 500: "var(--md-sys-color-surface-container-highest)", + }), + [], + ); + + const peaks = useMemo(() => { + const peakKeys = Object.keys(allPalettes).filter( + (k) => k !== "neutral" && k !== "neutral-variant", + ); + + return peakKeys.flatMap((key) => { + const palette = allPalettes[key]; + if (!palette) return []; + + const kebab = kebabCase(key); + const colors: Record = { + 600: `var(--md-sys-color-on-${kebab})`, + 700: `var(--md-sys-color-${kebab}-container)`, + 800: `var(--md-sys-color-${kebab})`, + 900: `var(--md-sys-color-on-${kebab}-container)`, + }; + + return { + id: key, + colors, + }; + }); + }, [allPalettes]); + + return ; +} + +export const FlowfieldSt: StoryObj< + Meta) => void)> +> = { + name: "Flowfield", + parameters: { + layout: "fullscreen", + }, + args: { + // MCU args + source: "#769CDF", + customColors: [ + { name: "myCustomColor1", hex: customColor1, blend: true }, + { name: "myCustomColor2", hex: customColor2, blend: true }, + ], + // Flowfield args + gridScale: 15, + defaultWeight: 0.65, + smoothing: 2, + driftAmplitude: 1100, + noiseFrequency: 0.002, + timeSpeed: 0.002, + scheme: "expressive", + cursorRadius: 200, + cursorStrength: 50, + cursorTrail: 0.95, + }, + argTypes: { + gridScale: { control: { type: "range", min: 2, max: 50, step: 1 } }, + defaultWeight: { + control: { type: "range", min: 0, max: 2, step: 0.05 }, + }, + noiseFrequency: { control: { type: "number", step: 0.0001 } }, + timeSpeed: { control: { type: "number", step: 0.0001 } }, + driftAmplitude: { + control: { type: "range", min: 0, max: 2000, step: 10 }, + }, + smoothing: { control: { type: "range", min: 0, max: 10, step: 1 } }, + cursorRadius: { + control: { type: "range", min: 0, max: 500, step: 10 }, + }, + cursorStrength: { + control: { type: "range", min: 0, max: 200, step: 5 }, + }, + cursorTrail: { + control: { type: "range", min: 0, max: 0.99, step: 0.01 }, + }, + }, + render: (args) => { + const { + gridScale, + defaultWeight, + noiseFrequency, + timeSpeed, + driftAmplitude, + smoothing, + cursorRadius, + cursorStrength, + cursorTrail, + ...mcuArgs + } = args as Record; + + return ( + )}> +
+ +
+
+ ); + }, +}; + export const St2: Story = { name: "Minimal", parameters: { @@ -778,115 +908,3 @@ export const RecolorizeSvgSt2: Story = { ), }; - -// -// ███████ ██ ██████ ██ ██ ███████ ██ ███████ ██ ██████ -// ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ -// █████ ██ ██ ██ ██ █ ██ █████ ██ █████ ██ ██ ██ -// ██ ██ ██ ██ ██ ███ ██ ██ ██ ██ ██ ██ ██ -// ██ ███████ ██████ ███ ███ ██ ██ ███████ ███████ ██████ -// - -function FlowfieldScene(props: ComponentProps) { - const { allPalettes } = useMcu(); - - const baseColors = useMemo>( - () => ({ - 100: "var(--md-sys-color-surface-container-lowest)", - 200: "var(--md-sys-color-surface-container-low)", - 300: "var(--md-sys-color-surface-container)", - 400: "var(--md-sys-color-surface-container-high)", - 500: "var(--md-sys-color-surface-container-highest)", - }), - [], - ); - - const peaks = useMemo(() => { - const peakKeys = Object.keys(allPalettes).filter( - (k) => k !== "neutral" && k !== "neutral-variant", - ); - - return peakKeys.flatMap((key) => { - const palette = allPalettes[key]; - if (!palette) return []; - - const kebab = kebabCase(key); - const colors: Record = { - 600: `var(--md-sys-color-on-${kebab})`, - 700: `var(--md-sys-color-${kebab}-container)`, - 800: `var(--md-sys-color-${kebab})`, - 900: `var(--md-sys-color-on-${kebab}-container)`, - }; - - return { - id: key, - colors, - }; - }); - }, [allPalettes]); - - return ; -} - -export const FlowfieldSt: StoryObj< - Meta) => void)> -> = { - name: "Flowfield", - parameters: { - layout: "fullscreen", - }, - args: { - // MCU args - source: "#769CDF", - customColors: [ - { name: "myCustomColor1", hex: customColor1, blend: true }, - { name: "myCustomColor2", hex: customColor2, blend: true }, - ], - // Flowfield args - gridScale: 15, - defaultWeight: 0.65, - smoothing: 2, - driftAmplitude: 1100, - noiseFrequency: 0.002, - timeSpeed: 0.002, - scheme: "expressive", - }, - argTypes: { - gridScale: { control: { type: "range", min: 2, max: 50, step: 1 } }, - defaultWeight: { - control: { type: "range", min: 0, max: 2, step: 0.05 }, - }, - noiseFrequency: { control: { type: "number", step: 0.0001 } }, - timeSpeed: { control: { type: "number", step: 0.0001 } }, - driftAmplitude: { - control: { type: "range", min: 0, max: 2000, step: 10 }, - }, - smoothing: { control: { type: "range", min: 0, max: 10, step: 1 } }, - }, - render: (args) => { - const { - gridScale, - defaultWeight, - noiseFrequency, - timeSpeed, - driftAmplitude, - smoothing, - ...mcuArgs - } = args as Record; - - return ( - )}> -
- -
-
- ); - }, -};