From c5fd30e70b3ce1ed916d9d07ab8e89bac4a9c295 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 22 Apr 2026 17:20:00 +0500 Subject: [PATCH 01/25] feat: add GPU transitions and runtime simulation toggle Signed-off-by: Stukova Olya --- src/config.ts | 31 +- src/index.ts | 147 +++- src/modules/Drag/index.ts | 6 +- src/modules/GraphData/index.ts | 14 + src/modules/Lines/draw-curve-line.vert | 28 +- src/modules/Lines/index.ts | 186 +++-- src/modules/Points/draw-points.vert | 26 +- src/modules/Points/index.ts | 749 ++++++++++++++----- src/modules/Points/interpolate-position.frag | 28 + src/modules/Points/position-utils.ts | 57 ++ src/modules/Transition/index.ts | 185 +++++ src/stories/2. configuration.mdx | 29 +- src/stories/3. api-reference.mdx | 20 +- src/variables.ts | 8 + 14 files changed, 1239 insertions(+), 275 deletions(-) create mode 100644 src/modules/Points/interpolate-position.frag create mode 100644 src/modules/Points/position-utils.ts create mode 100644 src/modules/Transition/index.ts diff --git a/src/config.ts b/src/config.ts index c8920a66..49e9f49d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -4,15 +4,42 @@ 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` + */ + 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/index.ts b/src/index.ts index 84b1cef2..a04e7b34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ 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' @@ -56,7 +57,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 @@ -251,6 +253,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) @@ -372,6 +375,9 @@ export class Graph { this.graph.inputPointPositions = pointPositions this.points!.shouldSkipRescale = dontRescale this.isPointPositionsUpdateNeeded = true + if (this.points?.currentPositionTexture) { + 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 +405,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 +431,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 +537,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 +563,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 +727,24 @@ export class Graph { } // Update graph and start frames this.update(simulationAlpha) + + // Animated transitions (duration > 0) should not compete with live force updates. + // If a transition is pending, simulation is running, and this is not the first render + // after init, pause before the transition cycle starts. The first-render skip avoids + // treating the default transitionDuration as an intentional animation on load. + if (this.transition.isPending && + this.store.isSimulationRunning && + this.config.transitionDuration > 0 && + !this._isFirstRenderAfterInit) { + this.store.isSimulationRunning = false + this.config.onSimulationPause?.() + } + + if (this.transition.isPending && !this.points?.currentPositionTexture) { + 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 +880,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 +894,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 @@ -1145,9 +1172,11 @@ export class Graph { if (this.ensureDevice(() => this.start(alpha))) return + if (!this.config.enableSimulation) return if (!this.graph.pointsNumber) return + if (this.store.isSimulationRunning) return - // Always set simulation as running when start() is called + // Ignore repeated start() calls while simulation is already running. this.store.isSimulationRunning = true this.store.simulationProgress = 0 this.store.alpha = alpha @@ -1162,10 +1191,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 +1206,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 +1218,11 @@ 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.isActive) { + this.transition.end(true) + } this.store.isSimulationRunning = true this.config.onSimulationUnpause?.() } @@ -1214,6 +1250,7 @@ export class Graph { if (this._isDestroyed) return this._isDestroyed = true this.isReady = false + this.transition.abort() window.clearTimeout(this._fitViewOnInitTimeoutID) this.stopFrames() @@ -1361,21 +1398,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 +1524,38 @@ 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) { + this.transition.end(true) + 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 + } + + this.store.isSimulationRunning = false + this.store.alpha = 0 + this.store.simulationProgress = 0 + this._shouldForceHoverDetection = true + 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 +1726,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 +1784,26 @@ 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(frameNow) + + if (shouldInterpolatePositions) { + this.points?.interpolatePosition(this.transition.progress) + } + } + + this.points?.setTransitionProgress(this.transition.progress, shouldAnimatePointColors, shouldAnimatePointSizes) + this.lines?.setTransitionProgress(this.transition.progress, shouldAnimateLinkColors, shouldAnimateLinkWidths) + if (!this.dragInstance.isActive) { this.findHoveredItem() } @@ -1722,7 +1850,7 @@ export class Graph { this.device.submit() } - this.fpsMonitor?.end(now ?? performance.now()) + this.fpsMonitor?.end(frameNow) this.currentEvent = undefined } @@ -2026,6 +2154,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..a5b3139e 100644 --- a/src/modules/Drag/index.ts +++ b/src/modules/Drag/index.ts @@ -1,13 +1,16 @@ import { drag } from 'd3-drag' import { Store } from '@/graph/modules/Store' import { type GraphConfigInterface } from '@/graph/config' +import { Transition } 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) => { + if (this.transition.isActive) return undefined return this.store.hoveredPoint && !this.store.isSpaceKeyPressed ? { x: event.x, y: event.y } : undefined }) .on('start', (e) => { @@ -26,8 +29,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..1cf0acd2 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -27,7 +27,13 @@ export class Lines extends CoreModule { private pointABuffer: Buffer | undefined private pointBBuffer: Buffer | undefined private colorBuffer: Buffer | undefined + private sourceColorBuffer: Buffer | undefined + private targetColorBuffer: Buffer | undefined + private previousColorData: Float32Array | undefined private widthBuffer: Buffer | 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 +41,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 +82,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: number; focusedLinkIndex: number; focusedLinkWidthIncrease: number; + transitionProgress: number; + animateColors: number; + animateWidths: number; }; drawLineFragmentUniforms: { renderMode: number; @@ -167,6 +179,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: 'f32', focusedLinkIndex: 'f32', focusedLinkWidthIncrease: 'f32', + transitionProgress: 'f32', + animateColors: 'f32', + animateWidths: 'f32', }, defaultUniforms: { transformationMatrix: store.transformationMatrix4x4, @@ -192,6 +207,9 @@ export class Lines extends CoreModule { linkStatusTextureSize: 0, focusedLinkIndex: config.focusedLinkIndex ?? -1, focusedLinkWidthIncrease: config.focusedLinkWidthIncrease, + transitionProgress: 1, + animateColors: 0, + animateWidths: 0, }, }, drawLineFragmentUniforms: { @@ -214,8 +232,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 +243,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' }, ], @@ -398,6 +420,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 +601,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 } = this.updateAttributeBuffers( + colorData, + this.sourceColorBuffer, + this.targetColorBuffer, + this.previousColorData, + 4 + ) + this.sourceColorBuffer = source + this.targetColorBuffer = target + this.previousColorData = previous + this.colorBuffer = target - 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 } = this.updateAttributeBuffers( + widthData, + this.sourceWidthBuffer, + this.targetWidthBuffer, + this.previousWidthData, + 1 + ) + this.sourceWidthBuffer = source + this.targetWidthBuffer = target + this.previousWidthData = previous + this.widthBuffer = target - 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 +911,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 +959,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 @@ -1009,10 +1027,28 @@ export class Lines extends CoreModule { this.colorBuffer.destroy() } this.colorBuffer = undefined + 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.widthBuffer && !this.widthBuffer.destroyed) { this.widthBuffer.destroy() } this.widthBuffer = undefined + if (this.sourceWidthBuffer && !this.sourceWidthBuffer.destroyed) { + this.sourceWidthBuffer.destroy() + } + this.sourceWidthBuffer = undefined + if (this.targetWidthBuffer && !this.targetWidthBuffer.destroyed) { + this.targetWidthBuffer.destroy() + } + this.targetWidthBuffer = undefined + this.previousWidthData = undefined if (this.arrowBuffer && !this.arrowBuffer.destroyed) { this.arrowBuffer.destroy() } @@ -1046,4 +1082,58 @@ export class Lines extends CoreModule { }) this.linkStatusTextureSize = 0 } + + private updateAttributeBuffers ( + 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. + 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: this.device.createBuffer({ + data: sourceData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + target: this.device.createBuffer({ + data: targetData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + previous: new Float32Array(targetData), + } + } } 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..1e9459d2 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -16,6 +16,7 @@ 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 interpolatePositionFrag from '@/graph/modules/Points/interpolate-position.frag?raw' import { createIndexesForBuffer } from '@/graph/modules/Shared/buffer' import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils' import trackPositionsFrag from '@/graph/modules/Points/track-positions.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 } 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 @@ -41,6 +47,8 @@ export class Points extends CoreModule { public previousPositionTexture: Texture | undefined public velocityTexture: Texture | undefined public pointStatusTexture: Texture | undefined + public sourcePositionTexture: Texture | undefined + 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). @@ -49,7 +57,13 @@ export class Points extends CoreModule { */ public areClusterCentroidsUpToDate = false private colorBuffer: Buffer | undefined + private sourceColorBuffer: Buffer | undefined + private targetColorBuffer: Buffer | undefined + private previousColorData: Float32Array | undefined private sizeBuffer: Buffer | 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 @@ -62,6 +76,7 @@ export class Points extends CoreModule { 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 +85,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 +101,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 +113,12 @@ export class Points extends CoreModule { }; }> | undefined + private interpolatePositionUniformStore: UniformStore<{ + interpolatePositionUniforms: { + progress: number; + }; + }> | undefined + private dragPointUniformStore: UniformStore<{ dragPointUniforms: { mousePos: [number, number]; @@ -119,6 +144,9 @@ export class Points extends CoreModule { hasImages: number; imageCount: number; imageAtlasCoordsTextureSize: number; + transitionProgress: number; + animateColors: number; + animateSizes: number; }; drawFragmentUniforms: { greyoutOpacity: number; @@ -205,28 +233,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,77 +258,19 @@ 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 - } + const sourceCount = data.sourcePointsNumber + const targetCount = data.targetPointsNumber + const sameCount = sourceCount === targetCount + const shouldAnimate = + this.transition?.isPending === true && + this.config.transitionDuration > 0 && + !!this.currentPositionTexture - // 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 targetState = buildPositionTextureData(data.pointPositions, pointsTextureSize, targetCount) - // 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.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, + const writePositionTexture = (tex: Texture, positionData: Float32Array): void => { + tex.copyImageData({ + data: positionData, bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), mipLevel: 0, x: 0, @@ -325,44 +278,47 @@ export class Points extends CoreModule { }) } - 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() + // Populate source/target position textures for the transition: + // - same count: GPU-to-GPU copy of current → source (no CPU transfer). + // - count changed: CPU readback of current, carry over shared indices, + // fill new indices from target. + // - no prior frame (first render): source = target (nothing to animate from). + 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) + const sourceData = buildSourcePositionTextureData( + previousPositionPixels, + targetState, + Math.min(sourceCount, targetCount), + targetCount, + pointsTextureSize + ) + writePositionTexture(this.sourcePositionTexture, sourceData) + } else { + writePositionTexture(this.sourcePositionTexture, targetState) } - 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, - }) + + writePositionTexture(this.targetPositionTexture, targetState) } } + this.createOrUpdatePositionTextures(targetState, pointsTextureSize) + if (this.config.enableSimulation) this.ensureSimulationResources() + // Create searchTexture and framebuffer if (!this.searchTexture || this.searchTexture.width !== pointsTextureSize || this.searchTexture.height !== pointsTextureSize) { if (this.searchTexture && !this.searchTexture.destroyed) { @@ -377,7 +333,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 +346,7 @@ export class Points extends CoreModule { }) } else { this.searchTexture.copyImageData({ - data: initialState, + data: targetState, bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), mipLevel: 0, x: 0, @@ -461,6 +417,7 @@ export class Points extends CoreModule { this.updateSampledPointsGrid() this.trackPointsByIndices() + return shouldAnimate } public initPrograms (): void { @@ -476,49 +433,7 @@ export class Points extends CoreModule { 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 +498,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 +528,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 +561,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' }, @@ -1013,26 +938,26 @@ export class Points extends CoreModule { } public updateColor (): void { - const { device, store: { pointsTextureSize }, data } = this + const { store: { pointsTextureSize }, data } = this if (!pointsTextureSize) return - const colorData = data.pointColors as Float32Array - const requiredByteLength = colorData.byteLength + const colorData = data.pointColors ?? new Float32Array((data.pointsNumber ?? 0) * 4).fill(0) + const { source, target, previous } = this.updateAttributeBuffers( + colorData, + this.sourceColorBuffer, + this.targetColorBuffer, + this.previousColorData, + 4 + ) + this.sourceColorBuffer = source + this.targetColorBuffer = target + this.previousColorData = previous + this.colorBuffer = target - 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 +1058,31 @@ 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 + const sizeData = data.pointSizes ?? new Float32Array(data.pointsNumber).fill(0) + const { source, target, previous } = this.updateAttributeBuffers( + sizeData, + this.sourceSizeBuffer, + this.targetSizeBuffer, + this.previousSizeData, + 1 + ) + this.sourceSizeBuffer = source + this.targetSizeBuffer = target + this.previousSizeData = previous + this.sizeBuffer = target - 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 +1091,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, @@ -1385,6 +1311,12 @@ 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() @@ -1432,6 +1364,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 = { @@ -2054,6 +1989,8 @@ export class Points extends CoreModule { 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 +2015,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 +2053,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 +2099,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() @@ -2170,10 +2125,28 @@ export class Points extends CoreModule { this.colorBuffer.destroy() } this.colorBuffer = undefined + 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.sizeBuffer && !this.sizeBuffer.destroyed) { this.sizeBuffer.destroy() } this.sizeBuffer = undefined + if (this.sourceSizeBuffer && !this.sourceSizeBuffer.destroyed) { + this.sourceSizeBuffer.destroy() + } + this.sourceSizeBuffer = undefined + if (this.targetSizeBuffer && !this.targetSizeBuffer.destroyed) { + this.targetSizeBuffer.destroy() + } + this.targetSizeBuffer = undefined + this.previousSizeData = undefined if (this.shapeBuffer && !this.shapeBuffer.destroyed) { this.shapeBuffer.destroy() } @@ -2202,6 +2175,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 +2201,212 @@ 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.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, + }) + } + } + + 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.sourcePositionTexture && !this.sourcePositionTexture.destroyed) { + this.sourcePositionTexture.destroy() + } + if (this.sourcePositionFbo && !this.sourcePositionFbo.destroyed) { + this.sourcePositionFbo.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.targetPositionTexture && !this.targetPositionTexture.destroyed) { + this.targetPositionTexture.destroy() + } + if (this.targetPositionFbo && !this.targetPositionFbo.destroyed) { + this.targetPositionFbo.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() + } + + 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 +2425,164 @@ export class Points extends CoreModule { this.areClusterCentroidsUpToDate = false } + private updateAttributeBuffers ( + 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. + 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] as number) + } + for (let i = sharedCount * tupleSize; i < targetData.length; i += 1) { + sourceData[i] = targetData[i] as number + } + + if (sourceBuffer && !sourceBuffer.destroyed) { + sourceBuffer.destroy() + } + if (targetBuffer && !targetBuffer.destroyed) { + targetBuffer.destroy() + } + + return { + source: this.device.createBuffer({ + data: sourceData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + target: this.device.createBuffer({ + data: targetData, + usage: Buffer.VERTEX | Buffer.COPY_DST, + }), + previous: new Float32Array(targetData), + } + } + + private createOrUpdatePositionTextures (positionData: Float32Array, pointsTextureSize: number): void { + // 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 = this.device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + }) + this.currentPositionFbo = this.device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.currentPositionTexture], + }) + } + this.currentPositionTexture.copyImageData({ + data: positionData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + + // 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.previousPositionTexture = this.device.createTexture({ + width: pointsTextureSize, + height: pointsTextureSize, + format: 'rgba32float', + }) + this.previousPositionFbo = this.device.createFramebuffer({ + width: pointsTextureSize, + height: pointsTextureSize, + colorAttachments: [this.previousPositionTexture], + }) + } + this.previousPositionTexture.copyImageData({ + data: positionData, + bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), + mipLevel: 0, + x: 0, + y: 0, + }) + + this.areClusterCentroidsUpToDate = false + this.isPositionsUpToDate = false + } + + 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/Transition/index.ts b/src/modules/Transition/index.ts new file mode 100644 index 00000000..daaff3c9 --- /dev/null +++ b/src/modules/Transition/index.ts @@ -0,0 +1,185 @@ +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 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) + } + + /** + * 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 (nowMs: number): void { + if (!this.isActive) return + + const { transitionDuration } = this.config + + if (transitionDuration <= 0) { + this.end(true) + return + } + + const linear = Math.min((nowMs - 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)`. + */ + 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..1f2ecc9a 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 by a new one or ended early (e.g. by `unpause()` or by setting `transitionDuration` to `0` mid-flight): `(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 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 for the duration of the transition — you can resume it with `unpause()` at any time, which interrupts the transition (`onTransitionEnd(true)` fires). + +**`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. 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..3a72e381 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, it auto-pauses for the duration of the transition (and `onSimulationPause` fires). 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. @@ -610,6 +626,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 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/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, From 050e19b756e73e1d7f254913d27830d053fdbc20 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 22 Apr 2026 20:27:41 +0500 Subject: [PATCH 02/25] docs(stories): add point position transition example Signed-off-by: Stukova Olya --- README.md | 5 +- migration-notes.md | 29 ++- src/declaration.d.ts | 5 + .../horsewoman-by-bryullov-1832.jpg | Bin 0 -> 82429 bytes src/stories/transition/point-data.ts | 58 ++++++ .../transition/point-transition.stories.ts | 33 ++++ src/stories/transition/point-transition.ts | 142 +++++++++++++++ src/stories/transition/transition-helpers.ts | 165 ++++++++++++++++++ src/stories/transition/transition.css | 35 ++++ 9 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 src/stories/transition/horsewoman-by-bryullov-1832.jpg create mode 100644 src/stories/transition/point-data.ts create mode 100644 src/stories/transition/point-transition.stories.ts create mode 100644 src/stories/transition/point-transition.ts create mode 100644 src/stories/transition/transition-helpers.ts create mode 100644 src/stories/transition/transition.css 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/migration-notes.md b/migration-notes.md index 346da76a..e04a0096 100644 --- a/migration-notes.md +++ b/migration-notes.md @@ -176,11 +176,38 @@ 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 }) +``` + +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/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/stories/transition/horsewoman-by-bryullov-1832.jpg b/src/stories/transition/horsewoman-by-bryullov-1832.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9a5318213e54b54c7687d64a11ae6c15fd8dbb0b GIT binary patch literal 82429 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 AtN;K2 literal 0 HcmV?d00001 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..ee97edf0 --- /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/Beginners', +} + +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..32d76c1f --- /dev/null +++ b/src/stories/transition/point-transition.ts @@ -0,0 +1,142 @@ +/** + * 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, with a color-clustered scatter reveal on start. + */ + +import { Graph, defaultConfigValues, TransitionEasing } from '@cosmos.gl/graph' + +import './transition.css' +import { loadPointData } from './point-data' +import { + createColorClusteredScatterPositions, + 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 scatterPositions = createColorClusteredScatterPositions( + cols, + rows, + spaceSize, + pictureColors + ) + + 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', + }) + + // First render: snap the color-clustered scatter — the picture pre-disassembled + // into palette blobs. Second render: queue the picture and let the transition + // interpolate scatter → picture; fitView frames the target. + graph.setPointPositions(scatterPositions) + graph.setPointColors(pictureColors) + graph.render() + + graph.setPointPositions(picturePositions) + requestAnimationFrame(() => { + 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..ed54296d --- /dev/null +++ b/src/stories/transition/transition-helpers.ts @@ -0,0 +1,165 @@ +/** 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) +} + +/** + * Pre-reveal scatter where points are clustered by color into blobs + * placed at random spots across the space. + * + * For each point we quantize its RGB into a coarse bucket; each bucket + * gets a deterministic (bucket-seeded) center and radius; the point + * lands somewhere inside that disk. The result is a field of colored + * patches — the picture's palette disassembled across space — that then + * reassembles into the image on the transition. + */ +export function createColorClusteredScatterPositions ( + cols: number, + rows: number, + spaceSize: number, + colors: Float32Array +): Float32Array { + const total = cols * rows + const out = new Float32Array(total * 2) + + // 4 levels/channel → up to 64 color buckets. Coarse enough that + // visually-similar pixels land in the same blob. + const levels = 4 + const quantize = (v: number): number => + Math.min(levels - 1, Math.max(0, Math.floor(v * levels))) + const bucketKey = (i: number): number => { + const r = colors[i * 4] ?? 0 + const g = colors[i * 4 + 1] ?? 0 + const b = colors[i * 4 + 2] ?? 0 + return quantize(r) * levels * levels + quantize(g) * levels + quantize(b) + } + + // Blob radius relative to the space — big enough that neighbors blend, + // small enough to read as distinct patches. + const blobRadius = spaceSize * 0.08 + const margin = blobRadius + const innerSize = spaceSize - margin * 2 + + const blobs = new Map() + const getBlob = (key: number): { cx: number; cy: number } => { + let blob = blobs.get(key) + if (!blob) { + blob = { + cx: margin + hash01(key + 1) * innerSize, + cy: margin + hash01((key + 1) * 97) * innerSize, + } + blobs.set(key, blob) + } + return blob + } + + for (let i = 0; i < total; i += 1) { + const { cx, cy } = getBlob(bucketKey(i)) + const angle = hash01(i + 12345) * Math.PI * 2 + // sqrt for uniform disk sampling, so points don't pile up at the center. + const dist = Math.sqrt(hash01(i + 67890)) * blobRadius + out[i * 2] = cx + Math.cos(angle) * dist + out[i * 2 + 1] = cy + Math.sin(angle) * dist + } + + return out +} + +/** 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; +} From 6b540da32aeac72ce75655ba1f43473f35fc0928 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Thu, 23 Apr 2026 10:37:05 +0500 Subject: [PATCH 03/25] docs(history): add history guidelines and /history Claude Code skill - Add history/ folder with README, LLM prompt, and first entry covering GPU transitions and runtime simulation toggle (commits e2af399, a9272fd) - Add /history skill to auto-draft entries from git context - Update CONTRIBUTING.md to point contributors to history/ for non-trivial changes Signed-off-by: Stukova Olya --- .claude/skills/history/SKILL.md | 19 +++++ CONTRIBUTING.md | 2 + history/2026/2026-04-22-gpu-transitions.md | 86 ++++++++++++++++++++++ history/PROMPT.md | 47 ++++++++++++ history/README.md | 41 +++++++++++ 5 files changed, 195 insertions(+) create mode 100644 .claude/skills/history/SKILL.md create mode 100644 history/2026/2026-04-22-gpu-transitions.md create mode 100644 history/PROMPT.md create mode 100644 history/README.md diff --git a/.claude/skills/history/SKILL.md b/.claude/skills/history/SKILL.md new file mode 100644 index 00000000..ed78e8a1 --- /dev/null +++ b/.claude/skills/history/SKILL.md @@ -0,0 +1,19 @@ +--- +name: history +description: Draft and save a history entry for a recent change in this repo. Use after a meaningful commit or set of commits. Provide the "why" as an argument — /history the reason this change was made. +allowed-tools: Bash(git *) Bash(date *) Bash(mkdir *) Read Glob Write +--- + +Draft a history entry and save it to `history/` following this repo's conventions. + +## Steps + +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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6aac0481..b1b9b875 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 `/history ` in Claude Code to let it gather context and write the file automatically. ## Pull Requests We actively welcome pull requests. If you want to submit one, please follow the following process: 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..745180c3 --- /dev/null +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -0,0 +1,86 @@ + + +# GPU transitions for positions and attributes + +**Commits:** e2af399, a9272fd + +## 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.** When `render()` sees a pending 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. + +**`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. +- `true → false`: stops the simulation, destroys simulation-only modules and GPU resources, fires `onSimulationEnd`. Any active transition keeps playing — its state is untouched. + +## 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. + +| `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | +| `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, transition) | Animate. **Simulation auto-pauses**; `onSimulationPause` fires. | `→ false`: stops simulation, destroys simulation-only resources, fires `onSimulationEnd`. | `→ ≤0`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | + +## 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. + +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 / Beginners → 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). 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..bd1c6bf4 --- /dev/null +++ b/history/README.md @@ -0,0 +1,41 @@ +# 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). + +See recent files under `history/` for examples — they're the real source of truth on style. From 75b1a1508a2287b5fd94c49eaac3af76ad837547 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Thu, 23 Apr 2026 15:59:07 +0500 Subject: [PATCH 04/25] fix(transitions): only pause simulation for position transitions Color/size transitions don't compete with force updates, so they shouldn't pause the simulation. Add Transition.isPendingFor(property) and gate the simulation pause on a pending Positions transition only. Also tidy the point-transition story: drop the color-clustered scatter reveal (and its helper), snap directly to the picture layout, and move it under Examples/Transitions. Set transitionDuration: 0 on the remove-points and moscow-metro-stations stories to avoid unintended load-time animation. Signed-off-by: Stukova Olya --- src/index.ts | 9 ++- src/modules/Transition/index.ts | 5 ++ src/stories/beginners/remove-points/config.ts | 1 + .../geospatial/moscow-metro-stations/index.ts | 1 + .../transition/point-transition.stories.ts | 2 +- src/stories/transition/point-transition.ts | 24 ++----- src/stories/transition/transition-helpers.ts | 62 ------------------- 7 files changed, 16 insertions(+), 88 deletions(-) diff --git a/src/index.ts b/src/index.ts index a04e7b34..0ed3a353 100644 --- a/src/index.ts +++ b/src/index.ts @@ -728,11 +728,10 @@ export class Graph { // Update graph and start frames this.update(simulationAlpha) - // Animated transitions (duration > 0) should not compete with live force updates. - // If a transition is pending, simulation is running, and this is not the first render - // after init, pause before the transition cycle starts. The first-render skip avoids - // treating the default transitionDuration as an intentional animation on load. - if (this.transition.isPending && + // Position transitions must not compete with live force updates — pause the simulation + // so physics and GPU interpolation don't fight over the same coordinates. + // 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) { diff --git a/src/modules/Transition/index.ts b/src/modules/Transition/index.ts index daaff3c9..0b4ad5be 100644 --- a/src/modules/Transition/index.ts +++ b/src/modules/Transition/index.ts @@ -80,6 +80,11 @@ export class Transition { 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) 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/point-transition.stories.ts b/src/stories/transition/point-transition.stories.ts index ee97edf0..f2087922 100644 --- a/src/stories/transition/point-transition.stories.ts +++ b/src/stories/transition/point-transition.stories.ts @@ -14,7 +14,7 @@ import pointDataRaw from './point-data?raw' import transitionHelpersRaw from './transition-helpers?raw' const meta: Meta = { - title: 'Examples/Beginners', + title: 'Examples/Transitions', } export const PointTransition: Story = { diff --git a/src/stories/transition/point-transition.ts b/src/stories/transition/point-transition.ts index 32d76c1f..bd813ff8 100644 --- a/src/stories/transition/point-transition.ts +++ b/src/stories/transition/point-transition.ts @@ -1,7 +1,6 @@ /** * 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, with a color-clustered scatter reveal on start. + * from a painting auto-loops between the picture layout and a sequence of tile scatters. */ import { Graph, defaultConfigValues, TransitionEasing } from '@cosmos.gl/graph' @@ -9,7 +8,6 @@ import { Graph, defaultConfigValues, TransitionEasing } from '@cosmos.gl/graph' import './transition.css' import { loadPointData } from './point-data' import { - createColorClusteredScatterPositions, createPicturePositions, createTileScatterPositions, } from './transition-helpers' @@ -38,12 +36,6 @@ export const pointTransition = async (): Promise<{ let loopIntervalId: ReturnType | undefined const picturePositions = createPicturePositions(cols, rows, spaceSize, aspect) - const scatterPositions = createColorClusteredScatterPositions( - cols, - rows, - spaceSize, - pictureColors - ) const div = document.createElement('div') div.className = 'app' @@ -103,19 +95,11 @@ export const pointTransition = async (): Promise<{ 'visualized with Cosmograph', }) - // First render: snap the color-clustered scatter — the picture pre-disassembled - // into palette blobs. Second render: queue the picture and let the transition - // interpolate scatter → picture; fitView frames the target. - graph.setPointPositions(scatterPositions) + graph.setPointPositions(picturePositions) graph.setPointColors(pictureColors) graph.render() - - graph.setPointPositions(picturePositions) - requestAnimationFrame(() => { - graph.render() - graph.fitView() - startLoop() - }) + graph.fitView() + startLoop() fitViewAction.addEventListener('click', () => { graph.fitView() diff --git a/src/stories/transition/transition-helpers.ts b/src/stories/transition/transition-helpers.ts index ed54296d..46b5eba0 100644 --- a/src/stories/transition/transition-helpers.ts +++ b/src/stories/transition/transition-helpers.ts @@ -50,68 +50,6 @@ function hash01 (key: number): number { return x - Math.floor(x) } -/** - * Pre-reveal scatter where points are clustered by color into blobs - * placed at random spots across the space. - * - * For each point we quantize its RGB into a coarse bucket; each bucket - * gets a deterministic (bucket-seeded) center and radius; the point - * lands somewhere inside that disk. The result is a field of colored - * patches — the picture's palette disassembled across space — that then - * reassembles into the image on the transition. - */ -export function createColorClusteredScatterPositions ( - cols: number, - rows: number, - spaceSize: number, - colors: Float32Array -): Float32Array { - const total = cols * rows - const out = new Float32Array(total * 2) - - // 4 levels/channel → up to 64 color buckets. Coarse enough that - // visually-similar pixels land in the same blob. - const levels = 4 - const quantize = (v: number): number => - Math.min(levels - 1, Math.max(0, Math.floor(v * levels))) - const bucketKey = (i: number): number => { - const r = colors[i * 4] ?? 0 - const g = colors[i * 4 + 1] ?? 0 - const b = colors[i * 4 + 2] ?? 0 - return quantize(r) * levels * levels + quantize(g) * levels + quantize(b) - } - - // Blob radius relative to the space — big enough that neighbors blend, - // small enough to read as distinct patches. - const blobRadius = spaceSize * 0.08 - const margin = blobRadius - const innerSize = spaceSize - margin * 2 - - const blobs = new Map() - const getBlob = (key: number): { cx: number; cy: number } => { - let blob = blobs.get(key) - if (!blob) { - blob = { - cx: margin + hash01(key + 1) * innerSize, - cy: margin + hash01((key + 1) * 97) * innerSize, - } - blobs.set(key, blob) - } - return blob - } - - for (let i = 0; i < total; i += 1) { - const { cx, cy } = getBlob(bucketKey(i)) - const angle = hash01(i + 12345) * Math.PI * 2 - // sqrt for uniform disk sampling, so points don't pile up at the center. - const dist = Math.sqrt(hash01(i + 67890)) * blobRadius - out[i * 2] = cx + Math.cos(angle) * dist - out[i * 2 + 1] = cy + Math.sin(angle) * dist - } - - return out -} - /** Builds an n×n tile scatter by rigidly shifting each tile block. */ export function createTileScatterPositions ( cols: number, From 74e0567b2c7d5a749cc1715acd15ac8e2552a8db Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 27 Apr 2026 17:46:49 +0500 Subject: [PATCH 05/25] fix(transitions): invalidate caches, gate hover, drop dead buffer aliases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Invalidate position/centroid caches after interpolatePosition so trackedPositions and centroid getters don't return stale data during a position transition with simulation inactive - Skip hover detection while transitions are active (hover shaders read target buffers and would mismatch the visible interpolated geometry) - Guard onSimulationEnd in applyEnableSimulationConfigChange against spurious firing when disabling a never-running simulation - Drop dead colorBuffer/sizeBuffer aliases in Points and colorBuffer/ widthBuffer in Lines; Lines init allocations were leaking on first update - Match Framebuffer→Texture destroy order in resize branches with destroy() - Replace zero-fill fallbacks in Points updateColor/updateSize with \`as Float32Array\` + invariant comment - Document that setPointPositions intentionally keeps simulation paused Signed-off-by: Stukova Olya --- src/config.ts | 3 ++ src/index.ts | 18 ++++++++++- src/modules/Lines/index.ts | 24 ++------------ src/modules/Points/index.ts | 63 ++++++++++++++++++------------------- 4 files changed, 53 insertions(+), 55 deletions(-) diff --git a/src/config.ts b/src/config.ts index 49e9f49d..d0dcb100 100644 --- a/src/config.ts +++ b/src/config.ts @@ -16,6 +16,9 @@ export interface GraphConfigInterface { /** * 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; /** diff --git a/src/index.ts b/src/index.ts index 0ed3a353..94a77785 100644 --- a/src/index.ts +++ b/src/index.ts @@ -367,6 +367,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 @@ -730,6 +732,9 @@ export class Graph { // 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 && @@ -1220,6 +1225,9 @@ export class Graph { if (!this.config.enableSimulation) return if (this.store.isSimulationRunning) return if (this.transition.isActive) { + // Interrupt any active transition. If it includes positions, leave the + // current position texture at the last interpolated frame so simulation + // resumes from the same coordinates currently shown on screen. this.transition.end(true) } this.store.isSimulationRunning = true @@ -1530,6 +1538,9 @@ export class Graph { if (prevConfig.enableSimulation === this.config.enableSimulation) return if (this.config.enableSimulation) { + // Interrupt any active transition. If it includes positions, leave the + // current position texture at the last interpolated frame so simulation + // resumes from the same coordinates currently shown on screen. this.transition.end(true) this.ensureSimulationModules() this.points?.ensureSimulationResources() @@ -1547,11 +1558,12 @@ export class Graph { 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 - this.config.onSimulationEnd?.() + if (wasSimulationActive) this.config.onSimulationEnd?.() this.destroySimulationModules() } @@ -1999,6 +2011,10 @@ export class Graph { private findHoveredItem (): void { if (this._isDestroyed || !this._isMouseOnCanvas) return + // Skip hover detection while a transition is animating — point sizes and link widths + // are interpolated in the draw shaders but the hover shaders read the target buffers + // directly, so the hit area would mismatch the visible geometry. + if (this.transition.isActive) return if (this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { this._findHoveredItemExecutionCount += 1 return diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index 1cf0acd2..e19ce356 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -26,11 +26,9 @@ export class Lines extends CoreModule { private fillSampledLinksFboCommand: Model | undefined private pointABuffer: Buffer | undefined private pointBBuffer: Buffer | undefined - private colorBuffer: Buffer | undefined private sourceColorBuffer: Buffer | undefined private targetColorBuffer: Buffer | undefined private previousColorData: Float32Array | undefined - private widthBuffer: Buffer | undefined private sourceWidthBuffer: Buffer | undefined private targetWidthBuffer: Buffer | undefined private previousWidthData: Float32Array | undefined @@ -135,14 +133,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, @@ -386,8 +376,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 @@ -614,7 +604,6 @@ export class Lines extends CoreModule { this.sourceColorBuffer = source this.targetColorBuffer = target this.previousColorData = previous - this.colorBuffer = target if (this.drawCurveCommand) { this.drawCurveCommand.setAttributes({ @@ -638,7 +627,6 @@ export class Lines extends CoreModule { this.sourceWidthBuffer = source this.targetWidthBuffer = target this.previousWidthData = previous - this.widthBuffer = target if (this.drawCurveCommand) { this.drawCurveCommand.setAttributes({ @@ -1023,10 +1011,6 @@ export class Lines extends CoreModule { this.pointBBuffer.destroy() } this.pointBBuffer = undefined - if (this.colorBuffer && !this.colorBuffer.destroyed) { - this.colorBuffer.destroy() - } - this.colorBuffer = undefined if (this.sourceColorBuffer && !this.sourceColorBuffer.destroyed) { this.sourceColorBuffer.destroy() } @@ -1036,10 +1020,6 @@ export class Lines extends CoreModule { } this.targetColorBuffer = undefined this.previousColorData = undefined - if (this.widthBuffer && !this.widthBuffer.destroyed) { - this.widthBuffer.destroy() - } - this.widthBuffer = undefined if (this.sourceWidthBuffer && !this.sourceWidthBuffer.destroyed) { this.sourceWidthBuffer.destroy() } diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 1e9459d2..604ff802 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -56,11 +56,9 @@ export class Points extends CoreModule { * Used together with `Clusters.cachedCentroidPositions` to skip redundant GPU readbacks. */ public areClusterCentroidsUpToDate = false - private colorBuffer: Buffer | undefined private sourceColorBuffer: Buffer | undefined private targetColorBuffer: Buffer | undefined private previousColorData: Float32Array | undefined - private sizeBuffer: Buffer | undefined private sourceSizeBuffer: Buffer | undefined private targetSizeBuffer: Buffer | undefined private previousSizeData: Float32Array | undefined @@ -72,6 +70,12 @@ export class Points extends CoreModule { 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 @@ -427,8 +431,8 @@ 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() @@ -745,7 +749,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: [ @@ -941,7 +945,8 @@ export class Points extends CoreModule { const { store: { pointsTextureSize }, data } = this if (!pointsTextureSize) return - const colorData = data.pointColors ?? new Float32Array((data.pointsNumber ?? 0) * 4).fill(0) + // GraphData.updatePointColor() always populates pointColors before this runs + const colorData = data.pointColors as Float32Array const { source, target, previous } = this.updateAttributeBuffers( colorData, this.sourceColorBuffer, @@ -952,7 +957,6 @@ export class Points extends CoreModule { this.sourceColorBuffer = source this.targetColorBuffer = target this.previousColorData = previous - this.colorBuffer = target if (this.drawCommand) { this.drawCommand.setAttributes({ @@ -1060,7 +1064,8 @@ export class Points extends CoreModule { const { device, store: { pointsTextureSize }, data } = this if (!pointsTextureSize || data.pointsNumber === undefined) return - const sizeData = data.pointSizes ?? new Float32Array(data.pointsNumber).fill(0) + // GraphData.updatePointSize() always populates pointSizes before this runs + const sizeData = data.pointSizes as Float32Array const { source, target, previous } = this.updateAttributeBuffers( sizeData, this.sourceSizeBuffer, @@ -1071,7 +1076,6 @@ export class Points extends CoreModule { this.sourceSizeBuffer = source this.targetSizeBuffer = target this.previousSizeData = previous - this.sizeBuffer = target if (this.drawCommand) { this.drawCommand.setAttributes({ @@ -1319,8 +1323,8 @@ export class Points extends CoreModule { 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() @@ -1698,7 +1702,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 }), }) @@ -1980,9 +1984,9 @@ 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() @@ -2121,10 +2125,6 @@ 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() - } - this.colorBuffer = undefined if (this.sourceColorBuffer && !this.sourceColorBuffer.destroyed) { this.sourceColorBuffer.destroy() } @@ -2134,10 +2134,6 @@ export class Points extends CoreModule { } this.targetColorBuffer = undefined this.previousColorData = undefined - if (this.sizeBuffer && !this.sizeBuffer.destroyed) { - this.sizeBuffer.destroy() - } - this.sizeBuffer = undefined if (this.sourceSizeBuffer && !this.sourceSizeBuffer.destroyed) { this.sourceSizeBuffer.destroy() } @@ -2208,12 +2204,12 @@ export class Points extends CoreModule { 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() } + if (this.velocityTexture && !this.velocityTexture.destroyed) { + this.velocityTexture.destroy() + } this.velocityTexture = device.createTexture({ width: pointsTextureSize, height: pointsTextureSize, @@ -2250,12 +2246,12 @@ export class Points extends CoreModule { 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.sourcePositionTexture && !this.sourcePositionTexture.destroyed) { - this.sourcePositionTexture.destroy() - } 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, @@ -2277,12 +2273,12 @@ export class Points extends CoreModule { }) if (!this.targetPositionTexture || this.targetPositionTexture.width !== pointsTextureSize || this.targetPositionTexture.height !== pointsTextureSize) { - if (this.targetPositionTexture && !this.targetPositionTexture.destroyed) { - this.targetPositionTexture.destroy() - } 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, @@ -2386,6 +2382,9 @@ export class Points extends CoreModule { }) this.interpolatePositionCommand.draw(renderPass) renderPass.end() + + this.isPositionsUpToDate = false + this.areClusterCentroidsUpToDate = false } public destroySimulationResources (): void { From c9d3f6a1331172aad40d5a69ca96beead7d484fc Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 27 Apr 2026 20:22:19 +0500 Subject: [PATCH 06/25] docs(history): refresh gpu-transitions entry, add /history --update - Update 2026-04-22-gpu-transitions.md to reflect post-merge state and follow-up fixes (c5fd30e, 75b1a15, 74e0567): auto-pause is now position-only, hover skipped during transitions, position/centroid caches invalidated, behavior matrix split into position vs color/size rows, Storybook path fixed - Add --update mode to the /history skill so follow-up commits extending an existing topic revise the latest entry in place without re-asking for a "why" - Mention /history --update in CONTRIBUTING.md and history/README.md Signed-off-by: Stukova Olya --- .claude/skills/history/SKILL.md | 23 +++++++++++++++++++--- CONTRIBUTING.md | 2 +- history/2026/2026-04-22-gpu-transitions.md | 17 ++++++++++------ history/README.md | 1 + 4 files changed, 33 insertions(+), 10 deletions(-) diff --git a/.claude/skills/history/SKILL.md b/.claude/skills/history/SKILL.md index ed78e8a1..9a3f311b 100644 --- a/.claude/skills/history/SKILL.md +++ b/.claude/skills/history/SKILL.md @@ -1,12 +1,14 @@ --- name: history -description: Draft and save a history entry for a recent change in this repo. Use after a meaningful commit or set of commits. Provide the "why" as an argument — /history the reason this change was made. -allowed-tools: Bash(git *) Bash(date *) Bash(mkdir *) Read Glob Write +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. -## Steps +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. @@ -17,3 +19,18 @@ Draft a history entry and save it to `history/` following this repo's convention 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). + - 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 b1b9b875..02449d7d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ what actions will and will not be tolerated. 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 `/history ` in Claude Code to let it gather context and write the file automatically. +Use `history/README.md` for guidance, `history/PROMPT.md` to draft it with any LLM, or run `/history ` in 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/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md index 745180c3..a4f195c1 100644 --- a/history/2026/2026-04-22-gpu-transitions.md +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -2,7 +2,7 @@ # GPU transitions for positions and attributes -**Commits:** e2af399, a9272fd +**Commits:** c5fd30e, 75b1a15, 74e0567 ## Why @@ -27,7 +27,11 @@ 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.** When `render()` sees a pending 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. +**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 during transitions.** Hover detection is skipped while any transition is active. Hover shaders read the target buffers, which would mismatch the interpolated geometry currently on screen. + +**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. @@ -49,18 +53,19 @@ All rows assume a setter ran (e.g. `setPointPositions`) so a transition is **pen ### 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. +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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | | `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, transition) | Animate. **Simulation auto-pauses**; `onSimulationPause` fires. | `→ false`: stops simulation, destroys simulation-only resources, fires `onSimulationEnd`. | `→ ≤0`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | +| `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | +| `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | ## 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. +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: @@ -79,7 +84,7 @@ graph.setConfigPartial({ transitionDuration: 800 }) // restore if needed ## Example -`src/stories/transition/` (Storybook: **Examples / Beginners → 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. +`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 diff --git a/history/README.md b/history/README.md index bd1c6bf4..8d5f8093 100644 --- a/history/README.md +++ b/history/README.md @@ -37,5 +37,6 @@ 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. From b775c4c0c5ec16f91ea4b31a8f4df3dfe680daaf Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 12:17:26 +0500 Subject: [PATCH 07/25] fix(transitions): guard destroyed position textures - src/index.ts: skip pending position transition queue/abort when currentPositionTexture is destroyed. - src/modules/{Lines,Points}/index.ts: add TODO for rare mid-animation attribute-update smoothing edge case. Signed-off-by: Stukova Olya --- src/index.ts | 6 ++++-- src/modules/Lines/index.ts | 1 + src/modules/Points/index.ts | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/index.ts b/src/index.ts index 94a77785..c01e3f02 100644 --- a/src/index.ts +++ b/src/index.ts @@ -377,7 +377,8 @@ export class Graph { this.graph.inputPointPositions = pointPositions this.points!.shouldSkipRescale = dontRescale this.isPointPositionsUpdateNeeded = true - if (this.points?.currentPositionTexture) { + 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 @@ -744,7 +745,8 @@ export class Graph { this.config.onSimulationPause?.() } - if (this.transition.isPending && !this.points?.currentPositionTexture) { + const currentPositionTexture = this.points?.currentPositionTexture + if (this.transition.isPending && (!currentPositionTexture || currentPositionTexture.destroyed)) { this.transition.abort() } diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index e19ce356..c66e67eb 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -1075,6 +1075,7 @@ export class Lines extends CoreModule { 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) { diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 604ff802..24dceb45 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -2436,6 +2436,7 @@ export class Points extends CoreModule { 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) { From 45a12f41e8f4566cce3cc2b89652fea8cf32efe7 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 19:14:49 +0500 Subject: [PATCH 08/25] fix(transitions): gate shouldAnimate on isPendingFor(Positions) Signed-off-by: Stukova Olya --- src/modules/Points/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 24dceb45..4a15c1ac 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -26,7 +26,7 @@ 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 } from '@/graph/modules/Transition' +import { Transition, TransitionProperty } from '@/graph/modules/Transition' export class Points extends CoreModule { public transition: Transition | undefined @@ -266,7 +266,7 @@ export class Points extends CoreModule { const targetCount = data.targetPointsNumber const sameCount = sourceCount === targetCount const shouldAnimate = - this.transition?.isPending === true && + this.transition?.isPendingFor(TransitionProperty.Positions) === true && this.config.transitionDuration > 0 && !!this.currentPositionTexture From a82639871288f00ae1d10183fa9f00e77c468e41 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 19:25:53 +0500 Subject: [PATCH 09/25] docs(history): add Codex /history skill and update related docs Signed-off-by: Stukova Olya --- .agents/skills/history/SKILL.md | 36 ++++++++++++++++++++++ CONTRIBUTING.md | 2 +- history/2026/2026-04-22-gpu-transitions.md | 3 +- 3 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 .agents/skills/history/SKILL.md diff --git a/.agents/skills/history/SKILL.md b/.agents/skills/history/SKILL.md new file mode 100644 index 00000000..9a3f311b --- /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). + - 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 02449d7d..59f25e17 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ what actions will and will not be tolerated. 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 `/history ` in 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. +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/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md index a4f195c1..76e3f17d 100644 --- a/history/2026/2026-04-22-gpu-transitions.md +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -2,7 +2,7 @@ # GPU transitions for positions and attributes -**Commits:** c5fd30e, 75b1a15, 74e0567 +**Commits:** c5fd30e, 75b1a15, 74e0567, b775c4c, 45a12f4 ## Why @@ -89,3 +89,4 @@ graph.setConfigPartial({ transitionDuration: 800 }) // restore if needed ## 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. From bcbbdcbfa1b83fe28ff600ffbf5540f892f362c0 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 20:05:39 +0500 Subject: [PATCH 10/25] docs(history): describe transitionDuration<=0 snap behavior more precisely Signed-off-by: Stukova Olya --- history/2026/2026-04-22-gpu-transitions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md index 76e3f17d..6b7cf446 100644 --- a/history/2026/2026-04-22-gpu-transitions.md +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -58,10 +58,10 @@ Each row is the state when `render()` fires. The last two columns show what happ | `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | +| `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | -| `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`: next `start()` snaps. A running transition ends with `onTransitionEnd(true)` on the next step. | +| `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 From 579801df550f35217c380f7ecb33ef9e092d49c0 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 21:34:41 +0500 Subject: [PATCH 11/25] fix(simulation): restore start() reheating semantics Bring back repeated start(alpha) calls as a reheat mechanism while keeping onSimulationStart limited to stopped/paused -> running. Signed-off-by: Stukova Olya --- src/index.ts | 9 +++++---- src/stories/3. api-reference.mdx | 2 ++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index c01e3f02..487fad71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1171,6 +1171,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 { @@ -1180,13 +1183,11 @@ export class Graph { if (!this.config.enableSimulation) return if (!this.graph.pointsNumber) return - if (this.store.isSimulationRunning) return - - // Ignore repeated start() calls while simulation is already running. + 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 } diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx index 3a72e381..e88101fd 100644 --- a/src/stories/3. api-reference.mdx +++ b/src/stories/3. api-reference.mdx @@ -618,6 +618,8 @@ 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. + ### # 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. From 5006eeac0d92074d1bf8569bb2ee8ab247b88e23 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Tue, 28 Apr 2026 22:14:53 +0500 Subject: [PATCH 12/25] fix(interactions): narrow transition hover and drag guards Only block hover for point size transitions. Block drag start for position and point size transitions, and update docs to match the current behavior. Signed-off-by: Stukova Olya --- history/2026/2026-04-22-gpu-transitions.md | 2 +- src/index.ts | 7 +++---- src/modules/Drag/index.ts | 11 +++++++++-- src/stories/2. configuration.mdx | 2 +- src/stories/3. api-reference.mdx | 2 +- 5 files changed, 15 insertions(+), 9 deletions(-) diff --git a/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md index 6b7cf446..65d0c441 100644 --- a/history/2026/2026-04-22-gpu-transitions.md +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -29,7 +29,7 @@ onTransitionEnd?: (interrupted: boolean) => void **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 during transitions.** Hover detection is skipped while any transition is active. Hover shaders read the target buffers, which would mismatch the interpolated geometry currently on screen. +**Hover during transitions.** Hover detection is skipped only during point size transitions. Point hover picking still 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. **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. diff --git a/src/index.ts b/src/index.ts index 487fad71..4cb07508 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2014,10 +2014,9 @@ export class Graph { private findHoveredItem (): void { if (this._isDestroyed || !this._isMouseOnCanvas) return - // Skip hover detection while a transition is animating — point sizes and link widths - // are interpolated in the draw shaders but the hover shaders read the target buffers - // directly, so the hit area would mismatch the visible geometry. - if (this.transition.isActive) return + // TODO: Hover can stay enabled during point size transitions once point picking + // consumes the same interpolated point sizes as the draw pass. + if (this.transition.isActiveFor(TransitionProperty.PointSizes)) return if (this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { this._findHoveredItemExecutionCount += 1 return diff --git a/src/modules/Drag/index.ts b/src/modules/Drag/index.ts index a5b3139e..b8fa3313 100644 --- a/src/modules/Drag/index.ts +++ b/src/modules/Drag/index.ts @@ -1,7 +1,7 @@ import { drag } from 'd3-drag' import { Store } from '@/graph/modules/Store' import { type GraphConfigInterface } from '@/graph/config' -import { Transition } from '@/graph/modules/Transition' +import { Transition, TransitionProperty } from '@/graph/modules/Transition' export class Drag { public readonly store: Store @@ -10,7 +10,14 @@ export class Drag { public isActive = false public behavior = drag() .subject((event) => { - if (this.transition.isActive) return undefined + // 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) => { diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index 1f2ecc9a..781ff217 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -142,7 +142,7 @@ A single transition cycle tracks every animated property in one shared timeline, **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 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 for the duration of the transition — you can resume it with `unpause()` at any time, which interrupts the transition (`onTransitionEnd(true)` fires). +**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()`, which interrupts any still-active transition (`onTransitionEnd(true)` fires). **`fitView` during a transition.** `fitView()` and `fitViewByPointIndices()` frame the target positions (the latest `setPointPositions` argument), not the interpolated positions currently on screen. diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx index e88101fd..27cb818e 100644 --- a/src/stories/3. api-reference.mdx +++ b/src/stories/3. api-reference.mdx @@ -397,7 +397,7 @@ 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, it auto-pauses for the duration of the transition (and `onSimulationPause` fires). 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. +**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]) From fed8dcd27e621b259f53c236523126031e2bbe9c Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Apr 2026 14:22:05 +0500 Subject: [PATCH 13/25] fix(transitions): tighten simulation/transition guards start() and unpause() now end transitions only when positions are animating. Enabling simulation also drops any queued position transition to prevent immediate auto-pause on next render(). Signed-off-by: Stukova Olya --- src/index.ts | 15 ++++++++------- src/modules/Transition/index.ts | 7 +++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4cb07508..82e30bbb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1183,6 +1183,10 @@ export class Graph { if (!this.config.enableSimulation) return if (!this.graph.pointsNumber) return + 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 @@ -1227,10 +1231,8 @@ export class Graph { if (this.ensureDevice(() => this.unpause())) return if (!this.config.enableSimulation) return if (this.store.isSimulationRunning) return - if (this.transition.isActive) { - // Interrupt any active transition. If it includes positions, leave the - // current position texture at the last interpolated frame so simulation - // resumes from the same coordinates currently shown on screen. + if (this.transition.isActiveFor(TransitionProperty.Positions)) { + // Avoids running simulation against mid-interpolation positions. this.transition.end(true) } this.store.isSimulationRunning = true @@ -1541,10 +1543,9 @@ export class Graph { if (prevConfig.enableSimulation === this.config.enableSimulation) return if (this.config.enableSimulation) { - // Interrupt any active transition. If it includes positions, leave the - // current position texture at the last interpolated frame so simulation - // resumes from the same coordinates currently shown on screen. + // Avoids running simulation against mid-interpolation positions. this.transition.end(true) + this.transition.dequeue(TransitionProperty.Positions) this.ensureSimulationModules() this.points?.ensureSimulationResources() this.isForceManyBodyUpdateNeeded = true diff --git a/src/modules/Transition/index.ts b/src/modules/Transition/index.ts index 0b4ad5be..e9f340ae 100644 --- a/src/modules/Transition/index.ts +++ b/src/modules/Transition/index.ts @@ -95,6 +95,11 @@ export class Transition { 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. * @@ -160,6 +165,8 @@ export class Transition { * * - No active cycle → no-op. * - Otherwise → fire `onTransitionEnd(interrupted)`. + * + * TODO: support per-property end. */ public end (interrupted: boolean): void { if (!this.isActive) return From 6256b66b2d35dd04258dd9c6cbd175025b485e4e Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Apr 2026 15:09:58 +0500 Subject: [PATCH 14/25] fix(transitions): prevent negative progress by sampling time in step() Signed-off-by: Stukova Olya --- src/index.ts | 2 +- src/modules/Transition/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index 82e30bbb..ea29cf8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1809,7 +1809,7 @@ export class Graph { const shouldAnimateLinkColors = this.transition.isActiveFor(TransitionProperty.LinkColors) const shouldAnimateLinkWidths = this.transition.isActiveFor(TransitionProperty.LinkWidths) if (this.transition.isActive) { - this.transition.step(frameNow) + this.transition.step() if (shouldInterpolatePositions) { this.points?.interpolatePosition(this.transition.progress) diff --git a/src/modules/Transition/index.ts b/src/modules/Transition/index.ts index e9f340ae..b561c393 100644 --- a/src/modules/Transition/index.ts +++ b/src/modules/Transition/index.ts @@ -142,7 +142,7 @@ export class Transition { * - Progress < 1 → update `progress`; fire `onTransition(eased)`. * - Progress reaches 1 → fire `onTransition(1)` then `onTransitionEnd(false)`. */ - public step (nowMs: number): void { + public step (): void { if (!this.isActive) return const { transitionDuration } = this.config @@ -152,7 +152,7 @@ export class Transition { return } - const linear = Math.min((nowMs - this.startTime) / transitionDuration, 1) + const linear = Math.min((performance.now() - this.startTime) / transitionDuration, 1) const eased = this.applyEasing(linear) this.progress = eased this.config.onTransition?.(eased) From cd32f595972ffb20a29bac696b52b15068e655e0 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Apr 2026 16:01:44 +0500 Subject: [PATCH 15/25] chore(transitions): minor doc and code tweaks Signed-off-by: Stukova Olya --- migration-notes.md | 2 + src/modules/Lines/index.ts | 62 +++---------------------------- src/modules/Points/index.ts | 63 +++----------------------------- src/modules/Shared/buffer.ts | 58 +++++++++++++++++++++++++++++ src/stories/2. configuration.mdx | 6 +-- src/stories/3. api-reference.mdx | 4 +- 6 files changed, 76 insertions(+), 119 deletions(-) diff --git a/migration-notes.md b/migration-notes.md index e04a0096..698de837 100644 --- a/migration-notes.md +++ b/migration-notes.md @@ -203,6 +203,8 @@ 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]`) diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index c66e67eb..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' @@ -594,7 +595,8 @@ export class Lines extends CoreModule { const { data } = this const linksNumber = data.linksNumber ?? 0 const colorData = data.linkColors ?? new Float32Array(linksNumber * 4).fill(0) - const { source, target, previous } = this.updateAttributeBuffers( + const { source, target, previous } = updateAttributeBuffers( + this.device, colorData, this.sourceColorBuffer, this.targetColorBuffer, @@ -617,7 +619,8 @@ export class Lines extends CoreModule { const { data } = this const linksNumber = data.linksNumber ?? 0 const widthData = data.linkWidths ?? new Float32Array(linksNumber).fill(0) - const { source, target, previous } = this.updateAttributeBuffers( + const { source, target, previous } = updateAttributeBuffers( + this.device, widthData, this.sourceWidthBuffer, this.targetWidthBuffer, @@ -1062,59 +1065,4 @@ export class Lines extends CoreModule { }) this.linkStatusTextureSize = 0 } - - private updateAttributeBuffers ( - 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: this.device.createBuffer({ - data: sourceData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }), - target: this.device.createBuffer({ - data: targetData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }), - previous: new Float32Array(targetData), - } - } } diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 4a15c1ac..ac81d6be 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -17,7 +17,7 @@ import fillGridWithSampledPointsFrag from '@/graph/modules/Points/fill-sampled-p import fillGridWithSampledPointsVert from '@/graph/modules/Points/fill-sampled-points.vert?raw' import updatePositionFrag from '@/graph/modules/Points/update-position.frag?raw' import interpolatePositionFrag from '@/graph/modules/Points/interpolate-position.frag?raw' -import { createIndexesForBuffer } from '@/graph/modules/Shared/buffer' +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' @@ -947,7 +947,8 @@ export class Points extends CoreModule { // GraphData.updatePointColor() always populates pointColors before this runs const colorData = data.pointColors as Float32Array - const { source, target, previous } = this.updateAttributeBuffers( + const { source, target, previous } = updateAttributeBuffers( + this.device, colorData, this.sourceColorBuffer, this.targetColorBuffer, @@ -1066,7 +1067,8 @@ export class Points extends CoreModule { // GraphData.updatePointSize() always populates pointSizes before this runs const sizeData = data.pointSizes as Float32Array - const { source, target, previous } = this.updateAttributeBuffers( + const { source, target, previous } = updateAttributeBuffers( + this.device, sizeData, this.sourceSizeBuffer, this.targetSizeBuffer, @@ -2424,61 +2426,6 @@ export class Points extends CoreModule { this.areClusterCentroidsUpToDate = false } - private updateAttributeBuffers ( - 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] as number) - } - for (let i = sharedCount * tupleSize; i < targetData.length; i += 1) { - sourceData[i] = targetData[i] as number - } - - if (sourceBuffer && !sourceBuffer.destroyed) { - sourceBuffer.destroy() - } - if (targetBuffer && !targetBuffer.destroyed) { - targetBuffer.destroy() - } - - return { - source: this.device.createBuffer({ - data: sourceData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }), - target: this.device.createBuffer({ - data: targetData, - usage: Buffer.VERTEX | Buffer.COPY_DST, - }), - previous: new Float32Array(targetData), - } - } - private createOrUpdatePositionTextures (positionData: Float32Array, pointsTextureSize: number): void { // Create currentPositionTexture and framebuffer if (!this.currentPositionTexture || this.currentPositionTexture.width !== pointsTextureSize || this.currentPositionTexture.height !== pointsTextureSize) { 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/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index 781ff217..ad63e9f1 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -107,7 +107,7 @@ cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/ | 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 by a new one or ended early (e.g. by `unpause()` or by setting `transitionDuration` to `0` mid-flight): `(interrupted: boolean) => void` | +| onTransitionEnd | Called when a transition ends. `interrupted` is `true` when the transition was replaced or ended early (e.g. by setting `transitionDuration` to `0` mid-flight, or toggling `enableSimulation` mid-flight): `(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 | @@ -142,11 +142,11 @@ A single transition cycle tracks every animated property in one shared timeline, **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()`, which interrupts any still-active transition (`onTransitionEnd(true)` fires). +**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. Turning simulation off leaves an active transition untouched. +**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 })`. diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx index 27cb818e..4da22dc8 100644 --- a/src/stories/3. api-reference.mdx +++ b/src/stories/3. api-reference.mdx @@ -620,6 +620,8 @@ Starts the simulation. This method only controls the simulation state, not rende 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. @@ -628,7 +630,7 @@ 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 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. +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() From b8f993ac7c4b37ff840d7e81d08a6236f487111a Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Apr 2026 16:14:13 +0500 Subject: [PATCH 16/25] docs(history): refresh gpu-transitions entry; skip history-only commits Signed-off-by: Stukova Olya --- .agents/skills/history/SKILL.md | 2 +- .claude/skills/history/SKILL.md | 2 +- history/2026/2026-04-22-gpu-transitions.md | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.agents/skills/history/SKILL.md b/.agents/skills/history/SKILL.md index 9a3f311b..ea089c47 100644 --- a/.agents/skills/history/SKILL.md +++ b/.agents/skills/history/SKILL.md @@ -29,7 +29,7 @@ Use this when recent commits extend or correct a topic that already has an entry 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). + - 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. diff --git a/.claude/skills/history/SKILL.md b/.claude/skills/history/SKILL.md index 9a3f311b..ea089c47 100644 --- a/.claude/skills/history/SKILL.md +++ b/.claude/skills/history/SKILL.md @@ -29,7 +29,7 @@ Use this when recent commits extend or correct a topic that already has an entry 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). + - 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. diff --git a/history/2026/2026-04-22-gpu-transitions.md b/history/2026/2026-04-22-gpu-transitions.md index 65d0c441..c6ccdb5c 100644 --- a/history/2026/2026-04-22-gpu-transitions.md +++ b/history/2026/2026-04-22-gpu-transitions.md @@ -2,7 +2,7 @@ # GPU transitions for positions and attributes -**Commits:** c5fd30e, 75b1a15, 74e0567, b775c4c, 45a12f4 +**Commits:** c5fd30e, 75b1a15, 74e0567, b775c4c, 45a12f4, 579801d, 5006eea, fed8dcd, 6256b66, cd32f59 ## Why @@ -29,7 +29,7 @@ onTransitionEnd?: (interrupted: boolean) => void **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 during transitions.** Hover detection is skipped only during point size transitions. Point hover picking still 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. +**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. @@ -44,9 +44,11 @@ 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. +- `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). From 635b6f4853026ce0dc7db80dc35aabec2386fb5b Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 29 Apr 2026 17:16:30 +0500 Subject: [PATCH 17/25] 3.0.0-beta.9 Signed-off-by: Stukova Olya --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 885de0c8..9f78bbfa 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.9", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.8", + "version": "3.0.0-beta.9", "license": "MIT", "dependencies": { "@luma.gl/core": "~9.2.6", diff --git a/package.json b/package.json index 949b73bb..f3960e07 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.9", "description": "GPU-based force graph layout and rendering", "jsdelivr": "dist/index.min.js", "main": "dist/index.js", From bdac75174da73def7af39c56479fdc86fa206e46 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 25 May 2026 17:00:54 +0500 Subject: [PATCH 18/25] fix(transitions): refresh tracked positions during interpolation Signed-off-by: Stukova Olya --- src/index.ts | 1 + src/modules/Points/index.ts | 23 ++++++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index ea29cf8e..43ae3373 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1813,6 +1813,7 @@ export class Graph { if (shouldInterpolatePositions) { this.points?.interpolatePosition(this.transition.progress) + this.points?.trackPoints() } } diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index ac81d6be..edfc8b8e 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -67,6 +67,15 @@ export class Points extends CoreModule { 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 @@ -420,7 +429,9 @@ 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 } @@ -1292,6 +1303,16 @@ 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 + * - `createOrUpdatePositionTextures()` — CPU upload (`setPointPositions`, no-animate) + * + * `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 From f65b746fe8c77224fcfb8f37ba514ad0a04fdb30 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 1 Jun 2026 12:16:20 +0500 Subject: [PATCH 19/25] docs(transitions): clarify onTransitionEnd interrupt sources Signed-off-by: Stukova Olya --- src/stories/2. configuration.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index ad63e9f1..eb158d85 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -107,7 +107,7 @@ cosmos.gl layout algorithm was inspired by the [d3-force](https://github.com/d3/ | 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 (e.g. by setting `transitionDuration` to `0` mid-flight, or toggling `enableSimulation` mid-flight): `(interrupted: boolean) => 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 | From 2ca534867138fb8f2b2f1c57e5a8babace5a8748 Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 1 Jun 2026 19:12:31 +0500 Subject: [PATCH 20/25] fix(transitions): avoid leaking target into current position textures Signed-off-by: Stukova Olya --- src/modules/Points/index.ts | 115 ++++++++++++++++++++++++------------ 1 file changed, 78 insertions(+), 37 deletions(-) diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index edfc8b8e..181be1a2 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -42,12 +42,47 @@ 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. @@ -281,21 +316,14 @@ export class Points extends CoreModule { const targetState = buildPositionTextureData(data.pointPositions, pointsTextureSize, targetCount) - const writePositionTexture = (tex: Texture, positionData: Float32Array): void => { - tex.copyImageData({ - data: positionData, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - } - - // Populate source/target position textures for the transition: - // - same count: GPU-to-GPU copy of current → source (no CPU transfer). - // - count changed: CPU readback of current, carry over shared indices, - // fill new indices from target. - // - no prior frame (first render): source = target (nothing to animate from). + // 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) { @@ -313,23 +341,39 @@ export class Points extends CoreModule { } } else if (this.currentPositionFbo) { const previousPositionPixels = readPixels(device, this.currentPositionFbo as Framebuffer) - const sourceData = buildSourcePositionTextureData( + animatedSourceData = buildSourcePositionTextureData( previousPositionPixels, targetState, Math.min(sourceCount, targetCount), targetCount, pointsTextureSize ) - writePositionTexture(this.sourcePositionTexture, sourceData) + this.writePositionTexture(this.sourcePositionTexture, animatedSourceData, pointsTextureSize) } else { - writePositionTexture(this.sourcePositionTexture, targetState) + this.writePositionTexture(this.sourcePositionTexture, targetState, pointsTextureSize) } - writePositionTexture(this.targetPositionTexture, targetState) + this.writePositionTexture(this.targetPositionTexture, targetState, pointsTextureSize) } } - this.createOrUpdatePositionTextures(targetState, pointsTextureSize) + // 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 @@ -1309,7 +1353,8 @@ export class Points extends CoreModule { * - `updatePosition()` — simulation tick * - `drag()` — pointer drag * - `interpolatePosition()` — each frame of a position transition - * - `createOrUpdatePositionTextures()` — CPU upload (`setPointPositions`, no-animate) + * - `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. */ @@ -2447,8 +2492,11 @@ export class Points extends CoreModule { this.areClusterCentroidsUpToDate = false } - private createOrUpdatePositionTextures (positionData: Float32Array, pointsTextureSize: number): void { - // Create currentPositionTexture and framebuffer + /** + * 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() @@ -2467,15 +2515,7 @@ export class Points extends CoreModule { colorAttachments: [this.currentPositionTexture], }) } - this.currentPositionTexture.copyImageData({ - data: positionData, - bytesPerRow: getBytesPerRow('rgba32float', pointsTextureSize), - mipLevel: 0, - x: 0, - y: 0, - }) - // Create previousPositionTexture and framebuffer if (!this.previousPositionTexture || this.previousPositionTexture.width !== pointsTextureSize || this.previousPositionTexture.height !== pointsTextureSize) { @@ -2496,16 +2536,17 @@ export class Points extends CoreModule { colorAttachments: [this.previousPositionTexture], }) } - this.previousPositionTexture.copyImageData({ - data: positionData, + } + + /** 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, }) - - this.areClusterCentroidsUpToDate = false - this.isPositionsUpToDate = false } private ensureUpdatePositionProgram (): void { From 399b0002c06e91855f1891553b7aefcd7c5f0a6d Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Mon, 1 Jun 2026 19:34:51 +0500 Subject: [PATCH 21/25] 3.0.0-beta.10 Signed-off-by: Stukova Olya --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f78bbfa..51f8a154 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.9", + "version": "3.0.0-beta.10", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.9", + "version": "3.0.0-beta.10", "license": "MIT", "dependencies": { "@luma.gl/core": "~9.2.6", diff --git a/package.json b/package.json index f3960e07..ddc07713 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@cosmos.gl/graph", - "version": "3.0.0-beta.9", + "version": "3.0.0-beta.10", "description": "GPU-based force graph layout and rendering", "jsdelivr": "dist/index.min.js", "main": "dist/index.js", From c4b93e1b5d59a8b5e79aef0cd2ecd4b761d3310b Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 3 Jun 2026 15:24:17 +0500 Subject: [PATCH 22/25] feat(interactions): touch and pen support via pointer events Signed-off-by: Stukova Olya --- src/index.ts | 159 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 140 insertions(+), 19 deletions(-) diff --git a/src/index.ts b/src/index.ts index 43ae3373..ec2bee31 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,10 @@ 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() @@ -45,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 @@ -69,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 */ @@ -161,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 @@ -171,18 +192,37 @@ export class Graph { this.canvasD3Selection = select(this.canvas) this.canvasD3Selection - .on('mouseenter.cosmos', (event) => { - this._isMouseOnCanvas = true + .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 + // 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() + } + } }) - .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 @@ -205,6 +245,42 @@ 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() + }) 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 }) @@ -239,7 +315,7 @@ export class Graph { .call(this.dragInstance.behavior) .call(this.zoomInstance.behavior) .on('click', this.onClick.bind(this)) - .on('mousemove', this.onMouseMove.bind(this)) + .on('pointermove', this.onPointerMove.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, @@ -1264,16 +1340,19 @@ export class Graph { this.isReady = false this.transition.abort() window.clearTimeout(this._fitViewOnInitTimeoutID) + this.cancelLongPress() this.stopFrames() // Remove all event listeners if (this.canvasD3Selection) { this.canvasD3Selection - .on('mouseenter.cosmos', null) - .on('mousemove.cosmos', null) - .on('mouseleave.cosmos', null) + .on('pointerenter.cosmos', null) + .on('pointermove.cosmos', null) + .on('pointerleave.cosmos pointercancel.cosmos', null) + .on('pointerdown.cosmos', null) + .on('pointerup.cosmos', null) .on('click', null) - .on('mousemove', null) + .on('pointermove', null) .on('contextmenu', null) .on('.drag', null) .on('.zoom', null) @@ -1900,6 +1979,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, @@ -1933,10 +2018,13 @@ export class Graph { this.store.screenMousePosition = [mouseX, (this.store.screenSize[1] - mouseY)] } - private onMouseMove (event: MouseEvent): void { + private onPointerMove (event: PointerEvent): void { + // Skip non-primary pointers (e.g. second finger of a pinch) so callbacks + // and mouse-position state stay tied to a single pointer per gesture. + if (!event.isPrimary) return this.currentEvent = event this.updateMousePosition(event) - this.isRightClickMouse = event.which === 3 + this.isRightClickMouse = (event.buttons & 2) !== 0 this.config.onMouseMove?.( this.store.hoveredPoint?.index, this.store.hoveredPoint?.position, @@ -1946,7 +2034,27 @@ export class Graph { 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, @@ -2012,14 +2120,27 @@ export class Graph { ?.call(this.zoomInstance.behavior) .on('wheel.zoom', null) } + + this.updateCanvasTouchAction() + } + + /** + * 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 (): void { - if (this._isDestroyed || !this._isMouseOnCanvas) return + 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 (this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { + if (!immediate && this._findHoveredItemExecutionCount < MAX_HOVER_DETECTION_DELAY) { this._findHoveredItemExecutionCount += 1 return } @@ -2030,7 +2151,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 } From 95dd97ecd396055c520f2c99ad7addf5284c110a Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 3 Jun 2026 15:26:11 +0500 Subject: [PATCH 23/25] docs(history): add touch-input entry Signed-off-by: Stukova Olya --- history/2026/2026-06-03-touch-input.md | 136 +++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 history/2026/2026-06-03-touch-input.md 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. From 04eaf8c9ffc1853442d29e51226dbfb0e230602a Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 3 Jun 2026 16:26:06 +0500 Subject: [PATCH 24/25] fix(interactions): clear right-click flag on pointerup Signed-off-by: Stukova Olya --- src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/index.ts b/src/index.ts index ec2bee31..48ede5bb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -280,6 +280,9 @@ export class Graph { 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 }) select(document) .on('keydown.cosmos', (event) => { if (event.code === 'Space') this.store.isSpaceKeyPressed = true }) From 3f8e64333f46b44eb9f38e0d81c9dfc898ec28dd Mon Sep 17 00:00:00 2001 From: Stukova Olya Date: Wed, 3 Jun 2026 16:42:52 +0500 Subject: [PATCH 25/25] refactor(interactions): consolidate canvas event listeners New shape: this.canvasD3Selection = select(this.canvas) .call(this.dragInstance.behavior) .call(this.zoomInstance.behavior) .on('pointerenter.cosmos', ...) .on('pointermove.cosmos', ...) .on('pointerleave.cosmos pointercancel.cosmos', ...) .on('pointerdown.cosmos', ...) .on('pointerup.cosmos', ...) .on('click.cosmos', this.onClick.bind(this)) .on('contextmenu.cosmos', this.onContextMenu.bind(this)) select(document) .on('keydown.cosmos', ...) .on('keyup.cosmos', ...) this.zoomInstance.behavior.on('start.detect', ...).on('zoom.detect', ...).on('end.detect', ...) this.dragInstance.behavior.on('start.detect', ...).on('drag.detect', ...).on('end.detect', ...) Signed-off-by: Stukova Olya --- src/index.ts | 55 +++++++++++++++++++++------------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/index.ts b/src/index.ts index 48ede5bb..45c18fc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -191,7 +191,8 @@ export class Graph { this.store.updateScreenSize(w, h) this.canvasD3Selection = select(this.canvas) - this.canvasD3Selection + .call(this.dragInstance.behavior) + .call(this.zoomInstance.behavior) .on('pointerenter.cosmos', (event: PointerEvent) => { if (!event.isPrimary) return this._isPointerOnCanvas = true @@ -203,6 +204,10 @@ export class Graph { 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) { @@ -212,6 +217,12 @@ export class Graph { this.cancelLongPress() } } + + this.config.onMouseMove?.( + this.store.hoveredPoint?.index, + this.store.hoveredPoint?.position, + this.currentEvent + ) }) .on('pointerleave.cosmos pointercancel.cosmos', (event: PointerEvent) => { // Non-primary pointers (e.g. second finger of a pinch) leaving must not @@ -284,9 +295,13 @@ export class Graph { // 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) => { @@ -299,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 @@ -314,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('pointermove', this.onPointerMove.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. @@ -1346,24 +1356,17 @@ export class Graph { 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('pointerenter.cosmos', null) - .on('pointermove.cosmos', null) - .on('pointerleave.cosmos pointercancel.cosmos', null) - .on('pointerdown.cosmos', null) - .on('pointerup.cosmos', null) - .on('click', null) - .on('pointermove', 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 @@ -2021,20 +2024,6 @@ export class Graph { this.store.screenMousePosition = [mouseX, (this.store.screenSize[1] - mouseY)] } - private onPointerMove (event: PointerEvent): void { - // Skip non-primary pointers (e.g. second finger of a pinch) so callbacks - // and mouse-position state stay tied to a single pointer per gesture. - if (!event.isPrimary) return - this.currentEvent = event - this.updateMousePosition(event) - this.isRightClickMouse = (event.buttons & 2) !== 0 - 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