diff --git a/.agents/skills/history/SKILL.md b/.agents/skills/history/SKILL.md new file mode 100644 index 00000000..ea089c47 --- /dev/null +++ b/.agents/skills/history/SKILL.md @@ -0,0 +1,36 @@ +--- +name: history +description: Draft a new history entry, or update the most recent one with `--update`. Use after a meaningful commit or set of commits. Provide the "why" as an argument — /history the reason this change was made. Use /history --update to extend the latest entry without prompting. +allowed-tools: Bash(git *) Bash(date *) Bash(mkdir *) Bash(ls *) Read Glob Edit Write +--- + +Draft a history entry and save it to `history/` following this repo's conventions. + +If `$ARGUMENTS` starts with `--update` (or `-u`), follow the **Update mode** steps. Otherwise follow **New entry** steps. + +## New entry + +1. Run `git log --oneline -15` to see recent commits and identify which ones this entry covers. +2. Run `git show --stat` on the relevant commit(s) to see what changed. +3. Read `history/PROMPT.md` — it contains the full writing instructions. Follow them. +4. Read 1–2 recent files under `history/` to match their tone and structure. +5. The **why** is: `$ARGUMENTS`. If empty, ask the user for it before proceeding — do not skip or guess. +6. Draft the entry. Use today's date for the filename. Use `## h2` sections for substantial entries, inline bold labels for small ones. +7. If any commit hashes are uncertain or not yet merged, leave them as ``. +8. Create the year directory if needed (`history/YYYY/`), then write the file to `history/YYYY/YYYY-MM-DD-topic.md`. +9. Show the user the saved file path and content. Ask them to review before committing. + +## Update mode (`--update` / `-u`) + +Use this when recent commits extend or correct a topic that already has an entry — do not create a new file, do not ask for a "why". + +1. Locate the latest entry: `ls history/*/ | tail` and pick the file with the most recent `YYYY-MM-DD-…` filename. That's the "latest topic". +2. Run `git log --oneline -15` to see recent commits. The commits to add are everything since the **Commits** line in that entry (those hashes already documented). If the entry's hashes don't exist (squashed during merge), use the new commits that touch the same area. +3. Run `git show --stat` on the new commit(s) to see what changed. +4. Read the existing entry in full so updates stay consistent with its structure and tone. +5. Update the entry in place with `Edit`: + - Append the new commit hashes to the **Commits** line (replace stale/squashed hashes if needed). **Skip commits that only touch `history/`** — entries that exist solely to refine the history doc itself shouldn't be listed as commits the entry "covers". + - Revise sections affected by the new commits (behavior matrices, migration notes, examples, API names) so the doc reflects the current state — not an addendum tacked on the end. + - Keep the original filename and date — the file represents the topic, not the latest commit. + - Anything left ambiguous by the commit messages: leave a `` rather than guessing. +6. Show the user the file path and a summary of what changed (which sections, which commits added). Ask them to review before committing. diff --git a/.claude/skills/history/SKILL.md b/.claude/skills/history/SKILL.md new file mode 100644 index 00000000..ea089c47 --- /dev/null +++ b/.claude/skills/history/SKILL.md @@ -0,0 +1,36 @@ +--- +name: history +description: Draft a new history entry, or update the most recent one with `--update`. Use after a meaningful commit or set of commits. Provide the "why" as an argument — /history the reason this change was made. Use /history --update to extend the latest entry without prompting. +allowed-tools: Bash(git *) Bash(date *) Bash(mkdir *) Bash(ls *) Read Glob Edit Write +--- + +Draft a history entry and save it to `history/` following this repo's conventions. + +If `$ARGUMENTS` starts with `--update` (or `-u`), follow the **Update mode** steps. Otherwise follow **New entry** steps. + +## New entry + +1. Run `git log --oneline -15` to see recent commits and identify which ones this entry covers. +2. Run `git show --stat` on the relevant commit(s) to see what changed. +3. Read `history/PROMPT.md` — it contains the full writing instructions. Follow them. +4. Read 1–2 recent files under `history/` to match their tone and structure. +5. The **why** is: `$ARGUMENTS`. If empty, ask the user for it before proceeding — do not skip or guess. +6. Draft the entry. Use today's date for the filename. Use `## h2` sections for substantial entries, inline bold labels for small ones. +7. If any commit hashes are uncertain or not yet merged, leave them as ``. +8. Create the year directory if needed (`history/YYYY/`), then write the file to `history/YYYY/YYYY-MM-DD-topic.md`. +9. Show the user the saved file path and content. Ask them to review before committing. + +## Update mode (`--update` / `-u`) + +Use this when recent commits extend or correct a topic that already has an entry — do not create a new file, do not ask for a "why". + +1. Locate the latest entry: `ls history/*/ | tail` and pick the file with the most recent `YYYY-MM-DD-…` filename. That's the "latest topic". +2. Run `git log --oneline -15` to see recent commits. The commits to add are everything since the **Commits** line in that entry (those hashes already documented). If the entry's hashes don't exist (squashed during merge), use the new commits that touch the same area. +3. Run `git show --stat` on the new commit(s) to see what changed. +4. Read the existing entry in full so updates stay consistent with its structure and tone. +5. Update the entry in place with `Edit`: + - Append the new commit hashes to the **Commits** line (replace stale/squashed hashes if needed). **Skip commits that only touch `history/`** — entries that exist solely to refine the history doc itself shouldn't be listed as commits the entry "covers". + - Revise sections affected by the new commits (behavior matrices, migration notes, examples, API names) so the doc reflects the current state — not an addendum tacked on the end. + - Keep the original filename and date — the file represents the topic, not the latest commit. + - Anything left ambiguous by the commit messages: leave a `` rather than guessing. +6. Show the user the file path and a summary of what changed (which sections, which commits added). Ask them to review before committing. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6aac0481..59f25e17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,8 @@ what actions will and will not be tolerated. ## Our Development Process We use Storybook to simplify the development process. You can start it by running `npm run storybook` in the root directory. If you've added a new feature, changed the configuration or public methods, please add a new example with its source code to Storybook if applicable and suggest changes to the docs. +For non-trivial changes, consider adding a short note in `history/` to capture **why** the change happened. +Use `history/README.md` for guidance, `history/PROMPT.md` to draft it with any LLM, or run the repo's `/history ` skill in Codex/Claude Code to let it gather context and write the file automatically. For follow-up commits that extend a topic that already has an entry, use `/history --update` to revise the latest entry in place. ## Pull Requests We actively welcome pull requests. If you want to submit one, please follow the following process: diff --git a/README.md b/README.md index ccf72a34..a586ab04 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,10 @@ cosmos.gl v3.0 brings a new rendering engine, async initialization, and several - **Config-driven highlighting** — imperative selection methods replaced by `highlightedPointIndices`, `highlightedLinkIndices`, and `outlinedPointIndices` config properties. Points and links are highlighted independently. Focused points are rendered with rings via `focusedPointIndex`; focused links are rendered wider via `focusedLinkIndex`. New `findPointsInRect()`, `findPointsInPolygon()`, `getNeighboringPointIndices()`, `getConnectedLinkIndices()`, and `getConnectedPointIndices()` methods. - **Hover improvements** — `onPointMouseOver` now includes `isHighlighted` and `isOutlined` parameters; hover correctly highlights the topmost point when points overlap. - **Config API changes** — `setConfig()` now resets all values to defaults before applying; use the new `setConfigPartial()` to update individual properties without resetting the rest. -- **Init-only config fields** — `enableSimulation`, `initialZoomLevel`, `randomSeed`, and `attribution` can only be set during initialization and are preserved across config updates. +- **Init-only config fields** — `initialZoomLevel`, `randomSeed`, and `attribution` can only be set during initialization and are preserved across config updates. +- **Runtime simulation toggle** — `enableSimulation` can now be changed at runtime via `setConfig()` or `setConfigPartial()`. +- **GPU transitions** — point positions, point colors/sizes, and link colors/widths now animate by default (`transitionDuration: 800`, `transitionEasing: TransitionEasing.CubicInOut`). Use `transitionDuration: 0` to keep snap updates. +- **Transition callbacks** — use `onTransitionStart`, `onTransition`, and `onTransitionEnd` to track transition lifecycle and progress. - **Default point shape** — new `pointDefaultShape` config property lets you set the fallback shape for all points when no per-point shapes are provided. Accepts a `PointShape` enum value (e.g., `PointShape.Star`), a plain number (e.g., `6`), or a numeric string (e.g., `"6"`). - **Exported defaults** — `defaultConfigValues` is now part of the public API. - **Optimized hover detection** — skips GPU work when the mouse hasn't moved. diff --git a/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md new file mode 100644 index 00000000..c6ccdb5c --- /dev/null +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -0,0 +1,94 @@ + + +# GPU transitions for positions and attributes + +**Commits:** c5fd30e, 75b1a15, 74e0567, b775c4c, 45a12f4, 579801d, 5006eea, fed8dcd, 6256b66, cd32f59 + +## Why + +We wanted Cosmos to: + +- Smoothly animate point positions, colors, and sizes (and link colors and widths) from one state to another, instead of snapping. +- Switch the simulation on and off at runtime, and actually free its GPU resources when it's off so apps that don't need layout don't pay for it. + +## Transitions + +New module `src/modules/Transition/` — a single state machine that tracks every animated property (positions, point colors/sizes, link colors/widths) in one shared cycle. + +New config: + +```ts +transitionDuration: 800, // ms; 0 or less = no animation +transitionEasing: TransitionEasing.CubicInOut, +onTransitionStart?: () => void +onTransition?: (progress: number) => void +onTransitionEnd?: (interrupted: boolean) => void +``` + +**First render after init.** Position setters never animate — there's no prior state to interpolate from. The auto-pause rule is also skipped. Attribute setters fire transition callbacks, but since there's no prior attribute data either, source equals target and the result is a visual snap. + +**Auto-pause (position transitions only).** When `render()` sees a pending **position** transition with `transitionDuration > 0` and a running simulation (and it's not the first render), the simulation pauses before the transition starts and `onSimulationPause` fires. The simulation **stays paused after the transition ends** — `setPointPositions()` signals the user wants to explore a specific layout, not have forces immediately pull nodes away from it. Call `unpause()` to resume explicitly. Color and size transitions don't compete with force updates and never pause the simulation. + +**Hover and drag during transitions.** Hover detection is skipped only during point size transitions — point hover picking reads target point sizes rather than interpolated sizes, so hit-testing would otherwise mismatch the geometry currently on screen. Position and link hover continue to use the interpolated current positions. Drag start is blocked during point position **and** point size transitions: starting a drag mid-position-transition would mean grabbing a point whose on-screen location is still moving under the cursor, and mid-size-transition the hit area would mismatch the visible point. + +**Cache invalidation.** Position and centroid caches are invalidated each frame during a position transition so `getTrackedPointPositionsMap()` / `getTrackedPointPositionsArray()` and centroid getters return the interpolated values, not stale post-transition targets — important when the simulation is paused and there's no other refresh driving cache turnover. + +**`fitView` during transition.** `fitView()` and `fitViewByPointIndices()` frame the target positions (`graph.pointPositions`), not the interpolated positions currently on screen. + +## Simulation toggle + +`enableSimulation` is now runtime-switchable via `setConfig` or `setConfigPartial` (it's **not** in `preserveInitOnlyFields`): + +```ts +graph.setConfigPartial({ enableSimulation: true }) +graph.setConfigPartial({ enableSimulation: false }) +``` + +- `false → true`: creates simulation modules and GPU resources, fires `onSimulationStart`. If a transition is mid-flight, it's interrupted first (`onTransitionEnd(true)`) and the simulation starts from the current mid-animation positions. Any **queued but not yet started** position transition is also dropped, so the next `render()` doesn't immediately auto-pause the simulation it just enabled. +- `true → false`: stops the simulation, destroys simulation-only modules and GPU resources, fires `onSimulationEnd`. Any active transition keeps playing — its state is untouched. + +`start()` and `unpause()` only interrupt a transition when **positions** are animating; an active color/size cycle keeps running. Repeated `start(alpha)` calls now reheat the simulation by resetting `alpha` and `simulationProgress`, but `onSimulationStart` only fires on a stopped/paused → running transition, not on every reheat. + +## Behavior matrix + +All rows assume a setter ran (e.g. `setPointPositions`) so a transition is **pending** when `render()` fires. `enableSimulation` = simulation on/off, `transitionDuration` = transition duration. Rows describe the **second and later** renders — on the first render, position setters always snap (see "First render after init" above). + +### Initial state at `render()` + +Each row is the state when `render()` fires. The last two columns show what happens if you flip each config via `setConfigPartial` from that state. Auto-pause applies to **position** transitions only — color/size transitions never pause the simulation. + +| `enableSimulation` + `transitionDuration` | Behavior | `enableSimulation` switch | `transitionDuration` switch | +|---|---|---|---| +| `false` + `≤0` (no simulation, no transition) | Snap. No simulation, no transition cycle. | `→ true`: starts simulation, creates modules and resources, fires `onSimulationStart`. | `→ >0`: next animation uses the new duration. | +| `false` + `>0` (no simulation, transition) | Animate. No simulation to pause. | `→ true`: interrupts the transition (`onTransitionEnd(true)`), then starts simulation from current positions, fires `onSimulationStart`. | `→ ≤0`: the next update + render cycle snaps immediately instead of animating. If a transition is already running, the next render frame interrupts it with `onTransitionEnd(true)`. | +| `true` + `≤0` (simulation, no transition) | Snap. Simulation keeps running. | `→ false`: stops simulation, destroys simulation-only resources, fires `onSimulationEnd`. | `→ >0`: next animation uses the new duration. | +| `true` + `>0` (simulation, **position** transition) | Animate. **Simulation auto-pauses and stays paused after** the transition ends; `onSimulationPause` fires. Call `unpause()` to resume. | `→ false`: stops simulation, destroys simulation-only resources, fires `onSimulationEnd`. | `→ ≤0`: the next update + render cycle snaps immediately instead of animating. `start()` only changes simulation state. If a transition is already running, the next render frame interrupts it with `onTransitionEnd(true)`; `onSimulationEnd` still only fires if the simulation is later stopped or finishes. | +| `true` + `>0` (simulation, **color/size** transition) | Animate. Simulation keeps running — color/size transitions don't compete with forces. | `→ false`: stops simulation, destroys simulation-only resources, fires `onSimulationEnd`. | `→ ≤0`: the next update + render cycle snaps immediately instead of animating. `start()` only changes simulation state. If a transition is already running, the next render frame interrupts it with `onTransitionEnd(true)`; `onSimulationEnd` still only fires if the simulation is later stopped or finishes. | + +## Migration + +The new `transitionDuration` config defaults to `800` ms, so calling `setPointPositions(...); render()` after the first render will now animate instead of snap — and if the simulation is running, it will auto-pause for the duration of the animation **and stay paused afterwards**. Call `graph.unpause()` to resume forces. + +To keep the old snap behavior, set `transitionDuration: 0` in your config: + +```ts +new Graph(el, { transitionDuration: 0, ... }) +``` + +Or disable it only for specific programmatic updates: + +```ts +graph.setConfigPartial({ transitionDuration: 0 }) +graph.setPointPositions(newPositions) +graph.render() +graph.setConfigPartial({ transitionDuration: 800 }) // restore if needed +``` + +## Example + +`src/stories/transition/` (Storybook: **Examples / Transitions → Point Transition**) — a 200k-point cloud sampled from Bryullov's *Horsewoman* (1832) that auto-loops between the picture layout and a sequence of tile scatters. Demonstrates `transitionDuration`, `TransitionEasing`, and the `onTransitionStart` / `onTransition` / `onTransitionEnd` callbacks in a self-contained, runnable setup. + +## Future work + +- **Separate timelines per property.** One clock runs all animations today, so a new one cuts the previous off. Goal: independent timelines per property (or per setter call). +- **Mid-animation attribute updates.** Point and link attribute transitions reuse their source/target buffers when topology stays the same, but smoothing updates that arrive in the middle of an already-running attribute transition is still a known edge case. diff --git a/history/2026/2026-06-03-touch-input.md b/history/2026/2026-06-03-touch-input.md new file mode 100644 index 00000000..04a0e52b --- /dev/null +++ b/history/2026/2026-06-03-touch-input.md @@ -0,0 +1,136 @@ + + +# Touch input on phones and tablets + +**Commits:** c4b93e1 + +## Why + +Cosmos relied entirely on `mouse*` events for canvas interactions. On a phone or tablet that meant three concrete bugs and a feature gap: + +- Tapping a point started a pan instead of dragging it — at `touchstart` no `pointermove` had fired yet, so `store.hoveredPoint` was undefined, `Drag.subject` returned `undefined`, and the gesture fell through to zoom. +- The first tap "hovered" the point and the second tap clicked it — synthesized mouse events left `hoveredPoint` populated between gestures, and there was no `mouseleave` on touch to clear it. +- The previously-tapped point stayed sticky — a tap on background after tapping a point would either drag the previous point or fire `onPointClick` for it. +- `onContextMenu` / `onPointContextMenu` / `onLinkContextMenu` / `onBackgroundContextMenu` were unreachable on touch. + +This entry covers the migration from mouse events to pointer events, plus a long-press recogniser for context menus, plus some adjacent fixes. + +## Pointer events instead of mouse events + +The canvas-level listeners changed: + +| Before | After | +|---|---| +| `mouseenter.cosmos` | `pointerenter.cosmos` | +| `mousemove.cosmos` | `pointermove.cosmos` | +| `mouseleave.cosmos` | `pointerleave.cosmos pointercancel.cosmos` | +| `mousemove` → `onMouseMove` | `pointermove` → `onPointerMove` | +| — | `pointerdown.cosmos` (new) | +| — | `pointerup.cosmos` (new) | + +All handlers short-circuit on `!event.isPrimary` so the second finger of a pinch can't perturb tracked state or fire spurious callbacks. The `onMouseMove` **config callback** name is preserved for back-compat — only the internal method was renamed. + +Internal field `_isMouseOnCanvas` → `_isPointerOnCanvas`. The `currentEvent` field stays typed as `… | MouseEvent | undefined` since `PointerEvent` is a structural subtype. + +## Sync pick at touchstart + +`pointerdown.cosmos` runs `findHoveredItem(true)` synchronously before d3-drag's `subject` filter runs, so `store.hoveredPoint` is correct at the instant the gesture starts. Without this, drag would still decline on touch. + +```ts +.on('pointerdown.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + this.currentEvent = event + this._shouldSuppressNextClick = false + // Touch fires no pointermove before touchstart, so hoveredPoint is empty + // when d3-drag checks it. Pick here so drag starts, not zoom. + // updateMousePosition first — findHoveredItem reads what it writes. + this._lastMouseX = event.clientX + this._lastMouseY = event.clientY + this.updateMousePosition(event) + this.findHoveredItem(true) + // … (long-press timer set below for non-mouse pointers) +}) +``` + +`findHoveredItem` gained an `immediate = false` parameter. When `true` it bypasses three gates: the `_isPointerOnCanvas` check, the `MAX_HOVER_DETECTION_DELAY` frame counter, and the `MIN_MOUSE_MOVEMENT_THRESHOLD` check. The `PointSizes` transition guard is **not** bypassed — picking is unreliable mid-transition regardless of who's asking. + +This differs from the existing `_shouldForceHoverDetection` field, which only bypasses the movement check on the next eligible RAF tick. `immediate=true` is *now, from this code path*; `_shouldForceHoverDetection=true` is *next eligible RAF*. + +## Hover sticks across `pointerleave` for touch + +```ts +.on('pointerleave.cosmos pointercancel.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + this.cancelLongPress() + this._isPointerOnCanvas = false + // Touch tap: pointerdown → pointerup → pointerleave → click + // Clearing here would empty hoveredPoint before click reads it. + // Keep it — the next tap overwrites it anyway. + if (event.pointerType !== 'mouse') return + // … mouse-only hover clear + onPointMouseOut + onLinkMouseOut + cursor reset +}) +``` + +On a touch tap the browser fires `pointerdown → pointerup → pointerleave → click`. Touch pointers cease to exist on lift-off, which is why `pointerleave` arrives before the synthesized `click`. If the leave handler cleared `hoveredPoint`, every tap on a point would route to `onBackgroundClick`. The early return preserves hover long enough for `click` to read it; the next `pointerdown` re-picks synchronously, so stale state can't carry into a new gesture. + +The mouse-only branch still clears hover and fires `onPointMouseOut` / `onLinkMouseOut` as before. + +## Long-press → contextmenu on touch + +New timer started in `pointerdown` for non-mouse pointers: + +```ts +const LONG_PRESS_DURATION_MS = 500 +const LONG_PRESS_MOVE_THRESHOLD_PX = 10 +``` + +| Gesture | Behavior | +|---|---| +| Tap a point | `onPointClick` (unchanged from desktop semantics) | +| Hold a point ≥500ms within 10px | `onPointContextMenu`; the synthesized click is dropped | +| Hold the background ≥500ms | `onBackgroundContextMenu` | +| Hold then drift past 10px | Timer cancelled; gesture becomes pan/drag | +| Browser fires its own `contextmenu` (Android Chrome on some elements) | Timer cancelled, suppress flag set — we don't double-fire and any synthesized click is dropped | + +Two helpers extracted to make this composable: `cancelLongPress()` and `fireContextMenu(event)` (the latter pulled out of `onContextMenu` so both the desktop right-click path and the long-press timer dispatch the same callback chain). + +A new field `_shouldSuppressNextClick` is set by long-press fire (and by browser-fired `contextmenu`), consumed by `onClick` to drop one synthesized click, and reset on every new `pointerdown` so it can't leak across gestures. + +## `touch-action` is config-aware + +```ts +private updateCanvasTouchAction (): void { + this.canvas.style.touchAction = + this.config.enableDrag || this.config.enableZoom ? 'none' : '' +} +``` + +Called from init and from `updateZoomDragBehaviors`. A read-only embed with `enableDrag: false` and `enableZoom: false` leaves the canvas with no `touch-action` so the surrounding page can scroll over it; toggling either flag back to `true` at runtime via `setConfigPartial` reinstates `touch-action: none` automatically. + +d3-drag sets `touch-action: none` on its own only while its behavior is attached — `updateCanvasTouchAction` covers the gap when drag is off but zoom is on, and the inverse. + +## `event.which` deprecation + +```ts +// Before +this.isRightClickMouse = event.which === 3 +// After +this.isRightClickMouse = (event.buttons & 2) !== 0 +``` + +`MouseEvent.which` is deprecated. `event.buttons` is a bitmask of currently-held buttons (bit 2 = right). It also has the right semantic during a `pointermove`: *is right button currently held* rather than *which button transitioned*. Touch reports `buttons = 0`, so `enableRightClickRepulsion` stays a desktop-only feature. + +## Migration + +- **`onMouseMove` now fires during touch gestures.** Previously it only fired after a touch ended (synthesized mousemove). Now it fires on every primary `pointermove`, matching desktop semantics — including during pinch and pan. Heavy callbacks may need their own throttle. +- **Tap behavior changed.** First-tap-on-point now fires `onPointClick`; the prior broken behavior (first tap "hovers", second tap clicks) is gone. Code that relied on it will see callbacks at different moments. +- **Touch can now reach contextmenu callbacks.** Long-press → `onContextMenu` / `onPointContextMenu` / `onLinkContextMenu` / `onBackgroundContextMenu`. Code that assumed these were desktop-only should be re-audited. +- **`event` passed to callbacks** is a `PointerEvent` (a subtype of `MouseEvent`) on the pointer-driven paths. `event.clientX` etc. still work; `instanceof MouseEvent` still passes. + +No public API removals. + +## Known caveats + +- **Two fingers on a point start a drag, not a pinch.** When the first finger lands on a point, `Drag.subject` accepts before the second finger arrives. Acceptable trade-off — symmetric with the desktop constraint that a mouse cursor can't pinch a point either. +- **Tap during a `Positions` transition still picks.** `findHoveredItem(true)` guards on `PointSizes` transitions but not `Positions`. `Drag.subject` blocks drag on both, so no drag actually starts — but `hoveredPoint` gets written and the subsequent `click` may route to a point at its target position rather than its rendered position. Same caveat as desktop click during a transition. +- **Hover stays sticky between touch gestures.** Skipping the hover clear on touch `pointerleave` is deliberate. `store.hoveredPoint` remains populated until the next `pointerdown` overwrites it. Code reading hover state from outside the click handler may show a previously-tapped point. diff --git a/history/PROMPT.md b/history/PROMPT.md new file mode 100644 index 00000000..749849e6 --- /dev/null +++ b/history/PROMPT.md @@ -0,0 +1,47 @@ +# Prompt: Draft a history entry + +Copy this into your LLM. Fill the 3 context blocks at the end. Review the output before committing. + +--- + +You are drafting an entry for this repo's `history/` folder. + +Goal: capture **why** a change happened (intent, tradeoffs, migration notes). +Git already captures **what** changed. + +Before writing, read one or two recent files under `history/` and match their level of detail, structure, and tone — the corpus is the real source of truth on style. + +## How it usually looks + +- **Filename:** `history/YYYY/YYYY-MM-DD-topic.md` — `topic` names an area of the codebase or a feature, not an action (`gpu-transitions`, `points-rendering`, not `fix-issue-42`). Same-day extras: `-02`, `-03`, ... +- **First line:** `` +- **Length:** fit the change — a few lines for a small fix, longer when the change deserves it. +- **Tone:** plain language for a teammate who was not present. +- **Structure:** flexible. Bold inline labels (`**Why:**`, `**Notes:**`) work for small entries; `## h2` sections work better for larger ones. + +## Patterns worth borrowing + +- For **breaking changes**, include a `## Migration` section with a before/after snippet. +- For **state-machine or behavior changes**, a small matrix or table often beats prose. +- **Code snippets** are welcome for new APIs or config. +- If a Storybook story or runnable demo was added for the feature, include a brief `## Example` section pointing to it — path, Storybook title, and one sentence on what it demonstrates. + +## A few ground rules + +- Don't invent facts. If something's missing or unclear, add `` instead of guessing. +- If the **why** is missing from the context, ask for it. +- Writing before merge? Leave the commit hash as `` and fill it in after. +- Output **only** the markdown content ready to save. + +--- + +## Context for this entry + +**Commits / diff:** + + +**PR or ticket info (if any):** + + +**Why this change happened (your words):** +<1-2 sentences; do not skip> diff --git a/history/README.md b/history/README.md new file mode 100644 index 00000000..8d5f8093 --- /dev/null +++ b/history/README.md @@ -0,0 +1,42 @@ +# History + +Short notes on **why** changes happened. Git has the diff; this has the intent. + +Write one when future-you (or another maintainer) would thank you. Skip trivial edits. + +**Path:** `history/YYYY/YYYY-MM-DD-topic.md` +Same day again? Use `-02`, `-03`, etc. + +**Topic slugs** name an area of the codebase or a feature, not an action — `gpu-transitions`, `points-rendering`, `simulation-cleanup` rather than `fix-issue-42` or `add-stuff`. + +**Useful content:** +- commit hash(es) — `` is fine if you write before merge +- why the change happened +- notes worth keeping (tradeoffs, migration, caveats) +- a small matrix or table when behavior gets tangled +- a pointer to a Storybook story or runnable demo if one was added for the feature + +Length is up to you. Often a screen, longer when the change deserves it. + +**Skeleton:** + +```markdown + + +# Short title + +**Commits:** abc1234 + +## Why +One or two sentences on the problem or goal. + +## Notes +What changed, what's worth knowing later — tradeoffs, caveats, migration steps. +``` + +**Writing options:** +- Yourself: write directly. +- With an LLM: use [`PROMPT.md`](./PROMPT.md). +- In Claude Code: `/history ` drafts a new entry; `/history --update` revises the latest entry in place when follow-up commits extend the same topic. + +See recent files under `history/` for examples — they're the real source of truth on style. diff --git a/migration-notes.md b/migration-notes.md index 346da76a..698de837 100644 --- a/migration-notes.md +++ b/migration-notes.md @@ -176,11 +176,40 @@ const config: GraphConfig = { /* ... */ } The following config properties can only be set during initialization (via `new Graph(div, config)`) and are ignored by `setConfig()` and `setConfigPartial()`: -- `enableSimulation` - `initialZoomLevel` - `randomSeed` - `attribution` +`enableSimulation` is runtime-switchable in v3 and can be changed via `setConfig()` and `setConfigPartial()`. + +#### Transitions Enabled by Default + +`transitionDuration` is a new config property in v3, and its default is `800`. Because of that, after the first render, updates such as `setPointPositions(...); render()` animate instead of snapping immediately. + +To preserve snap behavior from earlier versions, set: + +```ts +const graph = new Graph(div, { + transitionDuration: 0, +}) +``` + +Or disable transitions for a single update cycle: + +```ts +graph.setConfigPartial({ transitionDuration: 0 }) +graph.setPointPositions(nextPositions) +graph.render() +graph.setConfigPartial({ transitionDuration: 800 }) +``` + +**Auto-pause.** When a position transition runs while the simulation is on, the simulation auto-pauses for the transition and **stays paused afterwards**. Call `graph.unpause()` to resume forces. Set `transitionDuration: 0` to keep the v2 snap-and-keep-running behavior. + +You can track transition lifecycle via: +- `onTransitionStart` +- `onTransition` (eased progress in `[0, 1]`) +- `onTransitionEnd` (`interrupted: boolean`) + #### Simulation and Rendering Are Now Separate - `render()` — starts the render loop only; it no longer restarts the simulation. diff --git a/package-lock.json b/package-lock.json index 885de0c8..51f8a154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.10", "license": "MIT", "dependencies": { "@luma.gl/core": "~9.2.6", diff --git a/package.json b/package.json index 949b73bb..ddc07713 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.10", "description": "GPU-based force graph layout and rendering", "jsdelivr": "dist/index.min.js", "main": "dist/index.js", diff --git a/src/config.ts b/src/config.ts index c8920a66..d0dcb100 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,15 +4,45 @@ import { D3DragEvent } from 'd3-drag' import { type Hovered } from '@/graph/modules/Store' import { defaultConfigValues } from '@/graph/variables' import { PointShape } from '@/graph/modules/GraphData' +import { type TransitionEasing } from '@/graph/modules/Transition' export interface GraphConfigInterface { /** * If set to `false`, the simulation will not run. - * This property will be applied only on component initialization and it - * can't be changed using the `setConfig` or `setConfigPartial` methods. + * Can be toggled at runtime using `setConfig` or `setConfigPartial`. * Default value: `true` */ enableSimulation: boolean; + /** + * Transition duration in milliseconds. + * Default value: `800` + * @note When a position transition is triggered via `setPointPositions()`, the simulation + * is automatically paused for the duration and remains paused afterwards so forces do not + * pull nodes away from the target layout. Call `unpause()` to resume the simulation explicitly. + */ + transitionDuration: number; + /** + * Easing curve for transitions. + * Default value: `TransitionEasing.CubicInOut` + */ + transitionEasing: TransitionEasing | `${TransitionEasing}`; + /** + * Callback function that will be called when a transition starts. + * Default value: `undefined` + */ + onTransitionStart?: () => void; + /** + * Callback function that will be called on every transition frame. + * The `progress` value ranges from 0 to 1. + * Default value: `undefined` + */ + onTransition?: (progress: number) => void; + /** + * Callback function that will be called when a transition ends. + * `interrupted` is `true` when a transition was replaced or aborted before completion. + * Default value: `undefined` + */ + onTransitionEnd?: (interrupted: boolean) => void; /** * Canvas background color. * Can be either a hex color string (e.g., '#b3b3b3') or an array of RGBA values. diff --git a/src/declaration.d.ts b/src/declaration.d.ts index f1bf05f0..1926ac9a 100644 --- a/src/declaration.d.ts +++ b/src/declaration.d.ts @@ -5,6 +5,11 @@ declare module '*.png' { // eslint-disable-next-line import/no-default-export export default content } +declare module '*.jpg' { + const content: string + // eslint-disable-next-line import/no-default-export + export default content +} declare module '*?raw' { const content: string // eslint-disable-next-line import/no-default-export diff --git a/src/index.ts b/src/index.ts index 84b1cef2..45c18fc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,9 +19,14 @@ import { GraphData } from '@/graph/modules/GraphData' import { Lines } from '@/graph/modules/Lines' import { Points } from '@/graph/modules/Points' import { Store, ALPHA_MIN, MAX_HOVER_DETECTION_DELAY, MIN_MOUSE_MOVEMENT_THRESHOLD, type Hovered } from '@/graph/modules/Store' +import { Transition, TransitionProperty } from '@/graph/modules/Transition' import { Zoom } from '@/graph/modules/Zoom' import { Drag } from '@/graph/modules/Drag' +/** Touch/pen long-press → context menu thresholds. */ +const LONG_PRESS_DURATION_MS = 500 +const LONG_PRESS_MOVE_THRESHOLD_PX = 10 + export class Graph { /** Current graph configuration. Always fully populated with default values for any unset properties. */ public config: GraphConfigInterface = createDefaultConfig() @@ -44,6 +49,22 @@ export class Graph { private shouldDestroyDevice: boolean private requestAnimationFrameId = 0 private isRightClickMouse = false + /** + * Touch/pen long-press timer. Set on pointerdown for non-mouse pointers and + * cancelled on pointerup, pointercancel, or movement past + * LONG_PRESS_MOVE_THRESHOLD_PX. When it fires, the contextmenu callback chain + * runs and the synthesized click is suppressed. + */ + private _longPressTimerId: number | undefined + private _longPressStartX = 0 + private _longPressStartY = 0 + /** + * Set when long-press fires contextmenu (or a browser-dispatched contextmenu + * handles itself) so the synthesized click that follows the same touch is + * dropped instead of routed to onPointClick/onBackgroundClick/onLinkClick. + * Consumed by onClick; also cleared on next pointerdown. + */ + private _shouldSuppressNextClick = false private store = new Store() private points: Points | undefined @@ -56,7 +77,8 @@ export class Graph { private forceMouse: ForceMouse | undefined private clusters: Clusters | undefined private zoomInstance = new Zoom(this.store, this.config) - private dragInstance = new Drag(this.store, this.config) + private transition = new Transition(this.config) + private dragInstance = new Drag(this.store, this.config, this.transition) private fpsMonitor: FPSMonitor | undefined @@ -67,9 +89,9 @@ export class Graph { */ private _findHoveredItemExecutionCount = 0 /** - * If the mouse is not on the Canvas, the `findHoveredPoint` or `findHoveredLine` method will not be executed. + * If no pointer is over the Canvas, the `findHoveredPoint` or `findHoveredLine` method will not be executed. */ - private _isMouseOnCanvas = false + private _isPointerOnCanvas = false /** * Last mouse position for detecting significant mouse movement */ @@ -159,6 +181,7 @@ export class Graph { deviceCanvas.style.width = '100%' deviceCanvas.style.height = '100%' this.canvas = deviceCanvas + this.updateCanvasTouchAction() const w = this.canvas.clientWidth const h = this.canvas.clientHeight @@ -168,19 +191,49 @@ export class Graph { this.store.updateScreenSize(w, h) this.canvasD3Selection = select(this.canvas) - this.canvasD3Selection - .on('mouseenter.cosmos', (event) => { - this._isMouseOnCanvas = true + .call(this.dragInstance.behavior) + .call(this.zoomInstance.behavior) + .on('pointerenter.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + this._isPointerOnCanvas = true this._lastMouseX = event.clientX this._lastMouseY = event.clientY }) - .on('mousemove.cosmos', (event) => { - this._isMouseOnCanvas = true + .on('pointermove.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + this._isPointerOnCanvas = true this._lastMouseX = event.clientX this._lastMouseY = event.clientY + this.currentEvent = event + this.updateMousePosition(event) + this.isRightClickMouse = (event.buttons & 2) !== 0 + + // Cancel a pending long-press if the finger drifted past the threshold — + // the user is clearly panning/dragging, not holding to open a context menu. + if (this._longPressTimerId !== undefined) { + const dx = Math.abs(event.clientX - this._longPressStartX) + const dy = Math.abs(event.clientY - this._longPressStartY) + if (dx > LONG_PRESS_MOVE_THRESHOLD_PX || dy > LONG_PRESS_MOVE_THRESHOLD_PX) { + this.cancelLongPress() + } + } + + this.config.onMouseMove?.( + this.store.hoveredPoint?.index, + this.store.hoveredPoint?.position, + this.currentEvent + ) }) - .on('mouseleave.cosmos', (event) => { - this._isMouseOnCanvas = false + .on('pointerleave.cosmos pointercancel.cosmos', (event: PointerEvent) => { + // Non-primary pointers (e.g. second finger of a pinch) leaving must not + // flip _isPointerOnCanvas or clear hover — the primary pointer is still down. + if (!event.isPrimary) return + this.cancelLongPress() + this._isPointerOnCanvas = false + // Touch tap: pointerdown → pointerup → pointerleave → click + // Clearing here would empty hoveredPoint before click reads it. + // Keep it — the next tap overwrites it anyway. + if (event.pointerType !== 'mouse') return this.currentEvent = event // Clear point hover state and trigger callback if needed @@ -203,9 +256,52 @@ export class Graph { // Update cursor style after clearing hover states this.updateCanvasCursor() }) + .on('pointerdown.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + this.currentEvent = event + // A new gesture starts fresh — drop any stale suppress flag. + this._shouldSuppressNextClick = false + // Touch fires no pointermove before touchstart, so hoveredPoint is empty + // when d3-drag checks it. Pick here so drag starts, not zoom. + // updateMousePosition first — findHoveredItem reads what it writes. + this._lastMouseX = event.clientX + this._lastMouseY = event.clientY + this.updateMousePosition(event) + this.findHoveredItem(true) + + // Touch/pen long-press → contextmenu. The mouse path already gets + // contextmenu from the browser; this fills the gap for touch where + // long-press doesn't reliably dispatch contextmenu on canvas. + if (event.pointerType !== 'mouse') { + this._longPressStartX = event.clientX + this._longPressStartY = event.clientY + this.cancelLongPress() + this._longPressTimerId = window.setTimeout(() => { + this._longPressTimerId = undefined + if (this._isDestroyed) return + // Re-pick in case points moved during the hold (simulation may have + // shifted them under the stationary finger). + this.findHoveredItem(true) + this._shouldSuppressNextClick = true + this.fireContextMenu(event) + }, LONG_PRESS_DURATION_MS) + } + }) + .on('pointerup.cosmos', (event: PointerEvent) => { + if (!event.isPrimary) return + // Finger lifted before the long-press window expired — it's a tap. + this.cancelLongPress() + // pointermove normally updates this flag, but it doesn't fire on a still + // release — without this line, forceMouse would keep running. + this.isRightClickMouse = (event.buttons & 2) !== 0 + }) + .on('click.cosmos', this.onClick.bind(this)) + .on('contextmenu.cosmos', this.onContextMenu.bind(this)) + select(document) .on('keydown.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) .on('keyup.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = false }) + this.zoomInstance.behavior .on('start.detect', (e: D3ZoomEvent) => { this.currentEvent = e }) .on('zoom.detect', (e: D3ZoomEvent) => { @@ -218,6 +314,7 @@ export class Graph { // Force hover detection on next frame since zoom may have changed what's under the mouse this._shouldForceHoverDetection = true }) + this.dragInstance.behavior .on('start.detect', (e: D3DragEvent) => { this.currentEvent = e @@ -233,12 +330,6 @@ export class Graph { this.currentEvent = e this.updateCanvasCursor() }) - this.canvasD3Selection - .call(this.dragInstance.behavior) - .call(this.zoomInstance.behavior) - .on('click', this.onClick.bind(this)) - .on('mousemove', this.onMouseMove.bind(this)) - .on('contextmenu', this.onContextMenu.bind(this)) if (!this.config.enableZoom || !this.config.enableDrag) this.updateZoomDragBehaviors() // Zoom level 1 means no zoom (100% scale). defaultConfigValues.initialZoomLevel is undefined, // so we fall back to 1 here as the neutral zoom level when no initial zoom is configured. @@ -251,6 +342,7 @@ export class Graph { this.store.isSimulationRunning = this.config.enableSimulation this.points = new Points(device, this.config, this.store, this.graph) + this.points.transition = this.transition this.lines = new Lines(device, this.config, this.store, this.graph, this.points) if (this.config.enableSimulation) { this.forceGravity = new ForceGravity(device, this.config, this.store, this.graph, this.points) @@ -364,6 +456,8 @@ export class Graph { * @param {boolean | undefined} dontRescale - For this call only, don't rescale the points. * - `true`: Don't rescale. * - `false` or `undefined` (default): Use the behavior defined by `config.rescalePositions`. + * @note If `transitionDuration > 0` and the simulation is running, the simulation is automatically + * paused for the transition and remains paused afterwards. Call `unpause()` to resume it. */ public setPointPositions (pointPositions: Float32Array, dontRescale?: boolean | undefined): void { if (this._isDestroyed) return @@ -372,6 +466,10 @@ export class Graph { this.graph.inputPointPositions = pointPositions this.points!.shouldSkipRescale = dontRescale this.isPointPositionsUpdateNeeded = true + const currentPositionTexture = this.points?.currentPositionTexture + if (currentPositionTexture && !currentPositionTexture.destroyed) { + this.transition.queue(TransitionProperty.Positions) + } // Links related texture depends on point positions, so we need to update it this.isLinksUpdateNeeded = true // Point related textures depend on point positions length, so we need to update them @@ -399,6 +497,7 @@ export class Graph { if (this.ensureDevice(() => this.setPointColors(pointColors))) return this.graph.inputPointColors = pointColors this.isPointColorUpdateNeeded = true + this.transition.queue(TransitionProperty.PointColors) } /** @@ -424,6 +523,7 @@ export class Graph { if (this.ensureDevice(() => this.setPointSizes(pointSizes))) return this.graph.inputPointSizes = pointSizes this.isPointSizeUpdateNeeded = true + this.transition.queue(TransitionProperty.PointSizes) } /** @@ -529,6 +629,7 @@ export class Graph { if (this.ensureDevice(() => this.setLinkColors(linkColors))) return this.graph.inputLinkColors = linkColors this.isLinkColorUpdateNeeded = true + this.transition.queue(TransitionProperty.LinkColors) } /** @@ -554,6 +655,7 @@ export class Graph { if (this.ensureDevice(() => this.setLinkWidths(linkWidths))) return this.graph.inputLinkWidths = linkWidths this.isLinkWidthUpdateNeeded = true + this.transition.queue(TransitionProperty.LinkWidths) } /** @@ -717,6 +819,27 @@ export class Graph { } // Update graph and start frames this.update(simulationAlpha) + + // Position transitions must not compete with live force updates — pause the simulation + // so physics and GPU interpolation don't fight over the same coordinates. + // The simulation is intentionally left paused after the transition ends: calling + // setPointPositions() implies the user wants to explore a specific layout, not have + // forces immediately pull nodes away from it. Call unpause() to resume explicitly. + // Color/size transitions are independent of physics and must not pause the simulation. + if (this.transition.isPendingFor(TransitionProperty.Positions) && + this.store.isSimulationRunning && + this.config.transitionDuration > 0 && + !this._isFirstRenderAfterInit) { + this.store.isSimulationRunning = false + this.config.onSimulationPause?.() + } + + const currentPositionTexture = this.points?.currentPositionTexture + if (this.transition.isPending && (!currentPositionTexture || currentPositionTexture.destroyed)) { + this.transition.abort() + } + + this.transition.start() // Re-detect hover on the next frame since data may have changed under a stationary mouse this._shouldForceHoverDetection = true this.startFrames() @@ -852,8 +975,7 @@ export class Graph { if (this._isDestroyed) return if (this.ensureDevice(() => this.fitView(duration, padding, enableSimulation))) return - - this.setZoomTransformByPointPositions(new Float32Array(this.getPointPositions()), duration, undefined, padding, enableSimulation) + this.setZoomTransformByPointPositions(this.getFitViewPositions(), duration, undefined, padding, enableSimulation) } /** @@ -867,7 +989,7 @@ export class Graph { if (this._isDestroyed) return if (this.ensureDevice(() => this.fitViewByPointIndices(indices, duration, padding, enableSimulation))) return - const positionsArray = this.getPointPositions() + const positionsArray = this.getFitViewPositions() const positions = new Float32Array(indices.length * 2) for (const [i, index] of indices.entries()) { positions[i * 2] = positionsArray[index * 2] as number @@ -1138,6 +1260,9 @@ export class Graph { /** * Start the simulation. * This only controls the simulation state, not rendering. + * If the simulation is already running, calling `start(alpha)` reheats it by + * resetting `alpha` and `simulationProgress` without firing + * `onSimulationStart` again. * @param alpha Value from 0 to 1. The higher the value, the more initial energy the simulation will get. */ public start (alpha = 1): void { @@ -1145,13 +1270,17 @@ export class Graph { if (this.ensureDevice(() => this.start(alpha))) return + if (!this.config.enableSimulation) return if (!this.graph.pointsNumber) return - - // Always set simulation as running when start() is called + if (this.transition.isActiveFor(TransitionProperty.Positions)) { + // Avoids running simulation against mid-interpolation positions. + this.transition.end(true) + } + const wasRunning = this.store.isSimulationRunning this.store.isSimulationRunning = true this.store.simulationProgress = 0 this.store.alpha = alpha - this.config.onSimulationStart?.() + if (!wasRunning) this.config.onSimulationStart?.() // Note: Does NOT start frames - that's handled separately } @@ -1162,10 +1291,11 @@ export class Graph { */ public stop (): void { if (this._isDestroyed) return + const wasSimulationActive = this.store.isSimulationRunning || this.store.alpha > 0 || this.store.simulationProgress > 0 this.store.isSimulationRunning = false this.store.simulationProgress = 0 this.store.alpha = 0 - this.config.onSimulationEnd?.() + if (wasSimulationActive) this.config.onSimulationEnd?.() } /** @@ -1176,6 +1306,7 @@ export class Graph { public pause (): void { if (this._isDestroyed) return if (this.ensureDevice(() => this.pause())) return + if (!this.store.isSimulationRunning) return this.store.isSimulationRunning = false this.config.onSimulationPause?.() } @@ -1187,6 +1318,12 @@ export class Graph { public unpause (): void { if (this._isDestroyed) return if (this.ensureDevice(() => this.unpause())) return + if (!this.config.enableSimulation) return + if (this.store.isSimulationRunning) return + if (this.transition.isActiveFor(TransitionProperty.Positions)) { + // Avoids running simulation against mid-interpolation positions. + this.transition.end(true) + } this.store.isSimulationRunning = true this.config.onSimulationUnpause?.() } @@ -1214,25 +1351,22 @@ export class Graph { if (this._isDestroyed) return this._isDestroyed = true this.isReady = false + this.transition.abort() window.clearTimeout(this._fitViewOnInitTimeoutID) + this.cancelLongPress() this.stopFrames() - // Remove all event listeners + // Remove all event listeners — `.on('.cosmos', null)` clears every handler + // in the `.cosmos` namespace at once (canvas pointer/click/contextmenu and + // document key listeners), same trick we use for `.drag` / `.zoom`. if (this.canvasD3Selection) { this.canvasD3Selection - .on('mouseenter.cosmos', null) - .on('mousemove.cosmos', null) - .on('mouseleave.cosmos', null) - .on('click', null) - .on('mousemove', null) - .on('contextmenu', null) + .on('.cosmos', null) .on('.drag', null) .on('.zoom', null) } - select(document) - .on('keydown.cosmos', null) - .on('keyup.cosmos', null) + select(document).on('.cosmos', null) if (this.zoomInstance?.behavior) { this.zoomInstance.behavior @@ -1361,21 +1495,35 @@ export class Graph { } /** - * Restores init-only fields (`enableSimulation`, `initialZoomLevel`, `randomSeed`, `attribution`) + * Restores init-only fields (`initialZoomLevel`, `randomSeed`, `attribution`) * to their pre-update values, preventing runtime changes via setConfig/setConfigPartial. */ private preserveInitOnlyFields (prevConfig: GraphConfigInterface): void { - this.config.enableSimulation = prevConfig.enableSimulation this.config.initialZoomLevel = prevConfig.initialZoomLevel this.config.randomSeed = prevConfig.randomSeed this.config.attribution = prevConfig.attribution } + private getFitViewPositions (): Float32Array { + const useTargetPositions = + this.transition.isActive && + this.transition.isActiveFor(TransitionProperty.Positions) && + !!this.graph.pointPositions + + if (useTargetPositions && this.graph.pointPositions) { + return new Float32Array(this.graph.pointPositions) + } + + return new Float32Array(this.getPointPositions()) + } + /** * Compares the previous config snapshot with the current `this.config` and * applies any necessary side effects (updating renderers, store, behaviors, etc.). */ private updateStateFromConfig (prevConfig: GraphConfigInterface): void { + this.applyEnableSimulationConfigChange(prevConfig) + if (prevConfig.pointDefaultColor !== this.config.pointDefaultColor) { this.graph.updatePointColor() this.points?.updateColor() @@ -1473,6 +1621,41 @@ export class Graph { } } + /** + * Applies `enableSimulation` lifecycle changes triggered by config updates. + */ + private applyEnableSimulationConfigChange (prevConfig: GraphConfigInterface): void { + if (prevConfig.enableSimulation === this.config.enableSimulation) return + + if (this.config.enableSimulation) { + // Avoids running simulation against mid-interpolation positions. + this.transition.end(true) + this.transition.dequeue(TransitionProperty.Positions) + this.ensureSimulationModules() + this.points?.ensureSimulationResources() + this.isForceManyBodyUpdateNeeded = true + this.isForceLinkUpdateNeeded = true + this.isForceCenterUpdateNeeded = true + // Rebuild simulation resources before binding programs to them. + this.create() + this.initPrograms() + this.store.simulationProgress = 0 + this.store.alpha = 1 + this.store.isSimulationRunning = true + this._shouldForceHoverDetection = true + this.config.onSimulationStart?.() + return + } + + const wasSimulationActive = this.store.isSimulationRunning || this.store.alpha > 0 || this.store.simulationProgress > 0 + this.store.isSimulationRunning = false + this.store.alpha = 0 + this.store.simulationProgress = 0 + this._shouldForceHoverDetection = true + if (wasSimulationActive) this.config.onSimulationEnd?.() + this.destroySimulationModules() + } + /** * Ensures device is initialized before executing a method. * If device is not ready, queues the method to run after initialization. @@ -1643,6 +1826,33 @@ export class Graph { this.clusters.initPrograms() } + private ensureSimulationModules (): void { + if (!this.device || !this.points) return + + this.forceGravity ||= new ForceGravity(this.device, this.config, this.store, this.graph, this.points) + this.forceCenter ||= new ForceCenter(this.device, this.config, this.store, this.graph, this.points) + this.forceManyBody ||= new ForceManyBody(this.device, this.config, this.store, this.graph, this.points) + this.forceLinkIncoming ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points) + this.forceLinkOutgoing ||= new ForceLink(this.device, this.config, this.store, this.graph, this.points) + this.forceMouse ||= new ForceMouse(this.device, this.config, this.store, this.graph, this.points) + } + + private destroySimulationModules (): void { + this.forceGravity?.destroy() + this.forceGravity = undefined + this.forceCenter?.destroy() + this.forceCenter = undefined + this.forceManyBody?.destroy() + this.forceManyBody = undefined + this.forceLinkIncoming?.destroy() + this.forceLinkIncoming = undefined + this.forceLinkOutgoing?.destroy() + this.forceLinkOutgoing = undefined + this.forceMouse?.destroy() + this.forceMouse = undefined + this.points?.destroySimulationResources() + } + /** * The rendering loop - schedules itself to run continuously */ @@ -1674,8 +1884,27 @@ export class Graph { if (this._isDestroyed) return if (!this.store.pointsTextureSize) return + const frameNow = now ?? performance.now() this.fpsMonitor?.begin() this.resizeCanvas() + + const shouldInterpolatePositions = this.transition.isActiveFor(TransitionProperty.Positions) + const shouldAnimatePointColors = this.transition.isActiveFor(TransitionProperty.PointColors) + const shouldAnimatePointSizes = this.transition.isActiveFor(TransitionProperty.PointSizes) + const shouldAnimateLinkColors = this.transition.isActiveFor(TransitionProperty.LinkColors) + const shouldAnimateLinkWidths = this.transition.isActiveFor(TransitionProperty.LinkWidths) + if (this.transition.isActive) { + this.transition.step() + + if (shouldInterpolatePositions) { + this.points?.interpolatePosition(this.transition.progress) + this.points?.trackPoints() + } + } + + this.points?.setTransitionProgress(this.transition.progress, shouldAnimatePointColors, shouldAnimatePointSizes) + this.lines?.setTransitionProgress(this.transition.progress, shouldAnimateLinkColors, shouldAnimateLinkWidths) + if (!this.dragInstance.isActive) { this.findHoveredItem() } @@ -1722,7 +1951,7 @@ export class Graph { this.device.submit() } - this.fpsMonitor?.end(now ?? performance.now()) + this.fpsMonitor?.end(frameNow) this.currentEvent = undefined } @@ -1756,6 +1985,12 @@ export class Graph { } private onClick (event: MouseEvent): void { + if (this._shouldSuppressNextClick) { + // Long-press just fired contextmenu for this same touch; drop the + // synthesized click so callers don't see a click + contextmenu pair. + this._shouldSuppressNextClick = false + return + } this.config.onClick?.( this.store.hoveredPoint?.index, this.store.hoveredPoint?.position, @@ -1789,20 +2024,29 @@ export class Graph { this.store.screenMousePosition = [mouseX, (this.store.screenSize[1] - mouseY)] } - private onMouseMove (event: MouseEvent): void { - this.currentEvent = event - this.updateMousePosition(event) - this.isRightClickMouse = event.which === 3 - this.config.onMouseMove?.( - this.store.hoveredPoint?.index, - this.store.hoveredPoint?.position, - this.currentEvent - ) - } - private onContextMenu (event: MouseEvent): void { event.preventDefault() + // The browser may fire contextmenu on its own during a long-press (Android + // Chrome does this on some elements). Cancel our timer so we don't also + // fire it, and suppress the click that some browsers still synthesize after. + this.cancelLongPress() + this._shouldSuppressNextClick = true + this.fireContextMenu(event) + } + + /** Clear the pending touch/pen long-press timer, if any. */ + private cancelLongPress (): void { + if (this._longPressTimerId !== undefined) { + window.clearTimeout(this._longPressTimerId) + this._longPressTimerId = undefined + } + } + /** + * Dispatch the contextmenu callback chain — shared between the desktop + * `contextmenu` handler and the touch/pen long-press timer. + */ + private fireContextMenu (event: MouseEvent): void { this.config.onContextMenu?.( this.store.hoveredPoint?.index, this.store.hoveredPoint?.position, @@ -1868,11 +2112,27 @@ export class Graph { ?.call(this.zoomInstance.behavior) .on('wheel.zoom', null) } + + this.updateCanvasTouchAction() } - private findHoveredItem (): void { - if (this._isDestroyed || !this._isMouseOnCanvas) return - if (this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { + /** + * Only steal touch gestures when cosmos uses them. With both flags off + * the page can scroll over the canvas. + */ + private updateCanvasTouchAction (): void { + this.canvas.style.touchAction = + this.config.enableDrag || this.config.enableZoom ? 'none' : '' + } + + private findHoveredItem (immediate = false): void { + if (this._isDestroyed) return + if (!immediate && !this._isPointerOnCanvas) return + // TODO: Hover can stay enabled during point size transitions once point picking + // consumes the same interpolated point sizes as the draw pass. + // Picking is unreliable mid-transition, so we skip even when called immediately. + if (this.transition.isActiveFor(TransitionProperty.PointSizes)) return + if (!immediate && this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { this._findHoveredItemExecutionCount += 1 return } @@ -1883,7 +2143,7 @@ export class Graph { const mouseMoved = deltaX > MIN_MOUSE_MOVEMENT_THRESHOLD || deltaY > MIN_MOUSE_MOVEMENT_THRESHOLD // Skip if mouse hasn't moved AND not forced - if (!mouseMoved && !this._shouldForceHoverDetection) { + if (!immediate && !mouseMoved && !this._shouldForceHoverDetection) { return } @@ -2026,6 +2286,7 @@ export class Graph { export type { GraphConfig } from './config' export { PointShape } from './modules/GraphData' +export { TransitionEasing } from './modules/Transition' export * from './variables' export * from './helper' diff --git a/src/modules/Drag/index.ts b/src/modules/Drag/index.ts index 4684fa78..b8fa3313 100644 --- a/src/modules/Drag/index.ts +++ b/src/modules/Drag/index.ts @@ -1,13 +1,23 @@ import { drag } from 'd3-drag' import { Store } from '@/graph/modules/Store' import { type GraphConfigInterface } from '@/graph/config' +import { Transition, TransitionProperty } from '@/graph/modules/Transition' export class Drag { public readonly store: Store public readonly config: GraphConfigInterface + public readonly transition: Transition public isActive = false public behavior = drag() .subject((event) => { + // Block drag start while positions are animating so we don't begin dragging + // a point whose on-screen location is still moving under the cursor. + // TODO: Point drag can stay enabled during size transitions once hover picking + // consumes the same interpolated point sizes as the draw pass. + if ( + this.transition.isActiveFor(TransitionProperty.Positions) || + this.transition.isActiveFor(TransitionProperty.PointSizes) + ) return undefined return this.store.hoveredPoint && !this.store.isSpaceKeyPressed ? { x: event.x, y: event.y } : undefined }) .on('start', (e) => { @@ -26,8 +36,9 @@ export class Drag { this.config.onDragEnd?.(e) }) - public constructor (store: Store, config: GraphConfigInterface) { + public constructor (store: Store, config: GraphConfigInterface, transition: Transition) { this.store = store this.config = config + this.transition = transition } } diff --git a/src/modules/GraphData/index.ts b/src/modules/GraphData/index.ts index ee6cbb6b..63edd14a 100644 --- a/src/modules/GraphData/index.ts +++ b/src/modules/GraphData/index.ts @@ -31,6 +31,18 @@ export class GraphData { public inputPinnedPoints: number[] | undefined public pointPositions: Float32Array | undefined + /** + * Number of points before the latest data update. + * Used as the `from` value for point transitions. + * This lets transitions handle added or removed points correctly. + */ + public sourcePointsNumber = 0 + /** + * Number of points after the latest data update. + * Used as the `to` value for point transitions. + * This lets transitions handle added or removed points correctly. + */ + public targetPointsNumber = 0 public pointColors: Float32Array | undefined public pointSizes: Float32Array | undefined public pointShapes: Float32Array | undefined @@ -75,7 +87,9 @@ export class GraphData { } public updatePoints (): void { + this.sourcePointsNumber = this.pointPositions ? this.pointPositions.length / 2 : 0 this.pointPositions = this.inputPointPositions + this.targetPointsNumber = this.pointPositions ? this.pointPositions.length / 2 : 0 } /** diff --git a/src/modules/Lines/draw-curve-line.vert b/src/modules/Lines/draw-curve-line.vert index b19cdc31..cb7e0b34 100644 --- a/src/modules/Lines/draw-curve-line.vert +++ b/src/modules/Lines/draw-curve-line.vert @@ -4,8 +4,10 @@ precision highp float; #endif in vec2 position, pointA, pointB; -in vec4 color; -in float width; +in vec4 sourceColor; +in vec4 targetColor; +in float sourceWidth; +in float targetWidth; in float arrow; in float linkIndices; @@ -37,6 +39,9 @@ layout(std140) uniform drawLineUniforms { float linkStatusTextureSize; float focusedLinkIndex; float focusedLinkWidthIncrease; + float transitionProgress; + float animateColors; + float animateWidths; } drawLine; #define transformationMatrix drawLine.transformationMatrix @@ -62,6 +67,9 @@ layout(std140) uniform drawLineUniforms { #define linkStatusTextureSize drawLine.linkStatusTextureSize #define focusedLinkIndex drawLine.focusedLinkIndex #define focusedLinkWidthIncrease drawLine.focusedLinkWidthIncrease +#define transitionProgress drawLine.transitionProgress +#define animateColors drawLine.animateColors +#define animateWidths drawLine.animateWidths #else uniform mat3 transformationMatrix; uniform float pointsTextureSize; @@ -87,6 +95,9 @@ uniform float isLinkHighlightingActive; uniform float linkStatusTextureSize; uniform float focusedLinkIndex; uniform float focusedLinkWidthIncrease; +uniform float transitionProgress; +uniform float animateColors; +uniform float animateWidths; #endif out vec4 rgbaColor; @@ -155,9 +166,16 @@ void main() { // Convert link distance to screen pixels float linkDistPx = linkDist * transformationMatrix[0][0]; + + float lineWidthBase = animateWidths > 0.0 + ? mix(sourceWidth, targetWidth, transitionProgress) + : targetWidth; + vec4 lineColor = animateColors > 0.0 + ? mix(sourceColor, targetColor, transitionProgress) + : targetColor; // Calculate line width using the width scale - float linkWidth = width * widthScale; + float linkWidth = lineWidthBase * widthScale; float k = 2.0; // Arrow width is proportionally larger than the line width float arrowWidth = linkWidth * k; @@ -211,9 +229,9 @@ void main() { // Calculate final color with opacity based on link distance - vec3 rgbColor = color.rgb; + vec3 rgbColor = lineColor.rgb; // Adjust opacity based on link distance - float opacity = color.a * linkOpacity * max(linkVisibilityMinTransparency, map(linkDistPx, linkVisibilityDistanceRange.g, linkVisibilityDistanceRange.r, 0.0, 1.0)); + float opacity = lineColor.a * linkOpacity * max(linkVisibilityMinTransparency, map(linkDistPx, linkVisibilityDistanceRange.g, linkVisibilityDistanceRange.r, 0.0, 1.0)); // Apply greyed-out opacity from link status texture if (isLinkHighlightingActive > 0.0 && linkStatusTextureSize > 0.0) { diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index 32f8307b..cad8ddb4 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -11,6 +11,7 @@ import hoveredLineIndexFrag from '@/graph/modules/Lines/hovered-line-index.frag? import hoveredLineIndexVert from '@/graph/modules/Lines/hovered-line-index.vert?raw' import { defaultConfigValues } from '@/graph/variables' import { getCurveLineGeometry } from '@/graph/modules/Lines/geometry' +import { updateAttributeBuffers } from '@/graph/modules/Shared/buffer' import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils' import { ensureVec2, ensureVec4 } from '@/graph/modules/Shared/uniform-utils' import { readPixels } from '@/graph/helper' @@ -26,8 +27,12 @@ export class Lines extends CoreModule { private fillSampledLinksFboCommand: Model | undefined private pointABuffer: Buffer | undefined private pointBBuffer: Buffer | undefined - private colorBuffer: Buffer | undefined - private widthBuffer: Buffer | undefined + private sourceColorBuffer: Buffer | undefined + private targetColorBuffer: Buffer | undefined + private previousColorData: Float32Array | undefined + private sourceWidthBuffer: Buffer | undefined + private targetWidthBuffer: Buffer | undefined + private previousWidthData: Float32Array | undefined private arrowBuffer: Buffer | undefined private curveLineGeometry: number[][] | undefined private curveLineBuffer: Buffer | undefined @@ -35,6 +40,9 @@ export class Lines extends CoreModule { private quadBuffer: Buffer | undefined private linkIndexTexture: Texture | undefined private hoveredLineIndexTexture: Texture | undefined + private transitionProgress = 1 + private shouldAnimateLinkColors = false + private shouldAnimateLinkWidths = false private fillSampledLinksUniformStore: UniformStore<{ fillSampledLinksUniforms: { pointsTextureSize: number; @@ -73,6 +81,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: number; focusedLinkIndex: number; focusedLinkWidthIncrease: number; + transitionProgress: number; + animateColors: number; + animateWidths: number; }; drawLineFragmentUniforms: { renderMode: number; @@ -123,14 +134,6 @@ export class Lines extends CoreModule { data: new Float32Array(linksNumber * 2), usage: Buffer.VERTEX | Buffer.COPY_DST, }) - this.colorBuffer ||= device.createBuffer({ - data: new Float32Array(linksNumber * 4), - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - this.widthBuffer ||= device.createBuffer({ - data: new Float32Array(linksNumber), - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) this.arrowBuffer ||= device.createBuffer({ data: new Float32Array(linksNumber), usage: Buffer.VERTEX | Buffer.COPY_DST, @@ -167,6 +170,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: 'f32', focusedLinkIndex: 'f32', focusedLinkWidthIncrease: 'f32', + transitionProgress: 'f32', + animateColors: 'f32', + animateWidths: 'f32', }, defaultUniforms: { transformationMatrix: store.transformationMatrix4x4, @@ -192,6 +198,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: 0, focusedLinkIndex: config.focusedLinkIndex ?? -1, focusedLinkWidthIncrease: config.focusedLinkWidthIncrease, + transitionProgress: 1, + animateColors: 0, + animateWidths: 0, }, }, drawLineFragmentUniforms: { @@ -214,8 +223,10 @@ export class Lines extends CoreModule { ...this.curveLineBuffer && { position: this.curveLineBuffer }, ...this.pointABuffer && { pointA: this.pointABuffer }, ...this.pointBBuffer && { pointB: this.pointBBuffer }, - ...this.colorBuffer && { color: this.colorBuffer }, - ...this.widthBuffer && { width: this.widthBuffer }, + ...this.sourceColorBuffer && { sourceColor: this.sourceColorBuffer }, + ...this.targetColorBuffer && { targetColor: this.targetColorBuffer }, + ...this.sourceWidthBuffer && { sourceWidth: this.sourceWidthBuffer }, + ...this.targetWidthBuffer && { targetWidth: this.targetWidthBuffer }, ...this.arrowBuffer && { arrow: this.arrowBuffer }, ...this.linkIndexBuffer && { linkIndices: this.linkIndexBuffer }, }, @@ -223,8 +234,10 @@ export class Lines extends CoreModule { { name: 'position', format: 'float32x2' }, { name: 'pointA', format: 'float32x2', stepMode: 'instance' }, { name: 'pointB', format: 'float32x2', stepMode: 'instance' }, - { name: 'color', format: 'float32x4', stepMode: 'instance' }, - { name: 'width', format: 'float32', stepMode: 'instance' }, + { name: 'sourceColor', format: 'float32x4', stepMode: 'instance' }, + { name: 'targetColor', format: 'float32x4', stepMode: 'instance' }, + { name: 'sourceWidth', format: 'float32', stepMode: 'instance' }, + { name: 'targetWidth', format: 'float32', stepMode: 'instance' }, { name: 'arrow', format: 'float32', stepMode: 'instance' }, { name: 'linkIndices', format: 'float32', stepMode: 'instance' }, ], @@ -364,8 +377,8 @@ export class Lines extends CoreModule { if (!points) return if (!points.currentPositionTexture || points.currentPositionTexture.destroyed) return if (!this.pointABuffer || !this.pointBBuffer) this.updatePointsBuffer() - if (!this.colorBuffer) this.updateColor() - if (!this.widthBuffer) this.updateWidth() + if (!this.targetColorBuffer) this.updateColor() + if (!this.targetWidthBuffer) this.updateWidth() if (!this.arrowBuffer) this.updateArrow() if (!this.curveLineGeometry) this.updateCurveLineGeometry() if (!this.drawCurveCommand || !this.drawLineUniformStore || !this.linkStatusTexture) return @@ -398,6 +411,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: this.linkStatusTextureSize, focusedLinkIndex: config.focusedLinkIndex ?? -1, focusedLinkWidthIncrease: config.focusedLinkWidthIncrease, + transitionProgress: this.transitionProgress, + animateColors: this.shouldAnimateLinkColors ? 1 : 0, + animateWidths: this.shouldAnimateLinkWidths ? 1 : 0, }, drawLineFragmentUniforms: { renderMode: 0.0, // Normal rendering @@ -576,65 +592,49 @@ export class Lines extends CoreModule { } public updateColor (): void { - const { device, data } = this + const { data } = this const linksNumber = data.linksNumber ?? 0 const colorData = data.linkColors ?? new Float32Array(linksNumber * 4).fill(0) + const { source, target, previous } = updateAttributeBuffers( + this.device, + colorData, + this.sourceColorBuffer, + this.targetColorBuffer, + this.previousColorData, + 4 + ) + this.sourceColorBuffer = source + this.targetColorBuffer = target + this.previousColorData = previous - if (!this.colorBuffer) { - this.colorBuffer = device.createBuffer({ - data: colorData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - // Check if buffer needs to be resized - const currentSize = (this.colorBuffer.byteLength ?? 0) / (Float32Array.BYTES_PER_ELEMENT * 4) - if (currentSize !== linksNumber) { - if (this.colorBuffer && !this.colorBuffer.destroyed) { - this.colorBuffer.destroy() - } - this.colorBuffer = device.createBuffer({ - data: colorData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - this.colorBuffer.write(colorData) - } - } if (this.drawCurveCommand) { this.drawCurveCommand.setAttributes({ - color: this.colorBuffer, + ...(this.sourceColorBuffer && { sourceColor: this.sourceColorBuffer }), + ...(this.targetColorBuffer && { targetColor: this.targetColorBuffer }), }) } } public updateWidth (): void { - const { device, data } = this + const { data } = this const linksNumber = data.linksNumber ?? 0 const widthData = data.linkWidths ?? new Float32Array(linksNumber).fill(0) + const { source, target, previous } = updateAttributeBuffers( + this.device, + widthData, + this.sourceWidthBuffer, + this.targetWidthBuffer, + this.previousWidthData, + 1 + ) + this.sourceWidthBuffer = source + this.targetWidthBuffer = target + this.previousWidthData = previous - if (!this.widthBuffer) { - this.widthBuffer = device.createBuffer({ - data: widthData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - // Check if buffer needs to be resized - const currentSize = (this.widthBuffer.byteLength ?? 0) / Float32Array.BYTES_PER_ELEMENT - if (currentSize !== linksNumber) { - if (this.widthBuffer && !this.widthBuffer.destroyed) { - this.widthBuffer.destroy() - } - this.widthBuffer = device.createBuffer({ - data: widthData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - this.widthBuffer.write(widthData) - } - } if (this.drawCurveCommand) { this.drawCurveCommand.setAttributes({ - width: this.widthBuffer, + ...(this.sourceWidthBuffer && { sourceWidth: this.sourceWidthBuffer }), + ...(this.targetWidthBuffer && { targetWidth: this.targetWidthBuffer }), }) } } @@ -902,6 +902,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: this.linkStatusTextureSize, focusedLinkIndex: config.focusedLinkIndex ?? -1, focusedLinkWidthIncrease: config.focusedLinkWidthIncrease, + transitionProgress: this.transitionProgress, + animateColors: this.shouldAnimateLinkColors ? 1 : 0, + animateWidths: this.shouldAnimateLinkWidths ? 1 : 0, }, drawLineFragmentUniforms: { renderMode: 1.0, // Index rendering for picking @@ -947,6 +950,12 @@ export class Lines extends CoreModule { } } + public setTransitionProgress (progress: number, animateColors = false, animateWidths = false): void { + this.transitionProgress = progress + this.shouldAnimateLinkColors = animateColors + this.shouldAnimateLinkWidths = animateWidths + } + /** * Destruction order matters * Models -> Framebuffers -> Textures -> UniformStores -> Buffers @@ -1005,14 +1014,24 @@ export class Lines extends CoreModule { this.pointBBuffer.destroy() } this.pointBBuffer = undefined - if (this.colorBuffer && !this.colorBuffer.destroyed) { - this.colorBuffer.destroy() + if (this.sourceColorBuffer && !this.sourceColorBuffer.destroyed) { + this.sourceColorBuffer.destroy() + } + this.sourceColorBuffer = undefined + if (this.targetColorBuffer && !this.targetColorBuffer.destroyed) { + this.targetColorBuffer.destroy() + } + this.targetColorBuffer = undefined + this.previousColorData = undefined + if (this.sourceWidthBuffer && !this.sourceWidthBuffer.destroyed) { + this.sourceWidthBuffer.destroy() } - this.colorBuffer = undefined - if (this.widthBuffer && !this.widthBuffer.destroyed) { - this.widthBuffer.destroy() + this.sourceWidthBuffer = undefined + if (this.targetWidthBuffer && !this.targetWidthBuffer.destroyed) { + this.targetWidthBuffer.destroy() } - this.widthBuffer = undefined + this.targetWidthBuffer = undefined + this.previousWidthData = undefined if (this.arrowBuffer && !this.arrowBuffer.destroyed) { this.arrowBuffer.destroy() } diff --git a/src/modules/Points/draw-points.vert b/src/modules/Points/draw-points.vert index 0743dfa4..f558360b 100644 --- a/src/modules/Points/draw-points.vert +++ b/src/modules/Points/draw-points.vert @@ -4,8 +4,10 @@ precision highp float; #endif in vec2 pointIndices; -in float size; -in vec4 color; +in float sourceSize; +in float targetSize; +in vec4 sourceColor; +in vec4 targetColor; in float shape; in float imageIndex; in float imageSize; @@ -32,6 +34,9 @@ layout(std140) uniform drawVertexUniforms { float hasImages; float imageCount; float imageAtlasCoordsTextureSize; + float transitionProgress; + float animateColors; + float animateSizes; } drawVertex; #define ratio drawVertex.ratio @@ -50,6 +55,9 @@ layout(std140) uniform drawVertexUniforms { #define hasImages drawVertex.hasImages #define imageCount drawVertex.imageCount #define imageAtlasCoordsTextureSize drawVertex.imageAtlasCoordsTextureSize +#define transitionProgress drawVertex.transitionProgress +#define animateColors drawVertex.animateColors +#define animateSizes drawVertex.animateSizes #else uniform float ratio; uniform mat3 transformationMatrix; @@ -67,6 +75,9 @@ uniform float skipGreyed; uniform float hasImages; uniform float imageCount; uniform float imageAtlasCoordsTextureSize; +uniform float transitionProgress; +uniform float animateColors; +uniform float animateSizes; #endif out float pointShape; @@ -131,8 +142,15 @@ void main() { #endif gl_Position = vec4(finalPosition.rg, 0, 1); + float pointSize = animateSizes > 0.0 + ? mix(sourceSize, targetSize, transitionProgress) + : targetSize; + vec4 pointColor = animateColors > 0.0 + ? mix(sourceColor, targetColor, transitionProgress) + : targetColor; + // Calculate sizes for shape and image - float shapeSizeValue = calculatePointSize(size * sizeScale); + float shapeSizeValue = calculatePointSize(pointSize * sizeScale); float imageSizeValue = calculatePointSize(imageSize * sizeScale); // Use the larger of the two sizes for the overall point size @@ -152,7 +170,7 @@ void main() { imageSizeVarying = imageSizeValue; overallSize = overallSizeValue; - shapeColor = color; + shapeColor = pointColor; pointShape = shape; // Adjust color of greyed-out points diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index eac914a3..181be1a2 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -16,7 +16,8 @@ import findHoveredPointVert from '@/graph/modules/Points/find-hovered-point.vert import fillGridWithSampledPointsFrag from '@/graph/modules/Points/fill-sampled-points.frag?raw' import fillGridWithSampledPointsVert from '@/graph/modules/Points/fill-sampled-points.vert?raw' import updatePositionFrag from '@/graph/modules/Points/update-position.frag?raw' -import { createIndexesForBuffer } from '@/graph/modules/Shared/buffer' +import interpolatePositionFrag from '@/graph/modules/Points/interpolate-position.frag?raw' +import { createIndexesForBuffer, updateAttributeBuffers } from '@/graph/modules/Shared/buffer' import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils' import trackPositionsFrag from '@/graph/modules/Points/track-positions.frag?raw' import dragPointFrag from '@/graph/modules/Points/drag-point.frag?raw' @@ -24,10 +25,15 @@ import updateVert from '@/graph/modules/Shared/quad.vert?raw' import { readPixels } from '@/graph/helper' import { ensureVec2, ensureVec4 } from '@/graph/modules/Shared/uniform-utils' import { createAtlasDataFromImageData } from '@/graph/modules/Points/atlas-utils' +import { buildPositionTextureData, buildSourcePositionTextureData } from '@/graph/modules/Points/position-utils' +import { Transition, TransitionProperty } from '@/graph/modules/Transition' export class Points extends CoreModule { + public transition: Transition | undefined public currentPositionFbo: Framebuffer | undefined public previousPositionFbo: Framebuffer | undefined + public sourcePositionFbo: Framebuffer | undefined + public targetPositionFbo: Framebuffer | undefined public velocityFbo: Framebuffer | undefined public searchFbo: Framebuffer | undefined public hoveredFbo: Framebuffer | undefined @@ -36,11 +42,48 @@ export class Points extends CoreModule { public shouldSkipRescale: boolean | undefined public imageAtlasTexture: Texture | undefined public imageCount = 0 - // Add texture properties for position data (public for Clusters module access) + /** + * Where each point is right now. Every reader of point positions — draw, + * hover, tracking, `getPointPositions()`, `getTrackedPointPositionsMap()` — + * reads this texture and trusts it matches what's on screen. + * + * New contents come from one of four places: + * - `interpolatePosition()` — each frame while a transition is running + * - `updatePosition()` — each simulation tick + * - `drag()` — while dragging a point + * - `writePositionTexture()` from `updatePositions()` — direct CPU upload + * when no transition is running, and to fill a newly created texture + * before any shader reads it + * + * To preserve the "matches what's on screen" invariant, we only upload from + * the CPU when no shader is about to write to it. `updatePositions()` makes + * that call. + */ public currentPositionTexture: Texture | undefined + /** + * Holds the previous frame of positions so simulation and drag shaders can + * read it while writing the new frame into `currentPositionTexture` in the + * same render pass (a single texture cannot be both read and written in one + * pass). `swapFbo()` rotates current and previous each frame. + */ public previousPositionTexture: Texture | undefined public velocityTexture: Texture | undefined public pointStatusTexture: Texture | undefined + /** + * Start of a position transition — the "from" positions blended by + * `interpolatePosition()`. Populated by `updatePositions()` when an animated + * `setPointPositions()` arrives, either via a fast GPU copy of + * `currentPositionTexture` (same point count) or a CPU-side remap when the + * count changed. Untouched outside an active transition. + */ + public sourcePositionTexture: Texture | undefined + /** + * End of a position transition — the "to" positions blended by + * `interpolatePosition()`. Populated by `updatePositions()` from the latest + * `setPointPositions()` argument when a transition starts. Untouched outside + * an active transition. + */ + public targetPositionTexture: Texture | undefined /** * Whether the cached cluster centroid positions are still valid. * Set to `false` in `swapFbo()` whenever GPU point positions change (simulation tick or drag). @@ -48,20 +91,40 @@ export class Points extends CoreModule { * Used together with `Clusters.cachedCentroidPositions` to skip redundant GPU readbacks. */ public areClusterCentroidsUpToDate = false - private colorBuffer: Buffer | undefined - private sizeBuffer: Buffer | undefined + private sourceColorBuffer: Buffer | undefined + private targetColorBuffer: Buffer | undefined + private previousColorData: Float32Array | undefined + private sourceSizeBuffer: Buffer | undefined + private targetSizeBuffer: Buffer | undefined + private previousSizeData: Float32Array | undefined private shapeBuffer: Buffer | undefined private imageIndicesBuffer: Buffer | undefined private imageSizesBuffer: Buffer | undefined private imageAtlasCoordsTexture: Texture | undefined private imageAtlasCoordsTextureSize: number | undefined + /** + * Tracking pipeline — point positions read via `Graph.getTrackedPointPositionsMap()`: + * + * currentPositionTexture ──trackPoints()──▶ trackedPositionsFbo ──readPixels──▶ trackedPositions Map + * (source of truth) (GPU draw) (GPU cache) (on demand) (CPU cache) + * + * `trackPoints()` must run after every write to `currentPositionTexture` + * (see its JSDoc). `trackPointsByIndices()` does the one-time setup. + */ private trackedPositionsFbo: Framebuffer | undefined private sampledPointsFbo: Framebuffer | undefined private trackedPositions: Map | undefined + /** + * Guards the CPU-side `trackedPositions` cache in `getTrackedPositionsMap()`. + * Set to `true` after a successful readback when the simulation is inactive; + * must be set to `false` whenever `currentPositionFbo` is written to + * (simulation step, drag, position transition) so the next call re-reads from the GPU. + */ private isPositionsUpToDate = false private drawCommand: Model | undefined private drawHighlightedCommand: Model | undefined private updatePositionCommand: Model | undefined + private interpolatePositionCommand: Model | undefined private dragPointCommand: Model | undefined private findPointsInRectCommand: Model | undefined private findPointsInPolygonCommand: Model | undefined @@ -70,6 +133,7 @@ export class Points extends CoreModule { private trackPointsCommand: Model | undefined // Vertex buffers for quad rendering (Model doesn't destroy them automatically) private updatePositionVertexCoordBuffer: Buffer | undefined + private interpolatePositionVertexCoordBuffer: Buffer | undefined private dragPointVertexCoordBuffer: Buffer | undefined private findPointsInRectVertexCoordBuffer: Buffer | undefined private findPointsInPolygonVertexCoordBuffer: Buffer | undefined @@ -85,6 +149,9 @@ export class Points extends CoreModule { private drawPointIndices: Buffer | undefined private hoveredPointIndices: Buffer | undefined private sampledPointIndices: Buffer | undefined + private transitionProgress = 1 + private shouldAnimatePointColors = false + private shouldAnimatePointSizes = false // Uniform stores for scalar uniforms private updatePositionUniformStore: UniformStore<{ @@ -94,6 +161,12 @@ export class Points extends CoreModule { }; }> | undefined + private interpolatePositionUniformStore: UniformStore<{ + interpolatePositionUniforms: { + progress: number; + }; + }> | undefined + private dragPointUniformStore: UniformStore<{ dragPointUniforms: { mousePos: [number, number]; @@ -119,6 +192,9 @@ export class Points extends CoreModule { hasImages: number; imageCount: number; imageAtlasCoordsTextureSize: number; + transitionProgress: number; + animateColors: number; + animateSizes: number; }; drawFragmentUniforms: { greyoutOpacity: number; @@ -205,28 +281,11 @@ export class Points extends CoreModule { }; }> | undefined - public updatePositions (): void { + public updatePositions (): boolean { const { device, store, data, config: { rescalePositions, enableSimulation } } = this const { pointsTextureSize } = store - if (!pointsTextureSize || !data.pointPositions || data.pointsNumber === undefined) return - - // Create initial state array with exact size needed for RGBA32Float texture - // Ensure it's a new contiguous buffer (not a view) with the exact size - const textureDataSize = pointsTextureSize * pointsTextureSize * 4 - const initialState = new Float32Array(textureDataSize) - - const expectedBytes = pointsTextureSize * pointsTextureSize * 4 * 4 // width * height * 4 components * 4 bytes - const actualBytes = initialState.byteLength - if (actualBytes !== expectedBytes) { - console.error('Texture data size mismatch:', { - pointsTextureSize, - expectedBytes, - actualBytes, - textureDataSize, - dataLength: initialState.length, - }) - } + if (!pointsTextureSize || !data.pointPositions || data.pointsNumber === undefined) return false let shouldRescale = rescalePositions // If rescalePositions isn't specified in config and simulation is disabled, default to true @@ -247,121 +306,75 @@ export class Points extends CoreModule { // Reset temporary flag this.shouldSkipRescale = undefined - for (let i = 0; i < data.pointsNumber; ++i) { - initialState[i * 4 + 0] = data.pointPositions[i * 2 + 0] as number - initialState[i * 4 + 1] = data.pointPositions[i * 2 + 1] as number - initialState[i * 4 + 2] = i - } - - // Create currentPositionTexture and framebuffer - if (!this.currentPositionTexture || this.currentPositionTexture.width !== pointsTextureSize || this.currentPositionTexture.height !== pointsTextureSize) { - if (this.currentPositionTexture && !this.currentPositionTexture.destroyed) { - this.currentPositionTexture.destroy() - } - if (this.currentPositionFbo && !this.currentPositionFbo.destroyed) { - this.currentPositionFbo.destroy() - } - this.currentPositionTexture = device.createTexture({ - width: pointsTextureSize, - height: pointsTextureSize, - format: 'rgba32float', - }) - this.currentPositionTexture.copyImageData({ - data: initialState, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - this.currentPositionFbo = device.createFramebuffer({ - width: pointsTextureSize, - height: pointsTextureSize, - colorAttachments: [this.currentPositionTexture], - }) - } else { - this.currentPositionTexture.copyImageData({ - data: initialState, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - } + const sourceCount = data.sourcePointsNumber + const targetCount = data.targetPointsNumber + const sameCount = sourceCount === targetCount + const shouldAnimate = + this.transition?.isPendingFor(TransitionProperty.Positions) === true && + this.config.transitionDuration > 0 && + !!this.currentPositionTexture + + const targetState = buildPositionTextureData(data.pointPositions, pointsTextureSize, targetCount) + + // Position transition: `interpolatePosition()` blends source → target each frame. + // Target is always the new layout. + // + // How we build source: + // · Same point count — GPU copy of what's on screen + // · Count changed — CPU readback + remap (`animatedSourceData`) + // · No readable prior frame — source = target + let animatedSourceData: Float32Array | undefined + if (shouldAnimate) { + this.createTransitionResources() + if (this.sourcePositionTexture && this.targetPositionTexture) { + if (sameCount) { + const currentPositionTexture = this.currentPositionTexture + if (currentPositionTexture && !currentPositionTexture.destroyed) { + const commandEncoder = this.device.createCommandEncoder() + commandEncoder.copyTextureToTexture({ + sourceTexture: currentPositionTexture, + destinationTexture: this.sourcePositionTexture, + width: pointsTextureSize, + height: pointsTextureSize, + }) + this.device.submit(commandEncoder.finish()) + } + } else if (this.currentPositionFbo) { + const previousPositionPixels = readPixels(device, this.currentPositionFbo as Framebuffer) + animatedSourceData = buildSourcePositionTextureData( + previousPositionPixels, + targetState, + Math.min(sourceCount, targetCount), + targetCount, + pointsTextureSize + ) + this.writePositionTexture(this.sourcePositionTexture, animatedSourceData, pointsTextureSize) + } else { + this.writePositionTexture(this.sourcePositionTexture, targetState, pointsTextureSize) + } - // Create previousPositionTexture and framebuffer - if (!this.previousPositionTexture || - this.previousPositionTexture.width !== pointsTextureSize || - this.previousPositionTexture.height !== pointsTextureSize) { - if (this.previousPositionTexture && !this.previousPositionTexture.destroyed) { - this.previousPositionTexture.destroy() - } - if (this.previousPositionFbo && !this.previousPositionFbo.destroyed) { - this.previousPositionFbo.destroy() + this.writePositionTexture(this.targetPositionTexture, targetState, pointsTextureSize) } - this.previousPositionTexture = device.createTexture({ - width: pointsTextureSize, - height: pointsTextureSize, - format: 'rgba32float', - }) - this.previousPositionTexture.copyImageData({ - data: initialState, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - this.previousPositionFbo = device.createFramebuffer({ - width: pointsTextureSize, - height: pointsTextureSize, - colorAttachments: [this.previousPositionTexture], - }) - } else { - this.previousPositionTexture.copyImageData({ - data: initialState, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) } - if (this.config.enableSimulation) { - // Create velocityTexture and framebuffer - const velocityData = new Float32Array(pointsTextureSize * pointsTextureSize * 4).fill(0) - if (!this.velocityTexture || this.velocityTexture.width !== pointsTextureSize || this.velocityTexture.height !== pointsTextureSize) { - if (this.velocityTexture && !this.velocityTexture.destroyed) { - this.velocityTexture.destroy() - } - if (this.velocityFbo && !this.velocityFbo.destroyed) { - this.velocityFbo.destroy() - } - this.velocityTexture = device.createTexture({ - width: pointsTextureSize, - height: pointsTextureSize, - format: 'rgba32float', - }) - this.velocityTexture.copyImageData({ - data: velocityData, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - this.velocityFbo = device.createFramebuffer({ - width: pointsTextureSize, - height: pointsTextureSize, - colorAttachments: [this.velocityTexture], - }) - } else { - this.velocityTexture.copyImageData({ - data: velocityData, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - } + // current/previous are what draw and tracking read from. + // + // How we fill them: + // · Snap — upload final layout to both + // · Animate + count changed — upload remapped source to both (recreated textures must + // not be empty or show the target before the first interpolate frame) + // · Animate + same count — skip upload; buffers still show the last frame + this.ensurePositionTextures(pointsTextureSize) + if (!shouldAnimate) { + this.writePositionTexture(this.currentPositionTexture!, targetState, pointsTextureSize) + this.writePositionTexture(this.previousPositionTexture!, targetState, pointsTextureSize) + } else if (animatedSourceData) { + this.writePositionTexture(this.currentPositionTexture!, animatedSourceData, pointsTextureSize) + this.writePositionTexture(this.previousPositionTexture!, animatedSourceData, pointsTextureSize) } + this.areClusterCentroidsUpToDate = false + this.isPositionsUpToDate = false + if (this.config.enableSimulation) this.ensureSimulationResources() // Create searchTexture and framebuffer if (!this.searchTexture || this.searchTexture.width !== pointsTextureSize || this.searchTexture.height !== pointsTextureSize) { @@ -377,7 +390,7 @@ export class Points extends CoreModule { format: 'rgba32float', }) this.searchTexture.copyImageData({ - data: initialState, + data: targetState, bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), mipLevel: 0, x: 0, @@ -390,7 +403,7 @@ export class Points extends CoreModule { }) } else { this.searchTexture.copyImageData({ - data: initialState, + data: targetState, bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), mipLevel: 0, x: 0, @@ -460,7 +473,10 @@ export class Points extends CoreModule { this.updatePinnedStatus() this.updateSampledPointsGrid() - this.trackPointsByIndices() + // Animated path: render loop refreshes after each `interpolatePosition()`. + // No-animate path: no loop will run — seed once here. + if (!shouldAnimate) this.trackPoints() + return shouldAnimate } public initPrograms (): void { @@ -470,55 +486,13 @@ export class Points extends CoreModule { this.createAtlas() } // Ensure buffers exist before Model creation (Model needs attributes at creation time) - if (!this.colorBuffer) this.updateColor() - if (!this.sizeBuffer) this.updateSize() + if (!this.targetColorBuffer) this.updateColor() + if (!this.targetSizeBuffer) this.updateSize() if (!this.shapeBuffer) this.updateShape() if (!this.imageIndicesBuffer) this.updateImageIndices() if (!this.imageSizesBuffer) this.updateImageSizes() if (!this.pointStatusTexture) this.updatePointStatus() - if (config.enableSimulation) { - // Create vertex buffer for quad - this.updatePositionVertexCoordBuffer ||= device.createBuffer({ - data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), - }) - - // Create UniformStore for updatePosition uniforms - this.updatePositionUniformStore ||= new UniformStore({ - updatePositionUniforms: { - uniformTypes: { - // Order MUST match shader declaration order (std140 layout) - friction: 'f32', - spaceSize: 'f32', - }, - defaultUniforms: { - friction: config.simulationFriction, - spaceSize: store.adjustedSpaceSize, - }, - }, - }) - - this.updatePositionCommand ||= new Model(device, { - fs: updatePositionFrag, - vs: updateVert, - topology: 'triangle-strip', - vertexCount: 4, - attributes: { - vertexCoord: this.updatePositionVertexCoordBuffer, - }, - bufferLayout: [ - { name: 'vertexCoord', format: 'float32x2' }, - ], - defines: { - USE_UNIFORM_BUFFERS: true, - }, - bindings: { - // Create uniform buffer binding - // Update it later by calling uniformStore.setUniforms() - updatePositionUniforms: this.updatePositionUniformStore.getManagedUniformBuffer(device, 'updatePositionUniforms'), - // All texture bindings will be set dynamically in updatePosition() method - }, - }) - } + if (config.enableSimulation) this.ensureUpdatePositionProgram() // Create vertex buffer for quad this.dragPointVertexCoordBuffer ||= device.createBuffer({ @@ -583,6 +557,9 @@ export class Points extends CoreModule { hasImages: 'f32', imageCount: 'f32', imageAtlasCoordsTextureSize: 'f32', + transitionProgress: 'f32', + animateColors: 'f32', + animateSizes: 'f32', }, defaultUniforms: { // Order MUST match uniformTypes and shader declaration @@ -610,6 +587,9 @@ export class Points extends CoreModule { hasImages: (this.imageCount > 0) ? 1 : 0, // Convert boolean to float imageCount: this.imageCount, imageAtlasCoordsTextureSize: this.imageAtlasCoordsTextureSize ?? 0, + transitionProgress: 1, + animateColors: 0, + animateSizes: 0, }, }, drawFragmentUniforms: { @@ -640,16 +620,20 @@ export class Points extends CoreModule { vertexCount: data.pointsNumber ?? 0, attributes: { ...(this.drawPointIndices && { pointIndices: this.drawPointIndices }), - ...(this.sizeBuffer && { size: this.sizeBuffer }), - ...(this.colorBuffer && { color: this.colorBuffer }), + ...(this.sourceSizeBuffer && { sourceSize: this.sourceSizeBuffer }), + ...(this.targetSizeBuffer && { targetSize: this.targetSizeBuffer }), + ...(this.sourceColorBuffer && { sourceColor: this.sourceColorBuffer }), + ...(this.targetColorBuffer && { targetColor: this.targetColorBuffer }), ...(this.shapeBuffer && { shape: this.shapeBuffer }), ...(this.imageIndicesBuffer && { imageIndex: this.imageIndicesBuffer }), ...(this.imageSizesBuffer && { imageSize: this.imageSizesBuffer }), }, bufferLayout: [ { name: 'pointIndices', format: 'float32x2' }, - { name: 'size', format: 'float32' }, - { name: 'color', format: 'float32x4' }, + { name: 'sourceSize', format: 'float32' }, + { name: 'targetSize', format: 'float32' }, + { name: 'sourceColor', format: 'float32x4' }, + { name: 'targetColor', format: 'float32x4' }, { name: 'shape', format: 'float32' }, { name: 'imageIndex', format: 'float32' }, { name: 'imageSize', format: 'float32' }, @@ -820,7 +804,7 @@ export class Points extends CoreModule { vertexCount: data.pointsNumber ?? 0, attributes: { ...(this.hoveredPointIndices && { pointIndices: this.hoveredPointIndices }), - ...(this.sizeBuffer && { size: this.sizeBuffer }), + ...(this.targetSizeBuffer && { size: this.targetSizeBuffer }), ...(this.imageSizesBuffer && { imageSize: this.imageSizesBuffer }), }, bufferLayout: [ @@ -1013,26 +997,27 @@ export class Points extends CoreModule { } public updateColor (): void { - const { device, store: { pointsTextureSize }, data } = this + const { store: { pointsTextureSize }, data } = this if (!pointsTextureSize) return + // GraphData.updatePointColor() always populates pointColors before this runs const colorData = data.pointColors as Float32Array - const requiredByteLength = colorData.byteLength + const { source, target, previous } = updateAttributeBuffers( + this.device, + colorData, + this.sourceColorBuffer, + this.targetColorBuffer, + this.previousColorData, + 4 + ) + this.sourceColorBuffer = source + this.targetColorBuffer = target + this.previousColorData = previous - if (!this.colorBuffer || this.colorBuffer.byteLength !== requiredByteLength) { - if (this.colorBuffer && !this.colorBuffer.destroyed) { - this.colorBuffer.destroy() - } - this.colorBuffer = device.createBuffer({ - data: colorData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - this.colorBuffer.write(colorData) - } if (this.drawCommand) { this.drawCommand.setAttributes({ - color: this.colorBuffer, + ...(this.sourceColorBuffer && { sourceColor: this.sourceColorBuffer }), + ...(this.targetColorBuffer && { targetColor: this.targetColorBuffer }), }) } } @@ -1133,31 +1118,32 @@ export class Points extends CoreModule { public updateSize (): void { const { device, store: { pointsTextureSize }, data } = this - if (!pointsTextureSize || data.pointsNumber === undefined || data.pointSizes === undefined) return + if (!pointsTextureSize || data.pointsNumber === undefined) return - const sizeData = data.pointSizes - const requiredByteLength = sizeData.byteLength + // GraphData.updatePointSize() always populates pointSizes before this runs + const sizeData = data.pointSizes as Float32Array + const { source, target, previous } = updateAttributeBuffers( + this.device, + sizeData, + this.sourceSizeBuffer, + this.targetSizeBuffer, + this.previousSizeData, + 1 + ) + this.sourceSizeBuffer = source + this.targetSizeBuffer = target + this.previousSizeData = previous - if (!this.sizeBuffer || this.sizeBuffer.byteLength !== requiredByteLength) { - if (this.sizeBuffer && !this.sizeBuffer.destroyed) { - this.sizeBuffer.destroy() - } - this.sizeBuffer = device.createBuffer({ - data: sizeData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }) - } else { - this.sizeBuffer.write(sizeData) - } if (this.drawCommand) { this.drawCommand.setAttributes({ - size: this.sizeBuffer, + ...(this.sourceSizeBuffer && { sourceSize: this.sourceSizeBuffer }), + ...(this.targetSizeBuffer && { targetSize: this.targetSizeBuffer }), }) } const initialState = new Float32Array(pointsTextureSize * pointsTextureSize * 4) for (let i = 0; i < data.pointsNumber; i++) { - const shapeSize = data.pointSizes[i] as number + const shapeSize = sizeData[i] as number const imageSize = data.pointImageSizes?.[i] ?? shapeSize initialState[i * 4] = Math.max(shapeSize, imageSize) } @@ -1166,12 +1152,13 @@ export class Points extends CoreModule { if (this.sizeTexture && !this.sizeTexture.destroyed) { this.sizeTexture.destroy() } - this.sizeTexture = device.createTexture({ + const sizeTexture = device.createTexture({ width: pointsTextureSize, height: pointsTextureSize, format: 'rgba32float', }) - this.sizeTexture.copyImageData({ + this.sizeTexture = sizeTexture + sizeTexture.copyImageData({ data: initialState, bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), mipLevel: 0, @@ -1360,6 +1347,17 @@ export class Points extends CoreModule { } } + /** + * Refresh `trackedPositionsFbo` from `currentPositionTexture` (one GPU draw, + * no CPU sync). Must run after every write to `currentPositionTexture`: + * - `updatePosition()` — simulation tick + * - `drag()` — pointer drag + * - `interpolatePosition()` — each frame of a position transition + * - `writePositionTexture()` — CPU upload from `updatePositions` (`setPointPositions`, + * non-animated path; or animated path when the texture had to be recreated) + * + * `trackPointsByIndices()` self-calls after reallocating; no manual follow-up needed. + */ public trackPoints (): void { if (!this.trackedIndices?.length || !this.trackPointsCommand || !this.trackPointsUniformStore || !this.trackedPositionsFbo || this.trackedPositionsFbo.destroyed) return @@ -1385,10 +1383,16 @@ export class Points extends CoreModule { renderPass.end() } + public setTransitionProgress (progress: number, animateColors = false, animateSizes = false): void { + this.transitionProgress = progress + this.shouldAnimatePointColors = animateColors + this.shouldAnimatePointSizes = animateSizes + } + public draw (renderPass: RenderPass): void { const { data, config, store } = this - if (!this.colorBuffer) this.updateColor() - if (!this.sizeBuffer) this.updateSize() + if (!this.targetColorBuffer) this.updateColor() + if (!this.targetSizeBuffer) this.updateSize() if (!this.shapeBuffer) this.updateShape() if (!this.imageIndicesBuffer) this.updateImageIndices() if (!this.imageSizesBuffer) this.updateImageSizes() @@ -1432,6 +1436,9 @@ export class Points extends CoreModule { hasImages: (this.imageCount > 0) ? 1 : 0, // Convert boolean to float imageCount: this.imageCount, imageAtlasCoordsTextureSize: this.imageAtlasCoordsTextureSize ?? 0, + transitionProgress: this.transitionProgress, + animateColors: this.shouldAnimatePointColors ? 1 : 0, + animateSizes: this.shouldAnimatePointSizes ? 1 : 0, } const baseFragmentUniforms = { @@ -1763,7 +1770,7 @@ export class Points extends CoreModule { this.findHoveredPointCommand.setAttributes({ ...(this.hoveredPointIndices && { pointIndices: this.hoveredPointIndices }), - ...(this.sizeBuffer && { size: this.sizeBuffer }), + ...(this.targetSizeBuffer && { size: this.targetSizeBuffer }), ...(this.imageSizesBuffer && { imageSize: this.imageSizesBuffer }), }) @@ -2045,15 +2052,17 @@ export class Points extends CoreModule { } /** - * Destruction order matters - * Models -> Framebuffers -> Textures -> UniformStores -> Buffers - * */ + * Destroy luma.gl resources in ownership order: + * Models -> Framebuffers -> Textures -> UniformStores -> Buffers. + */ public destroy (): void { // 1. Destroy Models FIRST (they destroy _gpuGeometry if exists, and _uniformStore) this.drawCommand?.destroy() this.drawCommand = undefined this.drawHighlightedCommand?.destroy() this.drawHighlightedCommand = undefined + this.interpolatePositionCommand?.destroy() + this.interpolatePositionCommand = undefined this.updatePositionCommand?.destroy() this.updatePositionCommand = undefined this.dragPointCommand?.destroy() @@ -2078,6 +2087,14 @@ export class Points extends CoreModule { this.previousPositionFbo.destroy() } this.previousPositionFbo = undefined + if (this.sourcePositionFbo && !this.sourcePositionFbo.destroyed) { + this.sourcePositionFbo.destroy() + } + this.sourcePositionFbo = undefined + if (this.targetPositionFbo && !this.targetPositionFbo.destroyed) { + this.targetPositionFbo.destroy() + } + this.targetPositionFbo = undefined if (this.velocityFbo && !this.velocityFbo.destroyed) { this.velocityFbo.destroy() } @@ -2108,6 +2125,14 @@ export class Points extends CoreModule { this.previousPositionTexture.destroy() } this.previousPositionTexture = undefined + if (this.sourcePositionTexture && !this.sourcePositionTexture.destroyed) { + this.sourcePositionTexture.destroy() + } + this.sourcePositionTexture = undefined + if (this.targetPositionTexture && !this.targetPositionTexture.destroyed) { + this.targetPositionTexture.destroy() + } + this.targetPositionTexture = undefined if (this.velocityTexture && !this.velocityTexture.destroyed) { this.velocityTexture.destroy() } @@ -2146,6 +2171,8 @@ export class Points extends CoreModule { this.pinnedStatusTexture = undefined // 4. Destroy UniformStores (Models already destroyed their managed uniform buffers) + this.interpolatePositionUniformStore?.destroy() + this.interpolatePositionUniformStore = undefined this.updatePositionUniformStore?.destroy() this.updatePositionUniformStore = undefined this.dragPointUniformStore?.destroy() @@ -2166,14 +2193,24 @@ export class Points extends CoreModule { this.trackPointsUniformStore = undefined // 5. Destroy Buffers (passed via attributes - NOT owned by Models, must destroy manually) - if (this.colorBuffer && !this.colorBuffer.destroyed) { - this.colorBuffer.destroy() + if (this.sourceColorBuffer && !this.sourceColorBuffer.destroyed) { + this.sourceColorBuffer.destroy() + } + this.sourceColorBuffer = undefined + if (this.targetColorBuffer && !this.targetColorBuffer.destroyed) { + this.targetColorBuffer.destroy() + } + this.targetColorBuffer = undefined + this.previousColorData = undefined + if (this.sourceSizeBuffer && !this.sourceSizeBuffer.destroyed) { + this.sourceSizeBuffer.destroy() } - this.colorBuffer = undefined - if (this.sizeBuffer && !this.sizeBuffer.destroyed) { - this.sizeBuffer.destroy() + this.sourceSizeBuffer = undefined + if (this.targetSizeBuffer && !this.targetSizeBuffer.destroyed) { + this.targetSizeBuffer.destroy() } - this.sizeBuffer = undefined + this.targetSizeBuffer = undefined + this.previousSizeData = undefined if (this.shapeBuffer && !this.shapeBuffer.destroyed) { this.shapeBuffer.destroy() } @@ -2202,6 +2239,10 @@ export class Points extends CoreModule { this.updatePositionVertexCoordBuffer.destroy() } this.updatePositionVertexCoordBuffer = undefined + if (this.interpolatePositionVertexCoordBuffer && !this.interpolatePositionVertexCoordBuffer.destroyed) { + this.interpolatePositionVertexCoordBuffer.destroy() + } + this.interpolatePositionVertexCoordBuffer = undefined if (this.dragPointVertexCoordBuffer && !this.dragPointVertexCoordBuffer.destroyed) { this.dragPointVertexCoordBuffer.destroy() } @@ -2224,6 +2265,215 @@ export class Points extends CoreModule { this.trackPointsVertexCoordBuffer = undefined } + public ensureSimulationResources (): void { + const { store: { pointsTextureSize }, device } = this + if (!pointsTextureSize) return + this.ensureUpdatePositionProgram() + + const velocityData = new Float32Array(pointsTextureSize * pointsTextureSize * 4).fill(0) + if (!this.velocityTexture || this.velocityTexture.width !== pointsTextureSize || this.velocityTexture.height !== pointsTextureSize) { + if (this.velocityFbo && !this.velocityFbo.destroyed) { + this.velocityFbo.destroy() + } + if (this.velocityTexture && !this.velocityTexture.destroyed) { + this.velocityTexture.destroy() + } + this.velocityTexture = device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + }) + this.velocityTexture.copyImageData({ + data: velocityData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + this.velocityFbo = device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.velocityTexture], + }) + } else { + this.velocityTexture.copyImageData({ + data: velocityData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + } + } + + public createTransitionResources (): void { + const { store: { pointsTextureSize }, device } = this + if (!pointsTextureSize) return + + const emptyData = new Float32Array(pointsTextureSize * pointsTextureSize * 4).fill(0) + const textureUsage = Texture.SAMPLE | Texture.RENDER | Texture.COPY_SRC | Texture.COPY_DST + + if (!this.sourcePositionTexture || this.sourcePositionTexture.width !== pointsTextureSize || this.sourcePositionTexture.height !== pointsTextureSize) { + if (this.sourcePositionFbo && !this.sourcePositionFbo.destroyed) { + this.sourcePositionFbo.destroy() + } + if (this.sourcePositionTexture && !this.sourcePositionTexture.destroyed) { + this.sourcePositionTexture.destroy() + } + this.sourcePositionTexture = device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + usage: textureUsage, + }) + this.sourcePositionFbo = device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.sourcePositionTexture], + }) + } + this.sourcePositionTexture.copyImageData({ + data: emptyData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + + if (!this.targetPositionTexture || this.targetPositionTexture.width !== pointsTextureSize || this.targetPositionTexture.height !== pointsTextureSize) { + if (this.targetPositionFbo && !this.targetPositionFbo.destroyed) { + this.targetPositionFbo.destroy() + } + if (this.targetPositionTexture && !this.targetPositionTexture.destroyed) { + this.targetPositionTexture.destroy() + } + this.targetPositionTexture = device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + usage: textureUsage, + }) + this.targetPositionFbo = device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.targetPositionTexture], + }) + } + this.targetPositionTexture.copyImageData({ + data: emptyData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + + this.interpolatePositionVertexCoordBuffer ||= device.createBuffer({ + data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + }) + + this.interpolatePositionUniformStore ||= new UniformStore({ + interpolatePositionUniforms: { + uniformTypes: { + progress: 'f32', + }, + defaultUniforms: { + progress: 0, + }, + }, + }) + + this.interpolatePositionCommand ||= new Model(device, { + fs: interpolatePositionFrag, + vs: updateVert, + topology: 'triangle-strip', + vertexCount: 4, + attributes: { + vertexCoord: this.interpolatePositionVertexCoordBuffer, + }, + bufferLayout: [ + { name: 'vertexCoord', format: 'float32x2' }, + ], + defines: { + USE_UNIFORM_BUFFERS: true, + }, + bindings: { + interpolatePositionUniforms: this.interpolatePositionUniformStore.getManagedUniformBuffer(device, 'interpolatePositionUniforms'), + }, + }) + } + + public destroyTransitionResources (): void { + this.interpolatePositionCommand?.destroy() + this.interpolatePositionCommand = undefined + this.interpolatePositionUniformStore?.destroy() + this.interpolatePositionUniformStore = undefined + if (this.interpolatePositionVertexCoordBuffer && !this.interpolatePositionVertexCoordBuffer.destroyed) { + this.interpolatePositionVertexCoordBuffer.destroy() + } + this.interpolatePositionVertexCoordBuffer = undefined + if (this.sourcePositionFbo && !this.sourcePositionFbo.destroyed) { + this.sourcePositionFbo.destroy() + } + this.sourcePositionFbo = undefined + if (this.sourcePositionTexture && !this.sourcePositionTexture.destroyed) { + this.sourcePositionTexture.destroy() + } + this.sourcePositionTexture = undefined + if (this.targetPositionFbo && !this.targetPositionFbo.destroyed) { + this.targetPositionFbo.destroy() + } + this.targetPositionFbo = undefined + if (this.targetPositionTexture && !this.targetPositionTexture.destroyed) { + this.targetPositionTexture.destroy() + } + this.targetPositionTexture = undefined + } + + public interpolatePosition (progress: number): void { + if (!this.interpolatePositionCommand || !this.interpolatePositionUniformStore) return + if (!this.sourcePositionTexture || this.sourcePositionTexture.destroyed) return + if (!this.targetPositionTexture || this.targetPositionTexture.destroyed) return + if (!this.currentPositionFbo || this.currentPositionFbo.destroyed) return + + this.interpolatePositionUniformStore.setUniforms({ + interpolatePositionUniforms: { + progress, + }, + }) + this.interpolatePositionCommand.setBindings({ + sourceTexture: this.sourcePositionTexture, + targetTexture: this.targetPositionTexture, + }) + + const renderPass = this.device.beginRenderPass({ + framebuffer: this.currentPositionFbo, + }) + this.interpolatePositionCommand.draw(renderPass) + renderPass.end() + + this.isPositionsUpToDate = false + this.areClusterCentroidsUpToDate = false + } + + public destroySimulationResources (): void { + this.updatePositionCommand?.destroy() + this.updatePositionCommand = undefined + this.updatePositionUniformStore?.destroy() + this.updatePositionUniformStore = undefined + if (this.updatePositionVertexCoordBuffer && !this.updatePositionVertexCoordBuffer.destroyed) { + this.updatePositionVertexCoordBuffer.destroy() + } + this.updatePositionVertexCoordBuffer = undefined + if (this.velocityFbo && !this.velocityFbo.destroyed) { + this.velocityFbo.destroy() + } + this.velocityFbo = undefined + if (this.velocityTexture && !this.velocityTexture.destroyed) { + this.velocityTexture.destroy() + } + this.velocityTexture = undefined + } + public swapFbo (): void { // Swap textures and framebuffers // Safety check: ensure resources exist and aren't destroyed before swapping @@ -2242,6 +2492,106 @@ export class Points extends CoreModule { this.areClusterCentroidsUpToDate = false } + /** + * Makes sure the GPU has current and previous position textures at the right size. + * This method only allocates; `updatePositions()` is responsible for putting data in them. + */ + private ensurePositionTextures (pointsTextureSize: number): void { + if (!this.currentPositionTexture || this.currentPositionTexture.width !== pointsTextureSize || this.currentPositionTexture.height !== pointsTextureSize) { + if (this.currentPositionTexture && !this.currentPositionTexture.destroyed) { + this.currentPositionTexture.destroy() + } + if (this.currentPositionFbo && !this.currentPositionFbo.destroyed) { + this.currentPositionFbo.destroy() + } + this.currentPositionTexture = this.device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + }) + this.currentPositionFbo = this.device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.currentPositionTexture], + }) + } + + if (!this.previousPositionTexture || + this.previousPositionTexture.width !== pointsTextureSize || + this.previousPositionTexture.height !== pointsTextureSize) { + if (this.previousPositionTexture && !this.previousPositionTexture.destroyed) { + this.previousPositionTexture.destroy() + } + if (this.previousPositionFbo && !this.previousPositionFbo.destroyed) { + this.previousPositionFbo.destroy() + } + this.previousPositionTexture = this.device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + }) + this.previousPositionFbo = this.device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.previousPositionTexture], + }) + } + } + + /** CPU→GPU upload of position data into an existing RGBA32F texture. */ + private writePositionTexture (tex: Texture, data: Float32Array, pointsTextureSize: number): void { + tex.copyImageData({ + data, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + } + + private ensureUpdatePositionProgram (): void { + const { device, config, store } = this + this.updatePositionVertexCoordBuffer ||= device.createBuffer({ + data: new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), + }) + + this.updatePositionUniformStore ||= new UniformStore({ + updatePositionUniforms: { + uniformTypes: { + // Order MUST match shader declaration order (std140 layout) + friction: 'f32', + spaceSize: 'f32', + }, + defaultUniforms: { + friction: config.simulationFriction, + spaceSize: store.adjustedSpaceSize, + }, + }, + }) + + this.updatePositionCommand ||= new Model(device, { + fs: updatePositionFrag, + vs: updateVert, + topology: 'triangle-strip', + vertexCount: 4, + attributes: { + vertexCoord: this.updatePositionVertexCoordBuffer, + }, + bufferLayout: [ + { name: 'vertexCoord', format: 'float32x2' }, + ], + defines: { + USE_UNIFORM_BUFFERS: true, + }, + bindings: { + // Create uniform buffer binding + // Update it later by calling uniformStore.setUniforms() + updatePositionUniforms: this.updatePositionUniformStore.getManagedUniformBuffer(device, 'updatePositionUniforms'), + // All texture bindings will be set dynamically in updatePosition() method + }, + }) + } + private rescaleInitialNodePositions (): void { const { config: { spaceSize } } = this if (!this.data.pointPositions || !spaceSize) return diff --git a/src/modules/Points/interpolate-position.frag b/src/modules/Points/interpolate-position.frag new file mode 100644 index 00000000..c6f00f60 --- /dev/null +++ b/src/modules/Points/interpolate-position.frag @@ -0,0 +1,28 @@ +#version 300 es +#ifdef GL_ES +precision highp float; +#endif + +uniform sampler2D sourceTexture; +uniform sampler2D targetTexture; + +#ifdef USE_UNIFORM_BUFFERS +layout(std140) uniform interpolatePositionUniforms { + float progress; +} interpolatePosition; + +#define progress interpolatePosition.progress +#else +uniform float progress; +#endif + +in vec2 textureCoords; + +out vec4 fragColor; + +void main() { + vec4 source = texture(sourceTexture, textureCoords); + vec4 target = texture(targetTexture, textureCoords); + vec2 position = mix(source.rg, target.rg, progress); + fragColor = vec4(position, source.b, 1.0); +} diff --git a/src/modules/Points/position-utils.ts b/src/modules/Points/position-utils.ts new file mode 100644 index 00000000..800b5a6e --- /dev/null +++ b/src/modules/Points/position-utils.ts @@ -0,0 +1,57 @@ +/** + * Build RGBA32F texture data from a flat `[x, y, x, y, ...]` point positions array. + * + * Layout per pixel: `[x, y, index, 0]`. The blue channel encodes the point index — + * `drag-point.frag` reads it to match the drag target. Alpha is unused by shaders. + */ +export function buildPositionTextureData ( + pointPositions: Float32Array | undefined, + pointsTextureSize: number, + pointsNumber: number +): Float32Array { + const positionData = new Float32Array(pointsTextureSize * pointsTextureSize * 4) + if (!pointPositions) return positionData + + for (let i = 0; i < pointsNumber; ++i) { + positionData[i * 4 + 0] = pointPositions[i * 2 + 0] as number + positionData[i * 4 + 1] = pointPositions[i * 2 + 1] as number + positionData[i * 4 + 2] = i + } + + return positionData +} + +/** + * Build the `sourcePosition` texture data for a transition when the point count changed. + * + * Shared indices (`0..sharedCount`) carry over their on-screen positions from + * `previousPositionPixels` (readback of the pre-transition `currentPositionFbo`), so the + * animation starts from where each point was last rendered. New indices (`sharedCount..targetCount`) + * start at their target position so they don't drift in from the origin. + * + * Precondition: `sharedCount * 4 <= previousPositionPixels.length` — the caller guarantees + * this by passing `min(previousPointsCount, targetCount)`. + */ +export function buildSourcePositionTextureData ( + previousPositionPixels: Float32Array, + targetData: Float32Array, + sharedCount: number, + targetCount: number, + newTextureSize: number +): Float32Array { + const sourceData = new Float32Array(newTextureSize * newTextureSize * 4) + + for (let i = 0; i < sharedCount; i += 1) { + sourceData[i * 4 + 0] = previousPositionPixels[i * 4 + 0] as number + sourceData[i * 4 + 1] = previousPositionPixels[i * 4 + 1] as number + sourceData[i * 4 + 2] = i + } + + for (let i = sharedCount; i < targetCount; i += 1) { + sourceData[i * 4 + 0] = targetData[i * 4 + 0] as number + sourceData[i * 4 + 1] = targetData[i * 4 + 1] as number + sourceData[i * 4 + 2] = i + } + + return sourceData +} diff --git a/src/modules/Shared/buffer.ts b/src/modules/Shared/buffer.ts index 6ad98616..d16e0c52 100644 --- a/src/modules/Shared/buffer.ts +++ b/src/modules/Shared/buffer.ts @@ -1,3 +1,5 @@ +import { Buffer, Device } from '@luma.gl/core' + export function createIndexesForBuffer (textureSize: number): Float32Array { const indexes = new Float32Array(textureSize * textureSize * 2) for (let y = 0; y < textureSize; y++) { @@ -9,3 +11,59 @@ export function createIndexesForBuffer (textureSize: number): Float32Array { } return indexes } + +export function updateAttributeBuffers ( + device: Device, + targetData: Float32Array, + sourceBuffer: Buffer | undefined, + targetBuffer: Buffer | undefined, + previousData: Float32Array | undefined, + tupleSize: 1 | 4 +): { source: Buffer; target: Buffer; previous: Float32Array } { + const oldCount = previousData ? previousData.length / tupleSize : 0 + const newCount = targetData.length / tupleSize + const sameCount = oldCount === newCount + + // Reuse both buffers when the topology is unchanged so the old target becomes the next source. + // TODO: Rare edge case - smooth in-flight attribute transitions when updates arrive mid-animation. + if (sameCount && + sourceBuffer && !sourceBuffer.destroyed && + targetBuffer && !targetBuffer.destroyed) { + const nextSource = targetBuffer + const nextTarget = sourceBuffer + nextTarget.write(targetData) + return { + source: nextSource, + target: nextTarget, + previous: new Float32Array(targetData), + } + } + + const sourceData = new Float32Array(targetData.length) + const sharedCount = Math.min(oldCount, newCount) + for (let i = 0; i < sharedCount * tupleSize; i += 1) { + sourceData[i] = previousData?.[i] ?? targetData[i] ?? 0 + } + for (let i = sharedCount * tupleSize; i < targetData.length; i += 1) { + sourceData[i] = targetData[i] ?? 0 + } + + if (sourceBuffer && !sourceBuffer.destroyed) { + sourceBuffer.destroy() + } + if (targetBuffer && !targetBuffer.destroyed) { + targetBuffer.destroy() + } + + return { + source: device.createBuffer({ + data: sourceData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + target: device.createBuffer({ + data: targetData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + previous: new Float32Array(targetData), + } +} diff --git a/src/modules/Transition/index.ts b/src/modules/Transition/index.ts new file mode 100644 index 00000000..b561c393 --- /dev/null +++ b/src/modules/Transition/index.ts @@ -0,0 +1,197 @@ +import { + easeLinear, + easeQuadIn, easeQuadOut, easeQuadInOut, + easeCubicIn, easeCubicOut, easeCubicInOut, + easeSinIn, easeSinOut, easeSinInOut, + easeExpIn, easeExpOut, easeExpInOut, + easeCircleIn, easeCircleOut, easeCircleInOut, +} from 'd3-ease' + +import { type GraphConfigInterface } from '@/graph/config' + +export enum TransitionProperty { + Positions = 'positions', + PointColors = 'pointColors', + PointSizes = 'pointSizes', + LinkColors = 'linkColors', + LinkWidths = 'linkWidths', +} + +export enum TransitionEasing { + Linear = 'linear', + QuadIn = 'quad-in', + QuadOut = 'quad-out', + QuadInOut = 'quad-in-out', + CubicIn = 'cubic-in', + CubicOut = 'cubic-out', + CubicInOut = 'cubic-in-out', + SinIn = 'sin-in', + SinOut = 'sin-out', + SinInOut = 'sin-in-out', + ExpIn = 'exp-in', + ExpOut = 'exp-out', + ExpInOut = 'exp-in-out', + CircleIn = 'circle-in', + CircleOut = 'circle-out', + CircleInOut = 'circle-in-out', +} + +const easingFunctions: Record number> = { + [TransitionEasing.Linear]: easeLinear, + [TransitionEasing.QuadIn]: easeQuadIn, + [TransitionEasing.QuadOut]: easeQuadOut, + [TransitionEasing.QuadInOut]: easeQuadInOut, + [TransitionEasing.CubicIn]: easeCubicIn, + [TransitionEasing.CubicOut]: easeCubicOut, + [TransitionEasing.CubicInOut]: easeCubicInOut, + [TransitionEasing.SinIn]: easeSinIn, + [TransitionEasing.SinOut]: easeSinOut, + [TransitionEasing.SinInOut]: easeSinInOut, + [TransitionEasing.ExpIn]: easeExpIn, + [TransitionEasing.ExpOut]: easeExpOut, + [TransitionEasing.ExpInOut]: easeExpInOut, + [TransitionEasing.CircleIn]: easeCircleIn, + [TransitionEasing.CircleOut]: easeCircleOut, + [TransitionEasing.CircleInOut]: easeCircleInOut, +} + +export class Transition { + /** Last eased progress value in the `[0, 1]` range. */ + public progress = 1 + + private readonly config: GraphConfigInterface + private startTime = 0 + /** Properties queued via `queue()`, awaiting `start()` to consume them. */ + private pendingProperties = new Set() + /** Properties currently animating in the running cycle. */ + private activeProperties = new Set() + + public constructor (config: GraphConfigInterface) { + this.config = config + } + + /** True while one or more properties are queued via `queue()` awaiting `start()`. */ + public get isPending (): boolean { + return this.pendingProperties.size > 0 + } + + /** True between `start()` and the end of the cycle. */ + public get isActive (): boolean { + return this.activeProperties.size > 0 + } + + /** Reports whether a specific property is queued and awaiting `start()`. */ + public isPendingFor (property: TransitionProperty): boolean { + return this.pendingProperties.has(property) + } + + /** Reports whether a specific property is part of the active cycle. */ + public isActiveFor (property: TransitionProperty): boolean { + return this.activeProperties.has(property) + } + + /** Queues a property for the next transition cycle. */ + public queue (property: TransitionProperty): void { + this.pendingProperties.add(property) + } + + /** Removes a property from the pending queue without affecting the active cycle. */ + public dequeue (property: TransitionProperty): void { + this.pendingProperties.delete(property) + } + + /** + * Starts a queued transition cycle. + * + * - No pending queue → no-op. + * - `transitionDuration > 0` → begin cycle; fire `onTransitionStart`. + * - `transitionDuration <= 0` → pending is discarded; no cycle begins. + * + * In either non-no-op path, any active cycle is reported as interrupted + * via `onTransitionEnd(true)` before the new state takes effect. + */ + public start (): void { + if (!this.isPending) return + + const { transitionDuration } = this.config + + if (transitionDuration <= 0) { + const wasActive = this.isActive + this.pendingProperties.clear() + this.clearActiveCycle() + if (wasActive) this.config.onTransitionEnd?.(true) + return + } + + if (this.isActive) { + this.end(true) + } + + this.startTime = performance.now() + this.progress = 0 + this.activeProperties = new Set(this.pendingProperties) + this.pendingProperties.clear() + this.config.onTransitionStart?.() + } + + /** + * Advances the active cycle. + * + * - No active cycle → no-op. + * - `transitionDuration <= 0` → end interrupted; fire `onTransitionEnd(true)`. + * - Progress < 1 → update `progress`; fire `onTransition(eased)`. + * - Progress reaches 1 → fire `onTransition(1)` then `onTransitionEnd(false)`. + */ + public step (): void { + if (!this.isActive) return + + const { transitionDuration } = this.config + + if (transitionDuration <= 0) { + this.end(true) + return + } + + const linear = Math.min((performance.now() - this.startTime) / transitionDuration, 1) + const eased = this.applyEasing(linear) + this.progress = eased + this.config.onTransition?.(eased) + + if (linear >= 1) this.end(false) + } + + /** + * Ends the active cycle. + * + * - No active cycle → no-op. + * - Otherwise → fire `onTransitionEnd(interrupted)`. + * + * TODO: support per-property end. + */ + public end (interrupted: boolean): void { + if (!this.isActive) return + this.clearActiveCycle() + this.config.onTransitionEnd?.(interrupted) + } + + /** + * Clears all transition state — active cycle and pending queue — without + * firing lifecycle callbacks. Unlike `end()`, also drops any properties + * queued via `queue()`. + */ + public abort (): void { + this.pendingProperties.clear() + this.clearActiveCycle() + } + + private applyEasing (t: number): number { + return (easingFunctions[this.config.transitionEasing] ?? easeLinear)(t) + } + + /** Ends the active cycle, preserving any pending queue for the next `start()`. */ + private clearActiveCycle (): void { + this.startTime = 0 + this.progress = 1 + this.activeProperties.clear() + } +} diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index d5045028..eb158d85 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -13,7 +13,9 @@ All configuration properties are optional. When creating a graph or calling `set | Property | Description | Default | |---|---|---| -| enableSimulation | If set to `false`, the simulation will not run. This property will be applied only on component initialization and it can't be changed using the `setConfig` or `setConfigPartial` methods | `true` | +| enableSimulation | If set to `false`, the simulation will not run. This property can be changed at runtime using `setConfig()` or `setConfigPartial()`. Re-enabling simulation recreates the required simulation resources immediately. | `true` | +| transitionDuration | Duration in milliseconds of the animated transitions applied when point positions, point colors, point sizes, link colors, or link widths change via the corresponding setters.

