Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c5fd30e
feat: add GPU transitions and runtime simulation toggle
Stukova Apr 22, 2026
050e19b
docs(stories): add point position transition example
Stukova Apr 22, 2026
6b540da
docs(history): add history guidelines and /history Claude Code skill
Stukova Apr 23, 2026
75b1a15
fix(transitions): only pause simulation for position transitions
Stukova Apr 23, 2026
74e0567
fix(transitions): invalidate caches, gate hover, drop dead buffer ali…
Stukova Apr 27, 2026
c9d3f6a
docs(history): refresh gpu-transitions entry, add /history --update
Stukova Apr 27, 2026
b775c4c
fix(transitions): guard destroyed position textures
Stukova Apr 28, 2026
45a12f4
fix(transitions): gate shouldAnimate on isPendingFor(Positions)
Stukova Apr 28, 2026
a826398
docs(history): add Codex /history skill and update related docs
Stukova Apr 28, 2026
bcbbdcb
docs(history): describe transitionDuration<=0 snap behavior more prec…
Stukova Apr 28, 2026
579801d
fix(simulation): restore start() reheating semantics
Stukova Apr 28, 2026
5006eea
fix(interactions): narrow transition hover and drag guards
Stukova Apr 28, 2026
fed8dcd
fix(transitions): tighten simulation/transition guards
Stukova Apr 29, 2026
6256b66
fix(transitions): prevent negative progress by sampling time in step()
Stukova Apr 29, 2026
cd32f59
chore(transitions): minor doc and code tweaks
Stukova Apr 29, 2026
b8f993a
docs(history): refresh gpu-transitions entry; skip history-only commits
Stukova Apr 29, 2026
635b6f4
3.0.0-beta.9
Stukova Apr 29, 2026
bdac751
fix(transitions): refresh tracked positions during interpolation
Stukova May 25, 2026
f65b746
docs(transitions): clarify onTransitionEnd interrupt sources
Stukova Jun 1, 2026
2ca5348
fix(transitions): avoid leaking target into current position textures
Stukova Jun 1, 2026
399b000
3.0.0-beta.10
Stukova Jun 1, 2026
c4b93e1
feat(interactions): touch and pen support via pointer events
Stukova Jun 3, 2026
95dd97e
docs(history): add touch-input entry
Stukova Jun 3, 2026
04eaf8c
fix(interactions): clear right-click flag on pointerup
Stukova Jun 3, 2026
3f8e643
refactor(interactions): consolidate canvas event listeners
Stukova Jun 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .agents/skills/history/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 `<!-- TODO -->`.
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 `<!-- TODO: ... -->` 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.
36 changes: 36 additions & 0 deletions .claude/skills/history/SKILL.md
Original file line number Diff line number Diff line change
@@ -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 `<!-- TODO -->`.
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 `<!-- TODO: ... -->` 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.
2 changes: 2 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <why>` 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:
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 94 additions & 0 deletions history/2026/2026-04-22-gpu-transitions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
<!-- suggested path: history/2026/2026-04-22-gpu-transitions.md -->

# 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.
Comment thread
Stukova marked this conversation as resolved.

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.
Loading