From b282750c02d42261feaa3bf27860b9727f282f3b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:06:44 +0000 Subject: [PATCH 01/10] Initial plan From 973a8d0eae3f353e87b13e316eb7835c6266def9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:13:30 +0000 Subject: [PATCH 02/10] feat: make flowfield interactive with cursor (mouse/touch) influence on vector field Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 63 ++++++++++++++++++++++++++++++++++++++++++--- src/Mcu.stories.tsx | 12 +++++++++ 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index cd5de18..78b5fd1 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,8 @@ export function Flowfield({ timeSpeed = 0.0025, driftAmplitude = 150, smoothing = 0, + cursorRadius = 200, + cursorStrength = 800, }: { /** SVG viewBox width. */ width?: number; @@ -113,11 +115,43 @@ 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 elevation added at the cursor position. */ + cursorStrength?: number; }) { const svgRef = useRef(null); const noise2DRef = useRef>(null); const noise3DRef = useRef>(null); const timeRef = useRef(0); + const pointersRef = useRef>(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) pointersRef.current.set(e.pointerId, pos); + }, + [toViewBox], + ); + + const handlePointerLeave = useCallback( + (e: React.PointerEvent) => { + pointersRef.current.delete(e.pointerId); + }, + [], + ); useEffect(() => { const svgEl = svgRef.current; @@ -213,7 +247,19 @@ export function Flowfield({ rawElev[k] = elev; if (elev > maxElev) maxElev = elev; } - baseValues[idx] = maxElev; + + // Cursor influence: each active pointer adds a radial elevation bump + let cursorElev = 0; + for (const pos of pointersRef.current.values()) { + const dx = realX - pos.x; + const dy = realY - pos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < cursorRadius) { + const factor = 1 - dist / cursorRadius; + cursorElev = Math.max(cursorElev, cursorStrength * factor * factor); + } + } + baseValues[idx] = Math.max(maxElev, cursorElev); const pow = 8; let sum = 0.1; @@ -287,7 +333,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 +365,8 @@ export function Flowfield({ timeSpeed, driftAmplitude, smoothing, + cursorRadius, + cursorStrength, ]); return ( @@ -326,7 +374,14 @@ 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", + }} + onPointerMove={handlePointerMove} + onPointerLeave={handlePointerLeave} /> ); } diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index fb23aff..d01df91 100644 --- a/src/Mcu.stories.tsx +++ b/src/Mcu.stories.tsx @@ -850,6 +850,8 @@ export const FlowfieldSt: StoryObj< noiseFrequency: 0.002, timeSpeed: 0.002, scheme: "expressive", + cursorRadius: 200, + cursorStrength: 800, }, argTypes: { gridScale: { control: { type: "range", min: 2, max: 50, step: 1 } }, @@ -862,6 +864,12 @@ export const FlowfieldSt: StoryObj< 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: 2000, step: 50 }, + }, }, render: (args) => { const { @@ -871,6 +879,8 @@ export const FlowfieldSt: StoryObj< timeSpeed, driftAmplitude, smoothing, + cursorRadius, + cursorStrength, ...mcuArgs } = args as Record; @@ -884,6 +894,8 @@ export const FlowfieldSt: StoryObj< timeSpeed={timeSpeed as number} driftAmplitude={driftAmplitude as number} smoothing={smoothing as number} + cursorRadius={cursorRadius as number} + cursorStrength={cursorStrength as number} /> From b3536e2c22fdace0a5c788289b0fba8df8392a36 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:16:30 +0000 Subject: [PATCH 03/10] fix: add cursor elevation additively and capture pointer on first touch Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index 78b5fd1..4e1828f 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -259,7 +259,7 @@ export function Flowfield({ cursorElev = Math.max(cursorElev, cursorStrength * factor * factor); } } - baseValues[idx] = Math.max(maxElev, cursorElev); + baseValues[idx] = maxElev + cursorElev; const pow = 8; let sum = 0.1; @@ -380,6 +380,7 @@ export function Flowfield({ display: "block", touchAction: "none", }} + onPointerDown={handlePointerMove} onPointerMove={handlePointerMove} onPointerLeave={handlePointerLeave} /> From 8e1f4e32e335be645ea19194317ea49b807a9c32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:36:19 +0000 Subject: [PATCH 04/10] fix: cursor affects peaks too, add trail dissipation, move flowfield story first Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 61 +++++++++-- src/Mcu.stories.tsx | 254 +++++++++++++++++++++++--------------------- 2 files changed, 181 insertions(+), 134 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index 4e1828f..f4cc12a 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -94,6 +94,7 @@ export function Flowfield({ smoothing = 0, cursorRadius = 200, cursorStrength = 800, + cursorTrail = 0.95, }: { /** SVG viewBox width. */ width?: number; @@ -119,6 +120,8 @@ export function Flowfield({ cursorRadius?: number; /** Maximum elevation added at the cursor position. */ cursorStrength?: number; + /** Fraction of cursor influence retained per frame (0 = no trail, 0.99 = long trail). */ + cursorTrail?: number; }) { const svgRef = useRef(null); const noise2DRef = useRef>(null); @@ -213,6 +216,7 @@ export function Flowfield({ new Array(gridW * gridH).fill(0), ); const rawElev = new Array(runtimePeaks.length).fill(0); + const cursorHeat = new Array(gridW * gridH).fill(0); const baseThresholds = Object.keys(baseColors) .map(Number) .sort((a, b) => a - b); @@ -224,6 +228,43 @@ export function Flowfield({ isPeakBase: boolean; } + function updateCursorHeat() { + for (let i = 0; i < cursorHeat.length; i++) { + cursorHeat[i] = (cursorHeat[i] ?? 0) * cursorTrail; + } + for (const pos of pointersRef.current.values()) { + 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 dx = i * gridScale - pos.x; + const dy = j * gridScale - pos.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < cursorRadius) { + const factor = 1 - dist / cursorRadius; + const influence = cursorStrength * factor * factor; + const idx = j * gridW + i; + cursorHeat[idx] = Math.max(cursorHeat[idx] ?? 0, influence); + } + } + } + } + } + function computeGrid() { for (const p of runtimePeaks) { p.x = p.baseX + noise2D(p.seed, timeRef.current * 0.3) * driftAmplitude; @@ -232,6 +273,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); @@ -248,18 +291,15 @@ export function Flowfield({ if (elev > maxElev) maxElev = elev; } - // Cursor influence: each active pointer adds a radial elevation bump - let cursorElev = 0; - for (const pos of pointersRef.current.values()) { - const dx = realX - pos.x; - const dy = realY - pos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < cursorRadius) { - const factor = 1 - dist / cursorRadius; - cursorElev = Math.max(cursorElev, cursorStrength * factor * factor); + const heat = cursorHeat[idx] as number; + baseValues[idx] = maxElev + heat; + + // Boost each peak's raw elevation by cursor heat so peaks are affected + if (heat > 0) { + for (let k = 0; k < runtimePeaks.length; k++) { + rawElev[k] = (rawElev[k] ?? 0) + heat; } } - baseValues[idx] = maxElev + cursorElev; const pow = 8; let sum = 0.1; @@ -367,6 +407,7 @@ export function Flowfield({ smoothing, cursorRadius, cursorStrength, + cursorTrail, ]); return ( diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index d01df91..03c197e 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: 800, + 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: 2000, step: 50 }, + }, + 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,127 +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", - cursorRadius: 200, - cursorStrength: 800, - }, - 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: 2000, step: 50 }, - }, - }, - render: (args) => { - const { - gridScale, - defaultWeight, - noiseFrequency, - timeSpeed, - driftAmplitude, - smoothing, - cursorRadius, - cursorStrength, - ...mcuArgs - } = args as Record; - - return ( - )}> -
- -
-
- ); - }, -}; From 6aca7d55d8ad679aba6bf3707512a6c05f9ed5e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 11:59:23 +0000 Subject: [PATCH 05/10] feat: cursor-interactive flowfield with maath damp dissipation Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- package.json | 1 + pnpm-lock.yaml | 62 +++++++++++++++++++++++++++++++++++++++++++++ src/Flowfield.tsx | 31 +++++++++++++++-------- src/Mcu.stories.tsx | 10 ++++---- 4 files changed, 88 insertions(+), 16 deletions(-) diff --git a/package.json b/package.json index 3987b33..0f3b97e 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^16.2.7", + "maath": "^0.10.8", "postcss": "^8.5.6", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fc01a8..420e596 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: lint-staged: specifier: ^16.2.7 version: 16.2.7 + maath: + specifier: ^0.10.8 + version: 0.10.8(@types/three@0.183.1)(three@0.183.2) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -450,6 +453,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@es-joy/jsdoccomment@0.84.0': resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1363,6 +1369,9 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1524,6 +1533,15 @@ packages: '@types/seedrandom@3.0.8': resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.183.1': + resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} + + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1630,6 +1648,9 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@webgpu/types@0.1.69': + resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2685,6 +2706,12 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2706,6 +2733,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.0.1: + resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3239,6 +3269,9 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + three@0.183.2: + resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4029,6 +4062,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 @@ -4679,6 +4714,8 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 + '@tweenjs/tween.js@23.1.3': {} + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -4865,6 +4902,20 @@ snapshots: '@types/seedrandom@3.0.8': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.183.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + '@webgpu/types': 0.1.69 + fflate: 0.8.2 + meshoptimizer: 1.0.1 + + '@types/webxr@0.5.24': {} + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5029,6 +5080,8 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 + '@webgpu/types@0.1.69': {} + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -6073,6 +6126,11 @@ snapshots: lz-string@1.5.0: {} + maath@0.10.8(@types/three@0.183.1)(three@0.183.2): + dependencies: + '@types/three': 0.183.1 + three: 0.183.2 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6094,6 +6152,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.0.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -6621,6 +6681,8 @@ snapshots: dependencies: any-promise: 1.3.0 + three@0.183.2: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index f4cc12a..f010a33 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -1,4 +1,5 @@ import * as d3 from "d3"; +import { damp } from "maath/easing"; import { useCallback, useEffect, useRef } from "react"; import { createNoise2D, createNoise3D } from "simplex-noise"; @@ -94,7 +95,7 @@ export function Flowfield({ smoothing = 0, cursorRadius = 200, cursorStrength = 800, - cursorTrail = 0.95, + cursorSmoothTime = 0.5, }: { /** SVG viewBox width. */ width?: number; @@ -120,13 +121,14 @@ export function Flowfield({ cursorRadius?: number; /** Maximum elevation added at the cursor position. */ cursorStrength?: number; - /** Fraction of cursor influence retained per frame (0 = no trail, 0.99 = long trail). */ - cursorTrail?: number; + /** Approximate time in seconds for cursor influence to dissipate (uses `maath` critically-damped spring). Smaller = faster fade. */ + cursorSmoothTime?: number; }) { const svgRef = useRef(null); const noise2DRef = useRef>(null); const noise3DRef = useRef>(null); const timeRef = useRef(0); + const lastFrameRef = useRef(0); const pointersRef = useRef>(new Map()); const toViewBox = useCallback((clientX: number, clientY: number) => { @@ -228,9 +230,11 @@ export function Flowfield({ isPeakBase: boolean; } - function updateCursorHeat() { + function updateCursorHeat(delta: number) { for (let i = 0; i < cursorHeat.length; i++) { - cursorHeat[i] = (cursorHeat[i] ?? 0) * cursorTrail; + if ((cursorHeat[i] ?? 0) > 0) { + damp(cursorHeat, String(i), 0, cursorSmoothTime, delta); + } } for (const pos of pointersRef.current.values()) { const minI = Math.max( @@ -265,7 +269,7 @@ export function Flowfield({ } } - function computeGrid() { + function computeGrid(delta: number) { for (const p of runtimePeaks) { p.x = p.baseX + noise2D(p.seed, timeRef.current * 0.3) * driftAmplitude; p.y = @@ -273,7 +277,7 @@ export function Flowfield({ noise2D(p.seed + 100, timeRef.current * 0.3) * driftAmplitude; } - updateCursorHeat(); + updateCursorHeat(delta); for (let j = 0; j < gridH; ++j) { for (let i = 0; i < gridW; ++i) { @@ -362,9 +366,14 @@ export function Flowfield({ return pathData.sort((a, b) => a.value - b.value); } - function renderFrame() { + function renderFrame(now: DOMHighResTimeStamp) { + const delta = lastFrameRef.current + ? Math.min((now - lastFrameRef.current) / 1000, 0.1) + : 1 / 60; + lastFrameRef.current = now; + timeRef.current += timeSpeed; - computeGrid(); + computeGrid(delta); const pathData = buildContours(); const paths = pathGroup @@ -388,7 +397,7 @@ export function Flowfield({ animId = requestAnimationFrame(renderFrame); } - renderFrame(); + animId = requestAnimationFrame(renderFrame); return () => { cancelAnimationFrame(animId); @@ -407,7 +416,7 @@ export function Flowfield({ smoothing, cursorRadius, cursorStrength, - cursorTrail, + cursorSmoothTime, ]); return ( diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index 03c197e..ea087d9 100644 --- a/src/Mcu.stories.tsx +++ b/src/Mcu.stories.tsx @@ -158,7 +158,7 @@ export const FlowfieldSt: StoryObj< scheme: "expressive", cursorRadius: 200, cursorStrength: 800, - cursorTrail: 0.95, + cursorSmoothTime: 0.5, }, argTypes: { gridScale: { control: { type: "range", min: 2, max: 50, step: 1 } }, @@ -177,8 +177,8 @@ export const FlowfieldSt: StoryObj< cursorStrength: { control: { type: "range", min: 0, max: 2000, step: 50 }, }, - cursorTrail: { - control: { type: "range", min: 0, max: 0.99, step: 0.01 }, + cursorSmoothTime: { + control: { type: "range", min: 0.05, max: 3, step: 0.05 }, }, }, render: (args) => { @@ -191,7 +191,7 @@ export const FlowfieldSt: StoryObj< smoothing, cursorRadius, cursorStrength, - cursorTrail, + cursorSmoothTime, ...mcuArgs } = args as Record; @@ -207,7 +207,7 @@ export const FlowfieldSt: StoryObj< smoothing={smoothing as number} cursorRadius={cursorRadius as number} cursorStrength={cursorStrength as number} - cursorTrail={cursorTrail as number} + cursorSmoothTime={cursorSmoothTime as number} /> From 611a578bad462e9ab364cd96925ed0ce4e698ffe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:06:36 +0000 Subject: [PATCH 06/10] revert: remove maath, use simple cursorTrail with zero-overhead toggle Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- package.json | 1 - pnpm-lock.yaml | 62 --------------------------------------------- src/Flowfield.tsx | 38 ++++++++++++--------------- src/Mcu.stories.tsx | 10 ++++---- 4 files changed, 21 insertions(+), 90 deletions(-) diff --git a/package.json b/package.json index 0f3b97e..3987b33 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,6 @@ "husky": "^9.1.7", "jsdom": "^28.1.0", "lint-staged": "^16.2.7", - "maath": "^0.10.8", "postcss": "^8.5.6", "prettier": "^3.3.3", "prettier-plugin-organize-imports": "^4.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 420e596..5fc01a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,9 +120,6 @@ importers: lint-staged: specifier: ^16.2.7 version: 16.2.7 - maath: - specifier: ^0.10.8 - version: 0.10.8(@types/three@0.183.1)(three@0.183.2) postcss: specifier: ^8.5.6 version: 8.5.6 @@ -453,9 +450,6 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} - '@dimforge/rapier3d-compat@0.12.0': - resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} - '@es-joy/jsdoccomment@0.84.0': resolution: {integrity: sha512-0xew1CxOam0gV5OMjh2KjFQZsKL2bByX1+q4j3E73MpYIdyUxcZb/xQct9ccUb+ve5KGUYbCUxyPnYB7RbuP+w==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -1369,9 +1363,6 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' - '@tweenjs/tween.js@23.1.3': - resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} - '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1533,15 +1524,6 @@ packages: '@types/seedrandom@3.0.8': resolution: {integrity: sha512-TY1eezMU2zH2ozQoAFAQFOPpvP15g+ZgSfTZt31AUUH/Rxtnz3H+A/Sv1Snw2/amp//omibc+AEkTaA8KUeOLQ==} - '@types/stats.js@0.17.4': - resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} - - '@types/three@0.183.1': - resolution: {integrity: sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==} - - '@types/webxr@0.5.24': - resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} - '@typescript-eslint/eslint-plugin@8.56.1': resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1648,9 +1630,6 @@ packages: '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} - '@webgpu/types@0.1.69': - resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} - acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2706,12 +2685,6 @@ packages: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true - maath@0.10.8: - resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} - peerDependencies: - '@types/three': '>=0.134.0' - three: '>=0.134.0' - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2733,9 +2706,6 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - meshoptimizer@1.0.1: - resolution: {integrity: sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==} - micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3269,9 +3239,6 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} - three@0.183.2: - resolution: {integrity: sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==} - tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4062,8 +4029,6 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} - '@dimforge/rapier3d-compat@0.12.0': {} - '@es-joy/jsdoccomment@0.84.0': dependencies: '@types/estree': 1.0.8 @@ -4714,8 +4679,6 @@ snapshots: dependencies: '@testing-library/dom': 10.4.1 - '@tweenjs/tween.js@23.1.3': {} - '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -4902,20 +4865,6 @@ snapshots: '@types/seedrandom@3.0.8': {} - '@types/stats.js@0.17.4': {} - - '@types/three@0.183.1': - dependencies: - '@dimforge/rapier3d-compat': 0.12.0 - '@tweenjs/tween.js': 23.1.3 - '@types/stats.js': 0.17.4 - '@types/webxr': 0.5.24 - '@webgpu/types': 0.1.69 - fflate: 0.8.2 - meshoptimizer: 1.0.1 - - '@types/webxr@0.5.24': {} - '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.2(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5080,8 +5029,6 @@ snapshots: '@vitest/pretty-format': 4.0.18 tinyrainbow: 3.0.3 - '@webgpu/types@0.1.69': {} - acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -6126,11 +6073,6 @@ snapshots: lz-string@1.5.0: {} - maath@0.10.8(@types/three@0.183.1)(three@0.183.2): - dependencies: - '@types/three': 0.183.1 - three: 0.183.2 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6152,8 +6094,6 @@ snapshots: merge2@1.4.1: {} - meshoptimizer@1.0.1: {} - micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -6681,8 +6621,6 @@ snapshots: dependencies: any-promise: 1.3.0 - three@0.183.2: {} - tiny-invariant@1.3.3: {} tinybench@2.9.0: {} diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index f010a33..4cebdf6 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -1,5 +1,4 @@ import * as d3 from "d3"; -import { damp } from "maath/easing"; import { useCallback, useEffect, useRef } from "react"; import { createNoise2D, createNoise3D } from "simplex-noise"; @@ -95,7 +94,7 @@ export function Flowfield({ smoothing = 0, cursorRadius = 200, cursorStrength = 800, - cursorSmoothTime = 0.5, + cursorTrail = 0.95, }: { /** SVG viewBox width. */ width?: number; @@ -121,14 +120,13 @@ export function Flowfield({ cursorRadius?: number; /** Maximum elevation added at the cursor position. */ cursorStrength?: number; - /** Approximate time in seconds for cursor influence to dissipate (uses `maath` critically-damped spring). Smaller = faster fade. */ - cursorSmoothTime?: number; + /** Dissipation factor per frame for the cursor trail (0 = no trail / instant, 0.99 = long trail). When `0`, the heat 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 lastFrameRef = useRef(0); const pointersRef = useRef>(new Map()); const toViewBox = useCallback((clientX: number, clientY: number) => { @@ -218,7 +216,9 @@ export function Flowfield({ new Array(gridW * gridH).fill(0), ); const rawElev = new Array(runtimePeaks.length).fill(0); - const cursorHeat = new Array(gridW * gridH).fill(0); + const cursorHeat = cursorTrail > 0 + ? new Array(gridW * gridH).fill(0) + : null; const baseThresholds = Object.keys(baseColors) .map(Number) .sort((a, b) => a - b); @@ -230,11 +230,10 @@ export function Flowfield({ isPeakBase: boolean; } - function updateCursorHeat(delta: number) { + function updateCursorHeat() { + if (!cursorHeat) return; for (let i = 0; i < cursorHeat.length; i++) { - if ((cursorHeat[i] ?? 0) > 0) { - damp(cursorHeat, String(i), 0, cursorSmoothTime, delta); - } + cursorHeat[i] = (cursorHeat[i] ?? 0) * cursorTrail; } for (const pos of pointersRef.current.values()) { const minI = Math.max( @@ -269,7 +268,7 @@ export function Flowfield({ } } - function computeGrid(delta: number) { + function computeGrid() { for (const p of runtimePeaks) { p.x = p.baseX + noise2D(p.seed, timeRef.current * 0.3) * driftAmplitude; p.y = @@ -277,7 +276,7 @@ export function Flowfield({ noise2D(p.seed + 100, timeRef.current * 0.3) * driftAmplitude; } - updateCursorHeat(delta); + updateCursorHeat(); for (let j = 0; j < gridH; ++j) { for (let i = 0; i < gridW; ++i) { @@ -295,7 +294,7 @@ export function Flowfield({ if (elev > maxElev) maxElev = elev; } - const heat = cursorHeat[idx] as number; + const heat = cursorHeat ? (cursorHeat[idx] as number) : 0; baseValues[idx] = maxElev + heat; // Boost each peak's raw elevation by cursor heat so peaks are affected @@ -366,14 +365,9 @@ export function Flowfield({ return pathData.sort((a, b) => a.value - b.value); } - function renderFrame(now: DOMHighResTimeStamp) { - const delta = lastFrameRef.current - ? Math.min((now - lastFrameRef.current) / 1000, 0.1) - : 1 / 60; - lastFrameRef.current = now; - + function renderFrame() { timeRef.current += timeSpeed; - computeGrid(delta); + computeGrid(); const pathData = buildContours(); const paths = pathGroup @@ -397,7 +391,7 @@ export function Flowfield({ animId = requestAnimationFrame(renderFrame); } - animId = requestAnimationFrame(renderFrame); + renderFrame(); return () => { cancelAnimationFrame(animId); @@ -416,7 +410,7 @@ export function Flowfield({ smoothing, cursorRadius, cursorStrength, - cursorSmoothTime, + cursorTrail, ]); return ( diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index ea087d9..03c197e 100644 --- a/src/Mcu.stories.tsx +++ b/src/Mcu.stories.tsx @@ -158,7 +158,7 @@ export const FlowfieldSt: StoryObj< scheme: "expressive", cursorRadius: 200, cursorStrength: 800, - cursorSmoothTime: 0.5, + cursorTrail: 0.95, }, argTypes: { gridScale: { control: { type: "range", min: 2, max: 50, step: 1 } }, @@ -177,8 +177,8 @@ export const FlowfieldSt: StoryObj< cursorStrength: { control: { type: "range", min: 0, max: 2000, step: 50 }, }, - cursorSmoothTime: { - control: { type: "range", min: 0.05, max: 3, step: 0.05 }, + cursorTrail: { + control: { type: "range", min: 0, max: 0.99, step: 0.01 }, }, }, render: (args) => { @@ -191,7 +191,7 @@ export const FlowfieldSt: StoryObj< smoothing, cursorRadius, cursorStrength, - cursorSmoothTime, + cursorTrail, ...mcuArgs } = args as Record; @@ -207,7 +207,7 @@ export const FlowfieldSt: StoryObj< smoothing={smoothing as number} cursorRadius={cursorRadius as number} cursorStrength={cursorStrength as number} - cursorSmoothTime={cursorSmoothTime as number} + cursorTrail={cursorTrail as number} /> From 428e282d53b0bc911521d555d074f48c9a5e7b92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:10:32 +0000 Subject: [PATCH 07/10] fix: format Flowfield.tsx with prettier to pass CI check-format Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index 4cebdf6..a0e9855 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -216,9 +216,8 @@ export function Flowfield({ new Array(gridW * gridH).fill(0), ); const rawElev = new Array(runtimePeaks.length).fill(0); - const cursorHeat = cursorTrail > 0 - ? new Array(gridW * gridH).fill(0) - : null; + const cursorHeat = + cursorTrail > 0 ? new Array(gridW * gridH).fill(0) : null; const baseThresholds = Object.keys(baseColors) .map(Number) .sort((a, b) => a - b); From 2ae110d756246bafbac1560caa81f29052d9c08e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:18:47 +0000 Subject: [PATCH 08/10] fix: distribute cursor heat proportionally to prevent peak contour overlap Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index a0e9855..0540fc5 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -296,10 +296,12 @@ export function Flowfield({ const heat = cursorHeat ? (cursorHeat[idx] as number) : 0; baseValues[idx] = maxElev + heat; - // Boost each peak's raw elevation by cursor heat so peaks are affected - if (heat > 0) { + // Distribute cursor heat proportionally to each peak's natural share + // so peaks that have no natural elevation stay at zero → no overlap + if (heat > 0 && maxElev > 0) { for (let k = 0; k < runtimePeaks.length; k++) { - rawElev[k] = (rawElev[k] ?? 0) + heat; + const e = rawElev[k] as number; + rawElev[k] = e + heat * (e / maxElev); } } From 15f534054067295a451dcdc3d3fdb2a732774c2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:30:45 +0000 Subject: [PATCH 09/10] fix: apply cursor heat globally to the field before base/peak decomposition Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index 0540fc5..4dfb608 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -293,17 +293,15 @@ export function Flowfield({ if (elev > maxElev) maxElev = elev; } + // Apply cursor heat globally to the field before base/peak decomposition const heat = cursorHeat ? (cursorHeat[idx] as number) : 0; - baseValues[idx] = maxElev + heat; - - // Distribute cursor heat proportionally to each peak's natural share - // so peaks that have no natural elevation stay at zero → no overlap - if (heat > 0 && maxElev > 0) { + if (heat > 0) { for (let k = 0; k < runtimePeaks.length; k++) { - const e = rawElev[k] as number; - rawElev[k] = e + heat * (e / maxElev); + rawElev[k] = (rawElev[k] as number) + heat; } + maxElev += heat; } + baseValues[idx] = maxElev; const pow = 8; let sum = 0.1; From 259016e14aad8d374f4cc1f502ecb0c8467fe6ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 2 Mar 2026 12:44:22 +0000 Subject: [PATCH 10/10] feat: cursor deforms terrain along pointer path instead of raising elevation Co-authored-by: abernier <76580+abernier@users.noreply.github.com> --- src/Flowfield.tsx | 108 +++++++++++++++++++++++++------------------- src/Mcu.stories.tsx | 4 +- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/src/Flowfield.tsx b/src/Flowfield.tsx index 4dfb608..1db0d6b 100644 --- a/src/Flowfield.tsx +++ b/src/Flowfield.tsx @@ -93,7 +93,7 @@ export function Flowfield({ driftAmplitude = 150, smoothing = 0, cursorRadius = 200, - cursorStrength = 800, + cursorStrength = 50, cursorTrail = 0.95, }: { /** SVG viewBox width. */ @@ -118,16 +118,18 @@ export function Flowfield({ smoothing?: number; /** Influence radius of cursor interactions in viewBox units. */ cursorRadius?: number; - /** Maximum elevation added at the cursor position. */ + /** 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 heat grid is skipped entirely for zero overhead. */ + /** 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>(new Map()); + const pointersRef = useRef< + Map + >(new Map()); const toViewBox = useCallback((clientX: number, clientY: number) => { const svg = svgRef.current; @@ -144,7 +146,11 @@ export function Flowfield({ const handlePointerMove = useCallback( (e: React.PointerEvent) => { const pos = toViewBox(e.clientX, e.clientY); - if (pos) pointersRef.current.set(e.pointerId, pos); + 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], ); @@ -216,7 +222,9 @@ export function Flowfield({ new Array(gridW * gridH).fill(0), ); const rawElev = new Array(runtimePeaks.length).fill(0); - const cursorHeat = + 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) @@ -229,41 +237,51 @@ 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 (!cursorHeat) return; - for (let i = 0; i < cursorHeat.length; i++) { - cursorHeat[i] = (cursorHeat[i] ?? 0) * cursorTrail; + 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()) { - 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 dx = i * gridScale - pos.x; - const dy = j * gridScale - pos.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist < cursorRadius) { - const factor = 1 - dist / cursorRadius; - const influence = cursorStrength * factor * factor; - const idx = j * gridW + i; - cursorHeat[idx] = Math.max(cursorHeat[idx] ?? 0, influence); - } - } - } + applyPointerDisplacement(pos, displaceX, displaceY); } } @@ -285,22 +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; } - // Apply cursor heat globally to the field before base/peak decomposition - const heat = cursorHeat ? (cursorHeat[idx] as number) : 0; - if (heat > 0) { - for (let k = 0; k < runtimePeaks.length; k++) { - rawElev[k] = (rawElev[k] as number) + heat; - } - maxElev += heat; - } baseValues[idx] = maxElev; const pow = 8; diff --git a/src/Mcu.stories.tsx b/src/Mcu.stories.tsx index 03c197e..4f93d46 100644 --- a/src/Mcu.stories.tsx +++ b/src/Mcu.stories.tsx @@ -157,7 +157,7 @@ export const FlowfieldSt: StoryObj< timeSpeed: 0.002, scheme: "expressive", cursorRadius: 200, - cursorStrength: 800, + cursorStrength: 50, cursorTrail: 0.95, }, argTypes: { @@ -175,7 +175,7 @@ export const FlowfieldSt: StoryObj< control: { type: "range", min: 0, max: 500, step: 10 }, }, cursorStrength: { - control: { type: "range", min: 0, max: 2000, step: 50 }, + control: { type: "range", min: 0, max: 200, step: 5 }, }, cursorTrail: { control: { type: "range", min: 0, max: 0.99, step: 0.01 },