Set to `0` (or any value `<= 0`) to disable animated transitions and snap to the new state instead. See [Transitions](#transitions) below for details and migration notes. | `800` | +| transitionEasing | Easing curve used for animated transitions. Accepts a `TransitionEasing` enum value (e.g. `TransitionEasing.CubicInOut`) or its string literal (e.g. `'cubic-in-out'`).

Allowed values: `linear`, `quad-in`, `quad-out`, `quad-in-out`, `cubic-in`, `cubic-out`, `cubic-in-out`, `sin-in`, `sin-out`, `sin-in-out`, `exp-in`, `exp-out`, `exp-in-out`, `circle-in`, `circle-out`, `circle-in-out`. | `TransitionEasing.CubicInOut` | | backgroundColor | Canvas background color | `#222222` | | spaceSize | Simulation space size (default 4096; larger values may crash on some devices, e.g. iOS) | `4096` | | pointDefaultColor | The default color to use for points when no point colors are provided, or if the color value in the array is `undefined` or `null`. This can be either a hex color string (e.g., '#b3b3b3') or an array of RGBA values in the format `[red, green, blue, alpha]` where each value is a number between 0 and 1 | `#b3b3b3` | @@ -103,6 +105,9 @@ cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/ | onSimulationEnd | Called when simulation stops | | onSimulationPause | Called when simulation pauses | | onSimulationUnpause | Called when simulation unpauses | +| onTransitionStart | Called when an animated transition starts (see [Transitions](#transitions)) | +| onTransition | Called on every transition frame with the eased `progress` value in the `[0, 1]` range: `(progress: number) => void` | +| onTransitionEnd | Called when a transition ends. `interrupted` is `true` when the transition was replaced or ended early — specifically: a new transition cycle starts before the current one finishes, `transitionDuration` is set to `0` mid-flight, `enableSimulation` is turned on mid-flight, or `start()` / `unpause()` is called while a position transition is active. Otherwise `false`: `(interrupted: boolean) => void` | | onClick | Called on canvas click, with point index and position if exists | | onPointClick | Called when a point is clicked, with point index and position | | onLinkClick | Called when a link is clicked, with link index | @@ -123,6 +128,28 @@ cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/ | onDrag | Called during dragging | | onDragEnd | Called when dragging ends | +## # Transitions + +By default, changes made through the following setters are animated on the next `render()` call: + +- `setPointPositions` +- `setPointColors` +- `setPointSizes` +- `setLinkColors` +- `setLinkWidths` + +A single transition cycle tracks every animated property in one shared timeline, driven by `transitionDuration` (default `800` ms) and `transitionEasing` (default `TransitionEasing.CubicInOut`). Set `transitionDuration: 0` to disable animations and snap to the new state instead. + +**First render after init.** Position setters always snap on the very first render — there is no prior state to interpolate from. The auto-pause rule described below is also skipped for the first render. + +**Auto-pause.** When `render()` fires with a pending **position** transition, `transitionDuration > 0`, and the simulation running (and it is not the first render), the simulation is paused before the transition starts and `onSimulationPause` fires. The simulation stays paused after the transition ends until you resume it with `unpause()`. + +**`fitView` during a transition.** `fitView()` and `fitViewByPointIndices()` frame the target positions (the latest `setPointPositions` argument), not the interpolated positions currently on screen. + +**Toggling `enableSimulation` mid-transition.** Turning simulation on while a transition is active interrupts it (`onTransitionEnd(true)` fires) and the simulation starts from the current mid-animation positions. Any queued position transition is also dropped to avoid auto-pause on the next render. Turning simulation off leaves an active transition untouched. + +**Migration.** The `transitionDuration` default of `800` ms means `setPointPositions(...); render()` after the first render now animates instead of snapping. To keep the old snap behavior, set `transitionDuration: 0` at construction time, or toggle it per-update via `setConfigPartial({ transitionDuration: 0 })`. + --- Copyright [OpenJS Foundation](https://openjsf.org) and cosmos.gl contributors. All rights reserved. The [OpenJS Foundation](https://openjsf.org) has registered trademarks and uses trademarks. For a list of trademarks of the [OpenJS Foundation](https://openjsf.org), please see our [Trademark Policy](https://trademark-policy.openjsf.org/) and [Trademark List](https://trademark-list.openjsf.org/). Trademarks and logos not indicated on the [list of OpenJS Foundation trademarks](https://trademark-list.openjsf.org) are trademarks™ or registered® trademarks of their respective holders. Use of them does not imply any affiliation with or endorsement by them. [The OpenJS Foundation](https://openjsf.org/) | [Terms of Use](https://terms-of-use.openjsf.org/) | [Privacy Policy](https://privacy-policy.openjsf.org/) | [Bylaws](https://bylaws.openjsf.org/) | [Code of Conduct](https://code-of-conduct.openjsf.org) | [Trademark Policy](https://trademark-policy.openjsf.org/) | [Trademark List](https://trademark-list.openjsf.org/) | [Cookie Policy](https://www.linuxfoundation.org/cookies/) \ No newline at end of file diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx index a27bef28..4da22dc8 100644 --- a/src/stories/3. api-reference.mdx +++ b/src/stories/3. api-reference.mdx @@ -35,7 +35,7 @@ For advanced use cases, you can provide your own Luma.gl Device. Apply a new [configuration](../?path=/docs/configuration--docs). Most changes take effect immediately. -Some initialization-only options, such as `enableSimulation`, are applied only when the graph is created. +Some initialization-only options, such as `initialZoomLevel`, `randomSeed`, and `attribution`, are applied only when the graph is created. `enableSimulation` can be changed at runtime. **Important:** Every call fully resets the configuration to defaults first, then applies the provided values on top. Properties not included in `config` will revert to their default values — they are **not** preserved from the previous call. @@ -58,6 +58,8 @@ Unlike `setConfig()`, this method does **not** reset to defaults first — it on Properties set to `undefined` will be reset to their default values. +This includes `enableSimulation`, which can be toggled without recreating the graph instance. + * **`config`** (GraphConfig): Configuration object with only the properties to update. **Example:** @@ -81,6 +83,8 @@ This method sets the positions of points in a cosmos.gl graph using the provided - `true`: Don't rescale the points. - `false` or `undefined` (default): Use the behavior defined by `config.rescalePositions`. +On the next `render()`, points animate from their previous positions to the new ones using `transitionDuration` and `transitionEasing`. The first render after initialization always snaps (there is no prior state to animate from). See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + **Example:** ```javascript graph.setPointPositions(new Float32Array([1, 2, 3, 4, 5, 6])); @@ -99,6 +103,8 @@ This method sets the colors for points in a cosmos.gl graph. Each group of four values (`r`, `g`, `b`, `a`) represents the color of a single point in the graph. The order of the colors in the array corresponds to the order of the points in the graph's data. +On the next `render()`, point colors animate from their previous values to the new ones using `transitionDuration` and `transitionEasing`. See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + **Example:** ```javascript @@ -126,6 +132,8 @@ This method sets the sizes for the graph points. Each size value in the array specifies the size of a point using the same order as the points in the graph data. +On the next `render()`, point sizes animate from their previous values to the new ones using `transitionDuration` and `transitionEasing`. See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + **Example:** ```javascript graph.setPointSizes(new Float32Array([10, 20, 30])); @@ -267,6 +275,8 @@ graph.render(); In this example, the `linkColors` array contains three sets of four elements, each representing the RGBA color for a single link. The first set `[1, 0, 0, 1]` sets the first link to red, the second set `[0, 1, 0, 1]` sets the second link to green, and the third set `[0, 0, 1, 1]` sets the third link to blue. +On the next `render()`, link colors animate from their previous values to the new ones using `transitionDuration` and `transitionEasing`. See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + ### # graph.getLinkColors() This method retrieves the current colors of the graph links that were previously set using `setLinkColors`. @@ -289,6 +299,8 @@ graph.render(); In this example, the `linkWidths` array contains three numerical values. The first value `1` sets the width of the first link, the second value `2` sets the width of the second link, and the third value `3` sets the width of the third link. +On the next `render()`, link widths animate from their previous values to the new ones using `transitionDuration` and `transitionEasing`. See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + ### # graph.getLinkWidths() This method retrieves the current widths of the graph links that were previously set using `setLinkWidths`. @@ -385,6 +397,8 @@ The `render` method renders the graph and starts rendering. It does NOT modify s - If positive: Sets alpha to that value. - If `undefined`: Keeps current alpha value. +**Transitions:** If any setter (`setPointPositions`, `setPointColors`, `setPointSizes`, `setLinkColors`, `setLinkWidths`) has queued changes and `transitionDuration > 0`, `render()` starts an animated transition. When the simulation is running, only **position** transitions auto-pause it (and `onSimulationPause` fires), and the simulation stays paused afterwards until `unpause()` is called. The first render after initialization always snaps position changes — there is no prior state to animate from. See [Transitions](../?path=/docs/configuration--docs#transitions) in the configuration docs. + ### # graph.zoomToPointByIndex(index, [duration], [scale], [canZoomOut], [enableSimulation]) This method centers the view on the specified point (by its index) and zooms in with a given animation duration and scale value. @@ -417,6 +431,8 @@ This method centers and zooms the view to fit all points within the scene. The `fitView` method is particularly useful when you want to ensure that all points in the graph are visible within the currently displayed viewport. By fitting the view to include all points, users can get a complete overview of the entire graph. +**Note:** While a position transition is active, `fitView` frames the target positions (the latest `setPointPositions` argument), not the interpolated positions currently on screen. The same applies to `fitViewByPointIndices`. + ### # graph.fitViewByPointIndices(indices, [duration], [padding], [enableSimulation]) The `fitViewByPointIndices` method centers and zooms the view to fit the points specified by their indices in the scene, with an optional animation duration and padding. @@ -602,6 +618,10 @@ Starts the simulation. This method only controls the simulation state, not rende * **`alpha`** (Number, optional): A number between `0` and `1` representing the initial energy of the simulation. The default value is `1` if not provided. A higher `alpha` value results in more initial energy for the simulation. +If the simulation is already running, calling `start(alpha)` reheats it by resetting `alpha` and `simulationProgress` without firing `onSimulationStart` again. + +If a **position** transition is currently active when `start()` is called, the transition is ended as interrupted (`onTransitionEnd(true)` fires) and the simulation runs from the current mid-animation positions. + ### # graph.pause() Pauses the simulation. When paused, the simulation stops running but preserves its current state (progress, alpha). Can be resumed using the `unpause()` method. @@ -610,6 +630,8 @@ Pauses the simulation. When paused, the simulation stops running but preserves i Unpauses (resumes) the simulation. This method resumes a paused simulation and continues its execution from where it was paused. +If a **position** transition is currently active when `unpause()` is called, the transition is ended as interrupted (`onTransitionEnd(true)` fires) and the simulation resumes from the current mid-animation positions. + ### # graph.stop() Stops the simulation. This stops the simulation and resets its state (progress, alpha). Use `start()` to begin a new simulation cycle. diff --git a/src/stories/beginners/remove-points/config.ts b/src/stories/beginners/remove-points/config.ts index 0bc954c8..0fe530bc 100644 --- a/src/stories/beginners/remove-points/config.ts +++ b/src/stories/beginners/remove-points/config.ts @@ -2,6 +2,7 @@ import { type GraphConfig } from '@cosmos.gl/graph' export const config: GraphConfig = { spaceSize: 4096, + transitionDuration: 0, backgroundColor: '#2d313a', pointDefaultSize: 4, pointDefaultColor: '#4B5BBF', diff --git a/src/stories/geospatial/moscow-metro-stations/index.ts b/src/stories/geospatial/moscow-metro-stations/index.ts index 9cc9b7e7..75d44012 100644 --- a/src/stories/geospatial/moscow-metro-stations/index.ts +++ b/src/stories/geospatial/moscow-metro-stations/index.ts @@ -29,6 +29,7 @@ export const moscowMetroStations = (): {graph: Graph; div: HTMLDivElement; destr const config = { backgroundColor: '#2d313a', + transitionDuration: 0, scalePointsOnZoom: false, rescalePositions, pointDefaultColor: '#FEE08B', diff --git a/src/stories/transition/horsewoman-by-bryullov-1832.jpg b/src/stories/transition/horsewoman-by-bryullov-1832.jpg new file mode 100644 index 00000000..9a531821 Binary files /dev/null and b/src/stories/transition/horsewoman-by-bryullov-1832.jpg differ diff --git a/src/stories/transition/point-data.ts b/src/stories/transition/point-data.ts new file mode 100644 index 00000000..720a1153 --- /dev/null +++ b/src/stories/transition/point-data.ts @@ -0,0 +1,58 @@ +/** Load a source image and sample RGBA per point on a fixed story grid. */ + +/** Bryullov, Horsewoman, 1832 (see horsewoman-by-bryullov-1832.jpg). */ +const defaultPictureUrl = new URL('./horsewoman-by-bryullov-1832.jpg', import.meta.url).href + +const POINT_GRID_COLS = 400 +const POINT_GRID_ROWS = 500 + +function loadImage (url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.onload = (): void => resolve(img) + img.onerror = (): void => reject(new Error(`Failed to load image: ${url}`)) + img.src = url + }) +} + +function sampleImageToPointColors ( + img: HTMLImageElement, + cols: number, + rows: number +): Float32Array { + const canvas = document.createElement('canvas') + canvas.width = cols + canvas.height = rows + const ctx = canvas.getContext('2d', { willReadFrequently: true }) + if (!ctx) { + throw new Error('Could not get 2D canvas context.') + } + // Graph coordinates are Y-up, so flip once while drawing to keep sampling simple. + ctx.save() + ctx.translate(0, rows) + ctx.scale(1, -1) + ctx.drawImage(img, 0, 0, cols, rows) + ctx.restore() + const { data } = ctx.getImageData(0, 0, cols, rows) + const out = new Float32Array(cols * rows * 4) + for (const [i, value] of data.entries()) { + out[i] = value / 255 + } + return out +} + +export async function loadPointData ( + imageUrl: string = defaultPictureUrl +): Promise<{ + cols: number; + rows: number; + aspect: number; + colors: Float32Array; +}> { + const img = await loadImage(imageUrl) + const aspect = img.width / img.height + const cols = POINT_GRID_COLS + const rows = POINT_GRID_ROWS + const colors = sampleImageToPointColors(img, cols, rows) + return { cols, rows, aspect, colors } +} diff --git a/src/stories/transition/point-transition.stories.ts b/src/stories/transition/point-transition.stories.ts new file mode 100644 index 00000000..f2087922 --- /dev/null +++ b/src/stories/transition/point-transition.stories.ts @@ -0,0 +1,33 @@ +import type { Meta } from '@storybook/html' + +import { createStory, Story } from '@/graph/stories/create-story' +import { CosmosStoryProps } from '@/graph/stories/create-cosmos' +import { pointTransition } from './point-transition' + +// @ts-expect-error Vite raw imports are resolved by Storybook at runtime. +import pointTransitionRaw from './point-transition?raw' +// @ts-expect-error Vite raw imports are resolved by Storybook at runtime. +import transitionCssRaw from './transition.css?raw' +// @ts-expect-error Vite raw imports are resolved by Storybook at runtime. +import pointDataRaw from './point-data?raw' +// @ts-expect-error Vite raw imports are resolved by Storybook at runtime. +import transitionHelpersRaw from './transition-helpers?raw' + +const meta: Meta = { + title: 'Examples/Transitions', +} + +export const PointTransition: Story = { + ...createStory(pointTransition), + parameters: { + sourceCode: [ + { name: 'Story', code: pointTransitionRaw }, + { name: 'transition.css', code: transitionCssRaw }, + { name: 'point-data.ts', code: pointDataRaw }, + { name: 'transition-helpers.ts', code: transitionHelpersRaw }, + ], + }, +} + +// eslint-disable-next-line import/no-default-export +export default meta diff --git a/src/stories/transition/point-transition.ts b/src/stories/transition/point-transition.ts new file mode 100644 index 00000000..bd813ff8 --- /dev/null +++ b/src/stories/transition/point-transition.ts @@ -0,0 +1,126 @@ +/** + * Demonstrates GPU-driven point position transitions: a 200k-point cloud sampled + * from a painting auto-loops between the picture layout and a sequence of tile scatters. + */ + +import { Graph, defaultConfigValues, TransitionEasing } from '@cosmos.gl/graph' + +import './transition.css' +import { loadPointData } from './point-data' +import { + createPicturePositions, + createTileScatterPositions, +} from './transition-helpers' + +/** + * Auto-loop sequence for point positions: + * - number: render tile scatter with this `tileGridN` value (e.g. 2..16) + * - undefined: render the original picture layout (no scatter) + */ +const LOOP_STEPS = [ + 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, + undefined, + 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, + undefined, +] + +export const pointTransition = async (): Promise<{ + graph: Graph; + div: HTMLDivElement; + destroy?: () => void; +}> => { + const { cols, rows, aspect, colors: pictureColors } = await loadPointData() + const spaceSize = defaultConfigValues.spaceSize + + let loopStepIndex = 0 + let loopIntervalId: ReturnType | undefined + + const picturePositions = createPicturePositions(cols, rows, spaceSize, aspect) + + const div = document.createElement('div') + div.className = 'app' + div.style.background = defaultConfigValues.backgroundColor + + const graphDiv = document.createElement('div') + graphDiv.className = 'graph' + div.appendChild(graphDiv) + + const fitViewAction = document.createElement('div') + fitViewAction.className = 'action' + fitViewAction.textContent = 'FitView' + fitViewAction.title = 'Fit the camera to current points.' + + const pausePlayAction = document.createElement('div') + pausePlayAction.className = 'action' + pausePlayAction.textContent = 'Pause' + pausePlayAction.title = 'Pause or resume the auto-loop.' + + const actionsDiv = document.createElement('div') + actionsDiv.className = 'actions' + actionsDiv.appendChild(fitViewAction) + actionsDiv.appendChild(pausePlayAction) + div.appendChild(actionsDiv) + + const stopLoopTimer = (): void => { + if (loopIntervalId === undefined) return + clearInterval(loopIntervalId) + loopIntervalId = undefined + } + + const startLoop = (): void => { + loopIntervalId = setInterval(() => { + const step = LOOP_STEPS[loopStepIndex] + if (step === undefined) { + graph.setPointPositions(picturePositions) + } else { + const tilePositions = createTileScatterPositions( + cols, + rows, + spaceSize, + aspect, + step + ) + graph.setPointPositions(tilePositions) + } + graph.render() + loopStepIndex = (loopStepIndex + 1) % LOOP_STEPS.length + }, defaultConfigValues.transitionDuration) + } + + const graph = new Graph(graphDiv, { + enableSimulation: false, + pointDefaultSize: 2, + transitionEasing: TransitionEasing.CubicInOut, + attribution: + 'visualized with Cosmograph', + }) + + graph.setPointPositions(picturePositions) + graph.setPointColors(pictureColors) + graph.render() + graph.fitView() + startLoop() + + fitViewAction.addEventListener('click', () => { + graph.fitView() + }) + + pausePlayAction.addEventListener('click', () => { + if (loopIntervalId !== undefined) { + stopLoopTimer() + pausePlayAction.textContent = 'Play' + } else { + startLoop() + pausePlayAction.textContent = 'Pause' + } + }) + + return { + div, + graph, + destroy: (): void => { + stopLoopTimer() + graph.destroy() + }, + } +} diff --git a/src/stories/transition/transition-helpers.ts b/src/stories/transition/transition-helpers.ts new file mode 100644 index 00000000..46b5eba0 --- /dev/null +++ b/src/stories/transition/transition-helpers.ts @@ -0,0 +1,103 @@ +/** Fits the image into the scene with a small margin. */ +function getPictureLayoutRect ( + spaceSize: number, + aspect: number +): { left: number; top: number; w: number; h: number } { + const margin = spaceSize * 0.032 + const inner = spaceSize - 2 * margin + let w: number + let h: number + + if (aspect >= 1) { + w = inner * 0.98 + h = w / aspect + } else { + h = inner * 0.98 + w = h * aspect + } + + const cx = spaceSize / 2 + const cy = spaceSize / 2 + return { left: cx - w / 2, top: cy - h / 2, w, h } +} + +/** Generates the photo point layout on the fitted image rect. */ +export function createPicturePositions ( + cols: number, + rows: number, + spaceSize: number, + aspect: number +): Float32Array { + const { left, top, w, h } = getPictureLayoutRect(spaceSize, aspect) + const out = new Float32Array(cols * rows * 2) + let p = 0 + + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const u = cols > 1 ? col / (cols - 1) : 0.5 + const v = rows > 1 ? row / (rows - 1) : 0.5 + out[p] = left + u * w + out[p + 1] = top + v * h + p += 2 + } + } + + return out +} +/** Stable fractional pseudo-random number in [0, 1) from integer key. */ +function hash01 (key: number): number { + const x = Math.sin(key * 12.9898) * 43758.5453 + return x - Math.floor(x) +} + +/** Builds an n×n tile scatter by rigidly shifting each tile block. */ +export function createTileScatterPositions ( + cols: number, + rows: number, + spaceSize: number, + aspect: number, + n: number +): Float32Array { + const { left, top, w, h } = getPictureLayoutRect(spaceSize, aspect) + const gridN = Math.max(2, Math.min(64, Math.floor(n))) + const tileW = w / gridN + const tileH = h / gridN + + // Deterministic scatter: each tile index maps to one stable X/Y offset. + const scatterR = 0.72 * Math.min(tileW, tileH) + + const tileCount = gridN * gridN + const offsets = new Float32Array(tileCount * 2) + + let pi = 0 + for (let cellJ = 0; cellJ < gridN; cellJ += 1) { + for (let cellI = 0; cellI < gridN; cellI += 1) { + const tileId = cellJ * gridN + cellI + 1 + const dx = hash01(tileId) * 2 - 1 + const dy = hash01(tileId * 31) * 2 - 1 + offsets[pi] = dx * scatterR + offsets[pi + 1] = dy * scatterR + pi += 2 + } + } + + const out = new Float32Array(cols * rows * 2) + for (let row = 0; row < rows; row += 1) { + for (let col = 0; col < cols; col += 1) { + const offset = (row * cols + col) * 2 + const u = cols > 1 ? col / (cols - 1) : 0.5 + const v = rows > 1 ? row / (rows - 1) : 0.5 + const px = left + u * w + const py = top + v * h + + const cellI = Math.min(gridN - 1, Math.floor((col * gridN) / cols)) + const cellJ = Math.min(gridN - 1, Math.floor((row * gridN) / rows)) + const ti = (cellJ * gridN + cellI) * 2 + + out[offset] = px + (offsets[ti] ?? 0) + out[offset + 1] = py + (offsets[ti + 1] ?? 0) + } + } + + return out +} diff --git a/src/stories/transition/transition.css b/src/stories/transition/transition.css new file mode 100644 index 00000000..095bcb23 --- /dev/null +++ b/src/stories/transition/transition.css @@ -0,0 +1,35 @@ +/* Minimal panel, same spirit as beginners/basic-set-up (100×100 grid). */ +.app { + position: relative; + width: 100%; + height: 100vh; +} + +.graph { + width: 100%; + height: 100%; +} + +.actions { + position: absolute; + top: 10px; + left: 10px; + z-index: 1; + color: #ccc; + display: flex; + flex-direction: column; + gap: 2px; + align-items: flex-start; +} + +.action { + margin-left: 2px; + font-size: 10pt; + text-decoration: underline; + cursor: pointer; + user-select: none; +} + +.action:hover { + color: #fff; +} diff --git a/src/variables.ts b/src/variables.ts index 6c5108de..68c26f84 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -1,5 +1,6 @@ import type { GraphConfigInterface, Complete } from '@/graph/config' import { PointShape } from '@/graph/modules/GraphData' +import { TransitionEasing } from '@/graph/modules/Transition' /** * Default values for all graph configuration properties. @@ -7,6 +8,8 @@ import { PointShape } from '@/graph/modules/GraphData' export const defaultConfigValues = { // General enableSimulation: true, + transitionDuration: 800, + transitionEasing: TransitionEasing.CubicInOut, backgroundColor: '#222222', /** Setting to 4096 because larger values crash the graph on iOS. More info: https://github.com/cosmosgl/graph/issues/203 */ spaceSize: 4096, @@ -77,6 +80,11 @@ export const defaultConfigValues = { onSimulationPause: undefined, onSimulationUnpause: undefined, + // Transition callbacks + onTransitionStart: undefined, + onTransition: undefined, + onTransitionEnd: undefined, + // Interaction callbacks onClick: undefined, onPointClick: undefined,