diff --git a/package-lock.json b/package-lock.json index d79a2239..ce040a75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "random": "^4.1.0" }, "devDependencies": { - "@interacta/css-labels": "^0.1.2", + "@interacta/css-labels": "^0.6.0", "@jls-digital/storybook-addon-code": "^1.0.4", "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-commonjs": "^22.0.1", @@ -776,11 +776,10 @@ "license": "BSD-3-Clause" }, "node_modules/@interacta/css-labels": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.1.2.tgz", - "integrity": "sha512-BYEU8LDKevrua77e6eruGrJ32JMi9s+OOmzPq8GEUPAU6wMQCiUk0Sebg77VAtFZ3XARMQR5kXRhKMfdt3HTKg==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.6.0.tgz", + "integrity": "sha512-wY+UX6A1BP7MazIUkDl5rnLDETU0lhHGQsJ/Igf9BjH7ygGKvQ05348sCLbbJmsLMPoGD1P02WnTd8enYNZuHg==", "dev": true, - "hasInstallScript": true, "license": "MIT" }, "node_modules/@jls-digital/storybook-addon-code": { @@ -11275,9 +11274,9 @@ "dev": true }, "@interacta/css-labels": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.1.2.tgz", - "integrity": "sha512-BYEU8LDKevrua77e6eruGrJ32JMi9s+OOmzPq8GEUPAU6wMQCiUk0Sebg77VAtFZ3XARMQR5kXRhKMfdt3HTKg==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@interacta/css-labels/-/css-labels-0.6.0.tgz", + "integrity": "sha512-wY+UX6A1BP7MazIUkDl5rnLDETU0lhHGQsJ/Igf9BjH7ygGKvQ05348sCLbbJmsLMPoGD1P02WnTd8enYNZuHg==", "dev": true }, "@jls-digital/storybook-addon-code": { diff --git a/package.json b/package.json index 6cdf6209..8146486e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "homepage": "https://cosmosgl.github.io/graph", "author": "cosmos.gl", "devDependencies": { - "@interacta/css-labels": "^0.1.2", + "@interacta/css-labels": "^0.6.0", "@jls-digital/storybook-addon-code": "^1.0.4", "@rollup/plugin-alias": "^3.1.9", "@rollup/plugin-commonjs": "^22.0.1", diff --git a/src/config.ts b/src/config.ts index ac1d4780..278be33b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -635,6 +635,12 @@ export interface GraphConfigInterface { * Default value: `150` */ pointSamplingDistance?: number; + /** + * Link sampling distance in pixels between neighboring links when calling the `getSampledLinks` method. + * This parameter determines how many links will be included in the sample (based on link midpoints in screen space). + * Default value: `150` + */ + linkSamplingDistance?: number; /** * Controls automatic position adjustment of points in the visible space. * @@ -773,6 +779,7 @@ export class GraphConfig implements GraphConfigInterface { public randomSeed = undefined public pointSamplingDistance = defaultConfigValues.pointSamplingDistance + public linkSamplingDistance = defaultConfigValues.linkSamplingDistance public attribution = defaultConfigValues.attribution public rescalePositions = defaultConfigValues.rescalePositions diff --git a/src/index.ts b/src/index.ts index 45b1dc6f..f7c1ca34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1209,6 +1209,28 @@ export class Graph { return this.points.getSampledPoints() } + /** + * For the links that are currently visible on the screen, get a sample of link indices with their midpoint coordinates and angle. + * The resulting number of links will depend on the `linkSamplingDistance` configuration property, + * and the sampled links will be evenly distributed (one link per grid cell, based on link midpoint in screen space). + * Each value is [x, y, angle]: position in data space; angle in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation). + */ + public getSampledLinkPositionsMap (): Map { + if (this._isDestroyed || !this.lines) return new Map() + return this.lines.getSampledLinkPositionsMap() + } + + /** + * For the links that are currently visible on the screen, get a sample of link indices, midpoint positions, and angles. + * The resulting number of links will depend on the `linkSamplingDistance` configuration property, + * and the sampled links will be evenly distributed. + * Positions are in data space; angles are in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation). + */ + public getSampledLinks (): { indices: number[]; positions: number[]; angles: number[] } { + if (this._isDestroyed || !this.lines) return { indices: [], positions: [], angles: [] } + return this.lines.getSampledLinks() + } + /** * Gets the X-axis of rescaling function. * @@ -1819,6 +1841,7 @@ export class Graph { this.canvasD3Selection ?.call(this.zoomInstance.behavior.transform, this.zoomInstance.getTransform([centerPosition], k)) this.points?.updateSampledPointsGrid() + this.lines?.updateSampledLinksGrid() // Only update link index FBO if link hovering is enabled if (this.store.isLinkHoveringEnabled) { this.lines?.updateLinkIndexFbo() diff --git a/src/modules/Lines/conic-curve-module.ts b/src/modules/Lines/conic-curve-module.ts new file mode 100644 index 00000000..7ad70dc5 --- /dev/null +++ b/src/modules/Lines/conic-curve-module.ts @@ -0,0 +1,18 @@ +import type { ShaderModule } from '@luma.gl/shadertools' + +/** + * Shared GLSL for conic parametric curve (rational quadratic Bezier). + * Used by draw-curve-line.vert and fill-sampled-links.vert. + */ +const conicParametricCurveVS = /* glsl */ ` +vec2 conicParametricCurve(vec2 A, vec2 B, vec2 ControlPoint, float t, float w) { + vec2 divident = (1.0 - t) * (1.0 - t) * A + 2.0 * (1.0 - t) * t * w * ControlPoint + t * t * B; + float divisor = (1.0 - t) * (1.0 - t) + 2.0 * (1.0 - t) * t * w + t * t; + return divident / divisor; +} +` + +export const conicParametricCurveModule: ShaderModule = { + name: 'conicParametricCurve', + vs: conicParametricCurveVS, +} diff --git a/src/modules/Lines/draw-curve-line.vert b/src/modules/Lines/draw-curve-line.vert index 75080270..96d71c86 100644 --- a/src/modules/Lines/draw-curve-line.vert +++ b/src/modules/Lines/draw-curve-line.vert @@ -89,12 +89,6 @@ float map(float value, float min1, float max1, float min2, float max2) { return min2 + (value - min1) * (max2 - min2) / (max1 - min1); } -vec2 conicParametricCurve(vec2 A, vec2 B, vec2 ControlPoint, float t, float w) { - vec2 divident = (1.0 - t) * (1.0 - t) * A + 2.0 * (1.0 - t) * t * w * ControlPoint + t * t * B; - float divisor = (1.0 - t) * (1.0 - t) + 2.0 * (1.0 - t) * t * w + t * t; - return divident / divisor; -} - float calculateLinkWidth(float width) { float linkWidth; if (scaleLinksOnZoom > 0.0) { diff --git a/src/modules/Lines/fill-sampled-links.frag b/src/modules/Lines/fill-sampled-links.frag new file mode 100644 index 00000000..657aa900 --- /dev/null +++ b/src/modules/Lines/fill-sampled-links.frag @@ -0,0 +1,12 @@ +#version 300 es +#ifdef GL_ES +precision highp float; +#endif + +in vec4 rgba; + +out vec4 fragColor; + +void main() { + fragColor = rgba; +} diff --git a/src/modules/Lines/fill-sampled-links.vert b/src/modules/Lines/fill-sampled-links.vert new file mode 100644 index 00000000..669aee32 --- /dev/null +++ b/src/modules/Lines/fill-sampled-links.vert @@ -0,0 +1,81 @@ +#version 300 es +#ifdef GL_ES +precision highp float; +#endif + +in vec2 pointA; +in vec2 pointB; +in float linkIndices; + +uniform sampler2D positionsTexture; + +#ifdef USE_UNIFORM_BUFFERS +layout(std140) uniform fillSampledLinksUniforms { + float pointsTextureSize; + mat4 transformationMatrix; + float spaceSize; + vec2 screenSize; + float curvedWeight; + float curvedLinkControlPointDistance; + float curvedLinkSegments; +} fillSampledLinks; + +#define pointsTextureSize fillSampledLinks.pointsTextureSize +#define transformationMatrix fillSampledLinks.transformationMatrix +#define spaceSize fillSampledLinks.spaceSize +#define screenSize fillSampledLinks.screenSize +#define curvedWeight fillSampledLinks.curvedWeight +#define curvedLinkControlPointDistance fillSampledLinks.curvedLinkControlPointDistance +#define curvedLinkSegments fillSampledLinks.curvedLinkSegments +#else +uniform float pointsTextureSize; +uniform float spaceSize; +uniform vec2 screenSize; +uniform float curvedWeight; +uniform float curvedLinkControlPointDistance; +uniform float curvedLinkSegments; +uniform mat3 transformationMatrix; +#endif + +out vec4 rgba; + +void main() { + vec4 posA = texture(positionsTexture, (pointA + 0.5) / pointsTextureSize); + vec4 posB = texture(positionsTexture, (pointB + 0.5) / pointsTextureSize); + vec2 a = posA.rg; + vec2 b = posB.rg; + + vec2 tangent = b - a; + float angle = -atan(tangent.y, tangent.x); + + vec2 mid; + if (curvedLinkSegments <= 1.0) { + mid = (a + b) * 0.5; + } else if (curvedLinkControlPointDistance != 0.0 && curvedWeight != 0.0) { + vec2 xBasis = b - a; + vec2 yBasis = normalize(vec2(-xBasis.y, xBasis.x)); + float linkDist = length(xBasis); + float h = curvedLinkControlPointDistance; + vec2 controlPoint = (a + b) / 2.0 + yBasis * linkDist * h; + mid = conicParametricCurve(a, b, controlPoint, 0.5, curvedWeight); + } else { + mid = (a + b) * 0.5; + } + + vec2 p = 2.0 * mid / spaceSize - 1.0; + p *= spaceSize / screenSize; + #ifdef USE_UNIFORM_BUFFERS + mat3 transformMat3 = mat3(transformationMatrix); + vec3 final = transformMat3 * vec3(p, 1); + #else + vec3 final = transformationMatrix * vec3(p, 1); + #endif + + vec2 pointScreenPosition = (final.xy + 1.0) * screenSize / 2.0; + rgba = vec4(linkIndices, mid.x, mid.y, angle); + float i = (pointScreenPosition.x + 0.5) / screenSize.x; + float j = (pointScreenPosition.y + 0.5) / screenSize.y; + gl_Position = vec4(2.0 * vec2(i, j) - 1.0, 0.0, 1.0); + + gl_PointSize = 1.0; +} diff --git a/src/modules/Lines/index.ts b/src/modules/Lines/index.ts index 9f9adbbe..877aeba0 100644 --- a/src/modules/Lines/index.ts +++ b/src/modules/Lines/index.ts @@ -2,20 +2,26 @@ import { Framebuffer, Buffer, Texture, UniformStore, RenderPass } from '@luma.gl import { Model } from '@luma.gl/engine' import { CoreModule } from '@/graph/modules/core-module' import type { Mat4Array } from '@/graph/modules/Store' +import { conicParametricCurveModule } from '@/graph/modules/Lines/conic-curve-module' import drawLineFrag from '@/graph/modules/Lines/draw-curve-line.frag?raw' import drawLineVert from '@/graph/modules/Lines/draw-curve-line.vert?raw' +import fillGridWithSampledLinksFrag from '@/graph/modules/Lines/fill-sampled-links.frag?raw' +import fillGridWithSampledLinksVert from '@/graph/modules/Lines/fill-sampled-links.vert?raw' import hoveredLineIndexFrag from '@/graph/modules/Lines/hovered-line-index.frag?raw' import hoveredLineIndexVert from '@/graph/modules/Lines/hovered-line-index.vert?raw' import { defaultConfigValues } from '@/graph/variables' import { getCurveLineGeometry } from '@/graph/modules/Lines/geometry' import { getBytesPerRow } from '@/graph/modules/Shared/texture-utils' import { ensureVec2, ensureVec4 } from '@/graph/modules/Shared/uniform-utils' +import { readPixels } from '@/graph/helper' export class Lines extends CoreModule { public linkIndexFbo: Framebuffer | undefined public hoveredLineIndexFbo: Framebuffer | undefined + public sampledLinksFbo: Framebuffer | undefined private drawCurveCommand: Model | undefined private hoveredLineIndexCommand: Model | undefined + private fillSampledLinksFboCommand: Model | undefined private pointABuffer: Buffer | undefined private pointBBuffer: Buffer | undefined private colorBuffer: Buffer | undefined @@ -27,6 +33,17 @@ export class Lines extends CoreModule { private quadBuffer: Buffer | undefined private linkIndexTexture: Texture | undefined private hoveredLineIndexTexture: Texture | undefined + private fillSampledLinksUniformStore: UniformStore<{ + fillSampledLinksUniforms: { + pointsTextureSize: number; + transformationMatrix: Mat4Array; + spaceSize: number; + screenSize: [number, number]; + curvedWeight: number; + curvedLinkControlPointDistance: number; + curvedLinkSegments: number; + }; + }> | undefined // Uniform stores for scalar uniforms private drawLineUniformStore: UniformStore<{ @@ -67,7 +84,7 @@ export class Lines extends CoreModule { private previousScreenSize: [number, number] | undefined public initPrograms (): void { - const { device, config, store } = this + const { device, config, store, data } = this this.updateLinkIndexFbo() @@ -176,6 +193,7 @@ export class Lines extends CoreModule { this.drawCurveCommand ||= new Model(device, { vs: drawLineVert, fs: drawLineFrag, + modules: [conicParametricCurveModule], topology: 'triangle-strip', vertexCount: this.curveLineGeometry?.length ?? 0, attributes: { @@ -269,6 +287,61 @@ export class Lines extends CoreModule { // All texture bindings will be set dynamically in findHoveredLine() method }, }) + + // Sampled links (for getSampledLinks / getSampledLinkPositionsMap) + this.fillSampledLinksUniformStore ||= new UniformStore({ + fillSampledLinksUniforms: { + uniformTypes: { + pointsTextureSize: 'f32', + transformationMatrix: 'mat4x4', + spaceSize: 'f32', + screenSize: 'vec2', + curvedWeight: 'f32', + curvedLinkControlPointDistance: 'f32', + curvedLinkSegments: 'f32', + }, + defaultUniforms: { + pointsTextureSize: store.pointsTextureSize ?? 0, + transformationMatrix: store.transformationMatrix4x4, + spaceSize: store.adjustedSpaceSize ?? 0, + screenSize: ensureVec2(store.screenSize, [0, 0]), + curvedWeight: config.curvedLinkWeight ?? 0, + curvedLinkControlPointDistance: config.curvedLinkControlPointDistance ?? 0, + curvedLinkSegments: config.curvedLinks ? config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1, + }, + }, + }) + + this.fillSampledLinksFboCommand ||= new Model(device, { + fs: fillGridWithSampledLinksFrag, + vs: fillGridWithSampledLinksVert, + modules: [conicParametricCurveModule], + topology: 'point-list', + vertexCount: data.linksNumber ?? 0, + attributes: { + ...(this.pointABuffer && { pointA: this.pointABuffer }), + ...(this.pointBBuffer && { pointB: this.pointBBuffer }), + ...(this.linkIndexBuffer && { linkIndices: this.linkIndexBuffer }), + }, + bufferLayout: [ + { name: 'pointA', format: 'float32x2' }, + { name: 'pointB', format: 'float32x2' }, + { name: 'linkIndices', format: 'float32' }, + ], + defines: { + USE_UNIFORM_BUFFERS: true, + }, + bindings: { + fillSampledLinksUniforms: this.fillSampledLinksUniformStore.getManagedUniformBuffer(device, 'fillSampledLinksUniforms'), + }, + parameters: { + depthWriteEnabled: false, + depthCompare: 'always', + blend: false, + }, + }) + + this.updateSampledLinksGrid() } public draw (renderPass: RenderPass): void { @@ -377,6 +450,26 @@ export class Lines extends CoreModule { } } + public updateSampledLinksGrid (): void { + const { store: { screenSize }, config: { linkSamplingDistance }, device } = this + let dist = linkSamplingDistance ?? Math.min(...screenSize) / 2 + if (dist === 0) dist = defaultConfigValues.linkSamplingDistance + const w = Math.ceil(screenSize[0] / dist) + const h = Math.ceil(screenSize[1] / dist) + if (w === 0 || h === 0) return + + if (!this.sampledLinksFbo || this.sampledLinksFbo.width !== w || this.sampledLinksFbo.height !== h) { + if (this.sampledLinksFbo && !this.sampledLinksFbo.destroyed) { + this.sampledLinksFbo.destroy() + } + this.sampledLinksFbo = device.createFramebuffer({ + width: w, + height: h, + colorAttachments: ['rgba32float'], + }) + } + } + public updatePointsBuffer (): void { const { device, data, store } = this if (data.linksNumber === undefined || data.links === undefined) return @@ -450,6 +543,15 @@ export class Lines extends CoreModule { linkIndices: this.linkIndexBuffer, }) } + if (this.fillSampledLinksFboCommand) { + this.fillSampledLinksFboCommand.setAttributes({ + pointA: this.pointABuffer, + pointB: this.pointBBuffer, + linkIndices: this.linkIndexBuffer, + }) + } + + this.updateSampledLinksGrid() } public updateColor (): void { @@ -584,6 +686,100 @@ export class Lines extends CoreModule { } } + public getSampledLinkPositionsMap (): Map { + const positions = new Map() + if (!this.sampledLinksFbo || this.sampledLinksFbo.destroyed) return positions + const points = this.points + if (!points?.currentPositionTexture || points.currentPositionTexture.destroyed) return positions + + if (this.fillSampledLinksFboCommand && this.fillSampledLinksUniformStore && this.sampledLinksFbo) { + this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0) + this.fillSampledLinksUniformStore.setUniforms({ + fillSampledLinksUniforms: { + pointsTextureSize: this.store.pointsTextureSize ?? 0, + transformationMatrix: this.store.transformationMatrix4x4, + spaceSize: this.store.adjustedSpaceSize ?? 0, + screenSize: ensureVec2(this.store.screenSize, [0, 0]), + curvedWeight: this.config.curvedLinkWeight ?? 0, + curvedLinkControlPointDistance: this.config.curvedLinkControlPointDistance ?? 0, + curvedLinkSegments: this.config.curvedLinks ? this.config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1, + }, + }) + this.fillSampledLinksFboCommand.setBindings({ + positionsTexture: points.currentPositionTexture, + }) + + const fillPass = this.device.beginRenderPass({ + framebuffer: this.sampledLinksFbo, + clearColor: [-1, -1, -1, -1], + }) + this.fillSampledLinksFboCommand.draw(fillPass) + fillPass.end() + } + + const pixels = readPixels(this.device, this.sampledLinksFbo) + for (let i = 0; i < pixels.length / 4; i++) { + const index = pixels[i * 4] + const x = pixels[i * 4 + 1] + const y = pixels[i * 4 + 2] + const angle = pixels[i * 4 + 3] + + if (index !== undefined && index >= 0 && x !== undefined && y !== undefined && angle !== undefined) { + positions.set(Math.round(index), [x, y, angle]) + } + } + return positions + } + + public getSampledLinks (): { indices: number[]; positions: number[]; angles: number[] } { + const indices: number[] = [] + const positions: number[] = [] + const angles: number[] = [] + if (!this.sampledLinksFbo || this.sampledLinksFbo.destroyed) return { indices, positions, angles } + const points = this.points + if (!points?.currentPositionTexture || points.currentPositionTexture.destroyed) return { indices, positions, angles } + + if (this.fillSampledLinksFboCommand && this.fillSampledLinksUniformStore && this.sampledLinksFbo) { + this.fillSampledLinksFboCommand.setVertexCount(this.data.linksNumber ?? 0) + this.fillSampledLinksUniformStore.setUniforms({ + fillSampledLinksUniforms: { + pointsTextureSize: this.store.pointsTextureSize ?? 0, + transformationMatrix: this.store.transformationMatrix4x4, + spaceSize: this.store.adjustedSpaceSize ?? 0, + screenSize: ensureVec2(this.store.screenSize, [0, 0]), + curvedWeight: this.config.curvedLinkWeight ?? 0, + curvedLinkControlPointDistance: this.config.curvedLinkControlPointDistance ?? 0, + curvedLinkSegments: this.config.curvedLinks ? this.config.curvedLinkSegments ?? defaultConfigValues.curvedLinkSegments : 1, + }, + }) + this.fillSampledLinksFboCommand.setBindings({ + positionsTexture: points.currentPositionTexture, + }) + + const fillPass = this.device.beginRenderPass({ + framebuffer: this.sampledLinksFbo, + clearColor: [-1, -1, -1, -1], + }) + this.fillSampledLinksFboCommand.draw(fillPass) + fillPass.end() + } + + const pixels = readPixels(this.device, this.sampledLinksFbo) + for (let i = 0; i < pixels.length / 4; i++) { + const index = pixels[i * 4] + const x = pixels[i * 4 + 1] + const y = pixels[i * 4 + 2] + const angle = pixels[i * 4 + 3] + + if (index !== undefined && index >= 0 && x !== undefined && y !== undefined && angle !== undefined) { + indices.push(Math.round(index)) + positions.push(x, y) + angles.push(angle) + } + } + return { indices, positions, angles } + } + public findHoveredLine (): void { const { config, points, store } = this if (!points) return @@ -670,12 +866,18 @@ export class Lines extends CoreModule { this.drawCurveCommand = undefined this.hoveredLineIndexCommand?.destroy() this.hoveredLineIndexCommand = undefined + this.fillSampledLinksFboCommand?.destroy() + this.fillSampledLinksFboCommand = undefined // 2. Destroy Framebuffers (before textures they reference) if (this.linkIndexFbo && !this.linkIndexFbo.destroyed) { this.linkIndexFbo.destroy() } this.linkIndexFbo = undefined + if (this.sampledLinksFbo && !this.sampledLinksFbo.destroyed) { + this.sampledLinksFbo.destroy() + } + this.sampledLinksFbo = undefined if (this.hoveredLineIndexFbo && !this.hoveredLineIndexFbo.destroyed) { this.hoveredLineIndexFbo.destroy() } @@ -696,6 +898,8 @@ export class Lines extends CoreModule { this.drawLineUniformStore = undefined this.hoveredLineIndexUniformStore?.destroy() this.hoveredLineIndexUniformStore = undefined + this.fillSampledLinksUniformStore?.destroy() + this.fillSampledLinksUniformStore = undefined // 5. Destroy Buffers (passed via attributes - NOT owned by Models, must destroy manually) if (this.pointABuffer && !this.pointABuffer.destroyed) { diff --git a/src/modules/Points/index.ts b/src/modules/Points/index.ts index 3988c0c0..9e14baa4 100644 --- a/src/modules/Points/index.ts +++ b/src/modules/Points/index.ts @@ -1306,6 +1306,7 @@ export class Points extends CoreModule { if (dist === 0) dist = defaultConfigValues.pointSamplingDistance const w = Math.ceil(screenSize[0] / dist) const h = Math.ceil(screenSize[1] / dist) + if (w === 0 || h === 0) return if (!this.sampledPointsFbo || this.sampledPointsFbo.width !== w || this.sampledPointsFbo.height !== h) { if (this.sampledPointsFbo && !this.sampledPointsFbo.destroyed) { diff --git a/src/stories/2. configuration.mdx b/src/stories/2. configuration.mdx index 16abb120..7c1a48f2 100644 --- a/src/stories/2. configuration.mdx +++ b/src/stories/2. configuration.mdx @@ -57,7 +57,8 @@ import { Meta } from "@storybook/blocks"; | fitViewByPointsInRect | When `fitViewOnInit` is set to `true`, fits the view to show the points within a rectangle defined by its two corner coordinates `[[left, bottom], [right, top]]` in the scene space | `undefined` | | fitViewByPointIndices | When `fitViewOnInit` is set to `true`, fits the view to show only the specified points by their indices. Takes precedence over `fitViewByPointsInRect` when both are provided. | `undefined` | | randomSeed | Providing a value allows control over layout randomness for consistency across simulations. Applied only on initialization | `undefined` | -| pointSamplingDistance | Sampling density for point position methods (used in `getSampledPointPositionsMap`) in pixels | `150` | +| pointSamplingDistance | Sampling density for point position methods (used in `getSampledPointPositionsMap`) in pixels | `100` | +| linkSamplingDistance | Sampling density for link position methods (used in `getSampledLinks` and `getSampledLinkPositionsMap`) in pixels | `100` | | attribution | Controls the text shown in the bottom right corner. Provide HTML content as a string for custom attribution. Empty string hides attribution | `''` | | rescalePositions | Control automatic point position adjustment. When undefined: auto-rescale if simulation disabled | `undefined` | diff --git a/src/stories/3. api-reference.mdx b/src/stories/3. api-reference.mdx index 071a84a6..2fe2002d 100644 --- a/src/stories/3. api-reference.mdx +++ b/src/stories/3. api-reference.mdx @@ -533,6 +533,37 @@ The sample aims to distribute points evenly across the visible area. - **`indices`**: Array of point indices for the sampled points - **`positions`**: Flat array of coordinates in the format `[x1, y1, x2, y2, ..., xn, yn]`, where the coordinates correspond to the points at the same index positions in the `indices` array. +### # graph.getSampledLinkPositionsMap() + +This method provides a sampling of link positions from the links that are currently visible on the screen. It returns a `Map` from link index to a tuple of midpoint coordinates and angle: `[x, y, angle]`. Positions are in data space; angle is in radians for screen-space rotation (0 = right, positive = clockwise, e.g. for CSS rotation). + +The number of sampled links is determined by the `linkSamplingDistance` [configuration property](../?path=/docs/configuration--docs). This property controls the density of the sampled links: +* A higher value for `linkSamplingDistance` results in fewer sampled links. +* A lower value results in more sampled links. + +The sample distributes links evenly (one link per grid cell, based on link midpoint in screen space). + +**Returns:** + +* A `Map` where each key is a link index and each value is `[x, y, angle]` (midpoint in data space, angle in radians). + +### # graph.getSampledLinks() + +This method provides an optimized way to retrieve link indices, midpoint positions, and angles for links that are currently visible on the screen. Unlike `getSampledLinkPositionsMap`, this method returns the data as arrays rather than a Map. + +The number of sampled links is determined by the `linkSamplingDistance` [configuration property](../?path=/docs/configuration--docs). This property controls the density of the sampled links: +* A higher value for `linkSamplingDistance` results in fewer sampled links. +* A lower value results in more sampled links. + +The sample distributes links evenly across the visible area. + +**Returns:** + +* An object containing: + - **`indices`**: Array of link indices for the sampled links + - **`positions`**: Flat array of coordinates in the format `[x1, y1, x2, y2, ..., xn, yn]`, where each pair is the midpoint of the link at the same index in `indices` (in data space) + - **`angles`**: Array of angles in radians for screen-space rotation (0 = right, positive = clockwise), one per sampled link + ### # graph.start([alpha]) Starts the simulation. This method only controls the simulation state, not rendering. Rendering is started automatically by `render()`. diff --git a/src/stories/beginners.stories.ts b/src/stories/beginners.stories.ts index d4c86c99..97d54120 100644 --- a/src/stories/beginners.stories.ts +++ b/src/stories/beginners.stories.ts @@ -7,6 +7,7 @@ import { basicSetUp } from './beginners/basic-set-up' import { pointLabels } from './beginners/point-labels' import { removePoints } from './beginners/remove-points' import { linkHovering } from './beginners/link-hovering' +import { linkSampling } from './beginners/link-sampling' import { pinnedPoints } from './beginners/pinned-points' import quickStartStoryRaw from './beginners/quick-start?raw' @@ -24,6 +25,10 @@ import removePointsStoryDataGenRaw from './beginners/remove-points/data-gen.ts?r import linkHoveringStoryRaw from './beginners/link-hovering/index?raw' import linkHoveringStoryDataGenRaw from './beginners/link-hovering/data-generator.ts?raw' import linkHoveringStoryCssRaw from './beginners/link-hovering/style.css?raw' +import linkSamplingStoryRaw from './beginners/link-sampling/index?raw' +import linkSamplingStoryDataRaw from './beginners/link-sampling/data.ts?raw' +import linkSamplingStoryLabelsRaw from './beginners/link-sampling/labels.ts?raw' +import linkSamplingStoryCssRaw from './beginners/link-sampling/style.css?raw' import pinnedPointsStoryRaw from './beginners/pinned-points/index?raw' import pinnedPointsStoryDataGenRaw from './beginners/pinned-points/data-gen.ts?raw' @@ -128,6 +133,19 @@ export const LinkHovering: Story = { }, } +export const LinkSampling: Story = { + ...createStory(linkSampling), + name: 'Link Sampling', + parameters: { + sourceCode: [ + { name: 'Story', code: linkSamplingStoryRaw }, + { name: 'labels.ts', code: linkSamplingStoryLabelsRaw }, + { name: 'data.ts', code: linkSamplingStoryDataRaw }, + { name: 'style.css', code: linkSamplingStoryCssRaw }, + ], + }, +} + export const PinnedPoints: Story = { ...createStory(pinnedPoints), name: 'Pinned Points', diff --git a/src/stories/beginners/link-sampling/data.ts b/src/stories/beginners/link-sampling/data.ts new file mode 100644 index 00000000..1919d3a4 --- /dev/null +++ b/src/stories/beginners/link-sampling/data.ts @@ -0,0 +1,65 @@ +// Note: This is vibe coding only - quick prototype code for demonstration purposes + +const RADIUS = 90 +const N = 14 + +function hslToRgb (h: number, s: number, l: number): [number, number, number] { + const c = (1 - Math.abs(2 * l - 1)) * s + const x = c * (1 - Math.abs(((h / 60) % 2) - 1)) + const m = l - c / 2 + let r = 0 + let g = 0 + let b = 0 + if (h < 60) { + r = c; g = x; b = 0 + } else if (h < 120) { + r = x; g = c; b = 0 + } else if (h < 180) { + r = 0; g = c; b = x + } else if (h < 240) { + r = 0; g = x; b = c + } else if (h < 300) { + r = x; g = 0; b = c + } else { + r = c; g = 0; b = x + } + return [r + m, g + m, b + m] +} + +export function generateLinkSamplingDemoData (): { + pointPositions: Float32Array; + links: Float32Array; + linkColors: Float32Array; + } { + const pointPositions = new Float32Array(N * 2) + for (let i = 0; i < N; i++) { + const angle = (i / N) * Math.PI * 2 - Math.PI / 2 + pointPositions[i * 2] = Math.cos(angle) * RADIUS + pointPositions[i * 2 + 1] = Math.sin(angle) * RADIUS + } + + const connections: [number, number][] = [] + for (let i = 0; i < N; i++) { + connections.push([i, (i + 1) % N]) + } + connections.push([0, 5], [2, 7], [4, 9], [6, 11], [1, 8], [3, 10]) + + const links = new Float32Array(connections.length * 2) + const linkColors = new Float32Array(connections.length * 4) + + connections.forEach(([s, t], i) => { + links[i * 2] = s + links[i * 2 + 1] = t + const [r, g, b] = hslToRgb((i / Math.max(1, connections.length)) * 320, 0.5, 0.58) + linkColors[i * 4] = r + linkColors[i * 4 + 1] = g + linkColors[i * 4 + 2] = b + linkColors[i * 4 + 3] = 1 + }) + + return { + pointPositions, + links, + linkColors, + } +} diff --git a/src/stories/beginners/link-sampling/index.ts b/src/stories/beginners/link-sampling/index.ts new file mode 100644 index 00000000..eebde72a --- /dev/null +++ b/src/stories/beginners/link-sampling/index.ts @@ -0,0 +1,53 @@ +import { Graph, GraphConfigInterface } from '@cosmos.gl/graph' +import { generateLinkSamplingDemoData } from './data' +import { LinkSamplingLabels } from './labels' +import './style.css' + +export const linkSampling = (): { div: HTMLDivElement; graph: Graph; destroy?: () => void } => { + const data = generateLinkSamplingDemoData() + + const div = document.createElement('div') + div.className = 'link-sampling-demo' + div.style.height = '100vh' + div.style.width = '100%' + div.style.position = 'relative' + + const graphDiv = document.createElement('div') + graphDiv.style.width = '100%' + graphDiv.style.height = '100%' + div.appendChild(graphDiv) + + const labelsContainer = document.createElement('div') + labelsContainer.className = 'link-sampling-labels' + div.appendChild(labelsContainer) + + const linkLabels = new LinkSamplingLabels(labelsContainer) + + const config: GraphConfigInterface = { + backgroundColor: '#252830', + pointDefaultColor: '#adb5c7', + scalePointsOnZoom: true, + linkDefaultArrows: false, + linkDefaultWidth: 2, + curvedLinks: true, + enableSimulation: false, + linkSamplingDistance: 50, + attribution: 'visualized with Cosmograph', + onZoom: () => linkLabels.update(graph), + onDragEnd: () => linkLabels.update(graph), + } + + const graph = new Graph(graphDiv, config) + + graph.setPointPositions(data.pointPositions) + graph.setLinks(data.links) + graph.setLinkColors(data.linkColors) + + graph.render() + + const destroy = (): void => { + graph.destroy() + } + + return { div, graph, destroy } +} diff --git a/src/stories/beginners/link-sampling/labels.ts b/src/stories/beginners/link-sampling/labels.ts new file mode 100644 index 00000000..f4d9ee8c --- /dev/null +++ b/src/stories/beginners/link-sampling/labels.ts @@ -0,0 +1,83 @@ +import { LabelRenderer, LabelOptions } from '@interacta/css-labels' +import { Graph } from '@cosmos.gl/graph' + +const FONT_SIZE = 12 +const LINE_HEIGHT = 1.4 +const LABEL_PADDING = { + top: 6, + right: 9, + bottom: 6, + left: 9, +} + +/** Normalize to (-90, 90] so text is never upside down. Returns rotation and whether it was flipped. */ +function normalizeRotation (deg: number): { rotation: number; flipped: boolean } { + let d = deg + while (d > 90) d -= 180 + while (d <= -90) d += 180 + const flipped = deg > 90 || deg <= -90 + return { rotation: d, flipped } +} + +export class LinkSamplingLabels { + private labelRenderer: LabelRenderer + + public constructor (container: HTMLDivElement) { + this.labelRenderer = new LabelRenderer(container, { pointerEvents: 'none' }) + } + + public update (graph: Graph): void { + const { indices, positions, angles } = graph.getSampledLinks() + const linkColors = graph.getLinkColors() + const links = graph.graph.links + + const labelOptions: LabelOptions[] = indices.map((linkIdx, i) => { + // Text + const source = Math.round(links?.[linkIdx * 2] ?? 0) + const target = Math.round(links?.[linkIdx * 2 + 1] ?? 0) + const text = links != null ? `${source} → ${target}` : String(linkIdx) + + // Color + const base = linkIdx * 4 + const hasColor = linkColors.length >= base + 4 + const r = Math.round((linkColors[base] ?? 0) * 255) + const g = Math.round((linkColors[base + 1] ?? 0) * 255) + const b = Math.round((linkColors[base + 2] ?? 0) * 255) + const color = hasColor ? `rgba(${r}, ${g}, ${b}, 0.9)` : 'rgba(120, 120, 140, 0.9)' + + // Position and rotation + const [screenX, screenY] = graph.spaceToScreenPosition([ + positions[i * 2] ?? 0, + positions[i * 2 + 1] ?? 0, + ]) + const angleRad = angles[i] ?? 0 + const { rotation, flipped } = normalizeRotation((angleRad * 180) / Math.PI) + + // Outer normal of the curve (perpendicular to chord, toward control point) + const outerX = Math.sin(angleRad) + const outerY = -Math.cos(angleRad) + + // When flipped, the label extends inward from its anchor; push the anchor out by + // the full label height so the inner edge stays at the curve. + const labelHeight = LINE_HEIGHT * FONT_SIZE + LABEL_PADDING.top + LABEL_PADDING.bottom + const dist = flipped ? labelHeight : 0 + const x = screenX + dist * outerX + const y = screenY + dist * outerY + + return { + id: `link-${linkIdx}`, + text, + x, + y, + rotation, + fontSize: FONT_SIZE, + padding: LABEL_PADDING, + opacity: 0.9, + style: `background: none; color: ${color}; font-weight: 500`, + } + }) + + this.labelRenderer.setLabels(labelOptions) + this.labelRenderer.draw() + } +} diff --git a/src/stories/beginners/link-sampling/style.css b/src/stories/beginners/link-sampling/style.css new file mode 100644 index 00000000..2a7f1bb7 --- /dev/null +++ b/src/stories/beginners/link-sampling/style.css @@ -0,0 +1,10 @@ +.link-sampling-demo { + position: relative; +} + +.link-sampling-labels { + position: absolute; + inset: 0; + pointer-events: none; +} + diff --git a/src/variables.ts b/src/variables.ts index 3912fa5a..cf7b8b7d 100644 --- a/src/variables.ts +++ b/src/variables.ts @@ -55,7 +55,8 @@ export const defaultConfigValues = { fitViewDelay: 250, fitViewPadding: 0.1, fitViewDuration: 250, - pointSamplingDistance: 150, + pointSamplingDistance: 100, + linkSamplingDistance: 100, attribution: '', rescalePositions: undefined, enableRightClickRepulsion: false,