@cosmos.gl/graph 2.1.0 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -21,6 +21,7 @@ export declare class Points extends CoreModule {
21
21
  private updatePositionCommand;
22
22
  private dragPointCommand;
23
23
  private findPointsOnAreaSelectionCommand;
24
+ private findPointsOnPolygonSelectionCommand;
24
25
  private findHoveredPointCommand;
25
26
  private clearHoveredFboCommand;
26
27
  private clearSampledPointsFboCommand;
@@ -31,6 +32,9 @@ export declare class Points extends CoreModule {
31
32
  private greyoutStatusTexture;
32
33
  private sizeTexture;
33
34
  private trackedIndicesTexture;
35
+ private polygonPathTexture;
36
+ private polygonPathFbo;
37
+ private polygonPathLength;
34
38
  private drawPointIndices;
35
39
  private hoveredPointIndices;
36
40
  private sampledPointIndices;
@@ -45,6 +49,8 @@ export declare class Points extends CoreModule {
45
49
  updatePosition(): void;
46
50
  drag(): void;
47
51
  findPointsOnAreaSelection(): void;
52
+ findPointsOnPolygonSelection(): void;
53
+ updatePolygonPath(polygonPath: [number, number][]): void;
48
54
  findHoveredPoint(): void;
49
55
  trackPointsByIndices(indices?: number[] | undefined): void;
50
56
  getTrackedPositionsMap(): Map<number, [number, number]>;
@@ -0,0 +1,6 @@
1
+ import { Graph } from '../../..';
2
+ export declare const polygonSelection: () => {
3
+ div: HTMLDivElement;
4
+ graph: Graph;
5
+ destroy: () => void;
6
+ };
@@ -0,0 +1,20 @@
1
+ export declare class PolygonSelection {
2
+ private canvas;
3
+ private ctx;
4
+ private isDrawing;
5
+ private points;
6
+ private graphDiv;
7
+ private onPolygonComplete?;
8
+ private boundStartDrawing;
9
+ private boundDraw;
10
+ private boundStopDrawing;
11
+ private resizeObserver;
12
+ constructor(graphDiv: HTMLElement, onPolygonComplete?: (points: [number, number][]) => void);
13
+ enablePolygonMode(): void;
14
+ disablePolygonMode(): void;
15
+ destroy(): void;
16
+ private resizeCanvas;
17
+ private startDrawing;
18
+ private draw;
19
+ private stopDrawing;
20
+ }
@@ -5,4 +5,5 @@ declare const meta: Meta<CosmosStoryProps>;
5
5
  export declare const Worm: Story;
6
6
  export declare const Radial: Story;
7
7
  export declare const WithLabels: Story;
8
+ export declare const PolygonSelection: Story;
8
9
  export default meta;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmos.gl/graph",
3
- "version": "2.1.0",
3
+ "version": "2.2.1",
4
4
  "description": "GPU-based force graph layout and rendering",
5
5
  "jsdelivr": "dist/index.min.js",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -648,7 +648,7 @@ export class Graph {
648
648
  * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
649
649
  * @returns A Float32Array containing the indices of points inside a rectangular area.
650
650
  */
651
- public getPointsInRange (selection: [[number, number], [number, number]]): Float32Array {
651
+ public getPointsInRect (selection: [[number, number], [number, number]]): Float32Array {
652
652
  if (this._isDestroyed || !this.reglInstance || !this.points) return new Float32Array()
653
653
  const h = this.store.screenSize[1]
654
654
  this.store.selectedArea = [[selection[0][0], (h - selection[1][1])], [selection[1][0], (h - selection[0][1])]]
@@ -663,11 +663,48 @@ export class Graph {
663
663
  .filter(d => d !== -1)
664
664
  }
665
665
 
666
+ /**
667
+ * Get points indices inside a rectangular area.
668
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
669
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
670
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
671
+ * @returns A Float32Array containing the indices of points inside a rectangular area.
672
+ * @deprecated Use `getPointsInRect` instead. This method will be removed in a future version.
673
+ */
674
+ public getPointsInRange (selection: [[number, number], [number, number]]): Float32Array {
675
+ return this.getPointsInRect(selection)
676
+ }
677
+
678
+ /**
679
+ * Get points indices inside a polygon area.
680
+ * @param polygonPath - Array of points `[[x1, y1], [x2, y2], ..., [xn, yn]]` that defines the polygon.
681
+ * The coordinates should be from 0 to the width/height of the canvas.
682
+ * @returns A Float32Array containing the indices of points inside the polygon area.
683
+ */
684
+ public getPointsInPolygon (polygonPath: [number, number][]): Float32Array {
685
+ if (this._isDestroyed || !this.reglInstance || !this.points) return new Float32Array()
686
+ if (polygonPath.length < 3) return new Float32Array() // Need at least 3 points for a polygon
687
+
688
+ const h = this.store.screenSize[1]
689
+ // Convert coordinates to WebGL coordinate system (flip Y)
690
+ const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number])
691
+ this.points.updatePolygonPath(convertedPath)
692
+ this.points.findPointsOnPolygonSelection()
693
+ const pixels = readPixels(this.reglInstance, this.points.selectedFbo as regl.Framebuffer2D)
694
+
695
+ return pixels
696
+ .map((pixel, i) => {
697
+ if (i % 4 === 0 && pixel !== 0) return i / 4
698
+ else return -1
699
+ })
700
+ .filter(d => d !== -1)
701
+ }
702
+
666
703
  /** Select points inside a rectangular area.
667
704
  * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
668
705
  * The `left` and `right` coordinates should be from 0 to the width of the canvas.
669
706
  * The `top` and `bottom` coordinates should be from 0 to the height of the canvas. */
670
- public selectPointsInRange (selection: [[number, number], [number, number]] | null): void {
707
+ public selectPointsInRect (selection: [[number, number], [number, number]] | null): void {
671
708
  if (this._isDestroyed || !this.reglInstance || !this.points) return
672
709
  if (selection) {
673
710
  const h = this.store.screenSize[1]
@@ -686,6 +723,46 @@ export class Graph {
686
723
  this.points.updateGreyoutStatus()
687
724
  }
688
725
 
726
+ /** Select points inside a rectangular area.
727
+ * @param selection - Array of two corner points `[[left, top], [right, bottom]]`.
728
+ * The `left` and `right` coordinates should be from 0 to the width of the canvas.
729
+ * The `top` and `bottom` coordinates should be from 0 to the height of the canvas.
730
+ * @deprecated Use `selectPointsInRect` instead. This method will be removed in a future version.
731
+ */
732
+ public selectPointsInRange (selection: [[number, number], [number, number]] | null): void {
733
+ return this.selectPointsInRect(selection)
734
+ }
735
+
736
+ /** Select points inside a polygon area.
737
+ * @param polygonPath - Array of points `[[x1, y1], [x2, y2], ..., [xn, yn]]` that defines the polygon.
738
+ * The coordinates should be from 0 to the width/height of the canvas.
739
+ * Set to null to clear selection. */
740
+ public selectPointsInPolygon (polygonPath: [number, number][] | null): void {
741
+ if (this._isDestroyed || !this.reglInstance || !this.points) return
742
+ if (polygonPath) {
743
+ if (polygonPath.length < 3) {
744
+ console.warn('Polygon path requires at least 3 points to form a polygon.')
745
+ return
746
+ }
747
+
748
+ const h = this.store.screenSize[1]
749
+ // Convert coordinates to WebGL coordinate system (flip Y)
750
+ const convertedPath = polygonPath.map(([x, y]) => [x, h - y] as [number, number])
751
+ this.points.updatePolygonPath(convertedPath)
752
+ this.points.findPointsOnPolygonSelection()
753
+ const pixels = readPixels(this.reglInstance, this.points.selectedFbo as regl.Framebuffer2D)
754
+ this.store.selectedIndices = pixels
755
+ .map((pixel, i) => {
756
+ if (i % 4 === 0 && pixel !== 0) return i / 4
757
+ else return -1
758
+ })
759
+ .filter(d => d !== -1)
760
+ } else {
761
+ this.store.selectedIndices = null
762
+ }
763
+ this.points.updateGreyoutStatus()
764
+ }
765
+
689
766
  /**
690
767
  * Select a point by index. If you want the adjacent points to get selected too, provide `true` as the second argument.
691
768
  * @param index The index of the point in the array of points.
@@ -0,0 +1,65 @@
1
+ #ifdef GL_ES
2
+ precision highp float;
3
+ #endif
4
+
5
+ uniform sampler2D positionsTexture;
6
+ uniform sampler2D polygonPathTexture; // Texture containing polygon path points
7
+ uniform int polygonPathLength;
8
+ uniform float spaceSize;
9
+ uniform vec2 screenSize;
10
+ uniform mat3 transformationMatrix;
11
+
12
+ varying vec2 textureCoords;
13
+
14
+ // Get a point from the polygon path texture at a specific index
15
+ vec2 getPolygonPoint(sampler2D pathTexture, int index, int pathLength) {
16
+ if (index >= pathLength) return vec2(0.0);
17
+
18
+ // Calculate texture coordinates for the index
19
+ int textureSize = int(ceil(sqrt(float(pathLength))));
20
+ int x = index - (index / textureSize) * textureSize;
21
+ int y = index / textureSize;
22
+
23
+ vec2 texCoord = (vec2(float(x), float(y)) + 0.5) / float(textureSize);
24
+ vec4 pathData = texture2D(pathTexture, texCoord);
25
+
26
+ return pathData.xy;
27
+ }
28
+
29
+ // Point-in-polygon algorithm using ray casting
30
+ bool pointInPolygon(vec2 point, sampler2D pathTexture, int pathLength) {
31
+ bool inside = false;
32
+
33
+ for (int i = 0; i < 2048; i++) {
34
+ if (i >= pathLength) break;
35
+
36
+ int j = int(mod(float(i + 1), float(pathLength)));
37
+
38
+ vec2 pi = getPolygonPoint(pathTexture, i, pathLength);
39
+ vec2 pj = getPolygonPoint(pathTexture, j, pathLength);
40
+
41
+ if (((pi.y > point.y) != (pj.y > point.y)) &&
42
+ (point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y) + pi.x)) {
43
+ inside = !inside;
44
+ }
45
+ }
46
+
47
+ return inside;
48
+ }
49
+
50
+ void main() {
51
+ vec4 pointPosition = texture2D(positionsTexture, textureCoords);
52
+ vec2 p = 2.0 * pointPosition.rg / spaceSize - 1.0;
53
+ p *= spaceSize / screenSize;
54
+ vec3 final = transformationMatrix * vec3(p, 1);
55
+
56
+ // Convert to screen coordinates for polygon check
57
+ vec2 screenPos = (final.xy + 1.0) * screenSize / 2.0;
58
+
59
+ gl_FragColor = vec4(0.0, 0.0, pointPosition.rg);
60
+
61
+ // Check if point center is inside the polygon
62
+ if (pointInPolygon(screenPos, polygonPathTexture, polygonPathLength)) {
63
+ gl_FragColor.r = 1.0;
64
+ }
65
+ }
@@ -6,6 +6,7 @@ import { defaultConfigValues } from '@/graph/variables'
6
6
  import drawPointsFrag from '@/graph/modules/Points/draw-points.frag'
7
7
  import drawPointsVert from '@/graph/modules/Points/draw-points.vert'
8
8
  import findPointsOnAreaSelectionFrag from '@/graph/modules/Points/find-points-on-area-selection.frag'
9
+ import findPointsOnPolygonSelectionFrag from '@/graph/modules/Points/find-points-on-polygon-selection.frag'
9
10
  import drawHighlightedFrag from '@/graph/modules/Points/draw-highlighted.frag'
10
11
  import drawHighlightedVert from '@/graph/modules/Points/draw-highlighted.vert'
11
12
  import findHoveredPointFrag from '@/graph/modules/Points/find-hovered-point.frag'
@@ -41,6 +42,7 @@ export class Points extends CoreModule {
41
42
  private updatePositionCommand: regl.DrawCommand | undefined
42
43
  private dragPointCommand: regl.DrawCommand | undefined
43
44
  private findPointsOnAreaSelectionCommand: regl.DrawCommand | undefined
45
+ private findPointsOnPolygonSelectionCommand: regl.DrawCommand | undefined
44
46
  private findHoveredPointCommand: regl.DrawCommand | undefined
45
47
  private clearHoveredFboCommand: regl.DrawCommand | undefined
46
48
  private clearSampledPointsFboCommand: regl.DrawCommand | undefined
@@ -51,6 +53,9 @@ export class Points extends CoreModule {
51
53
  private greyoutStatusTexture: regl.Texture2D | undefined
52
54
  private sizeTexture: regl.Texture2D | undefined
53
55
  private trackedIndicesTexture: regl.Texture2D | undefined
56
+ private polygonPathTexture: regl.Texture2D | undefined
57
+ private polygonPathFbo: regl.Framebuffer2D | undefined
58
+ private polygonPathLength = 0
54
59
  private drawPointIndices: regl.Buffer | undefined
55
60
  private hoveredPointIndices: regl.Buffer | undefined
56
61
  private sampledPointIndices: regl.Buffer | undefined
@@ -280,6 +285,27 @@ export class Points extends CoreModule {
280
285
  })
281
286
  }
282
287
 
288
+ if (!this.findPointsOnPolygonSelectionCommand) {
289
+ this.findPointsOnPolygonSelectionCommand = reglInstance({
290
+ frag: findPointsOnPolygonSelectionFrag,
291
+ vert: updateVert,
292
+ framebuffer: () => this.selectedFbo as regl.Framebuffer2D,
293
+ primitive: 'triangle strip',
294
+ count: 4,
295
+ attributes: {
296
+ vertexCoord: createQuadBuffer(reglInstance),
297
+ },
298
+ uniforms: {
299
+ positionsTexture: () => this.currentPositionFbo,
300
+ spaceSize: () => store.adjustedSpaceSize,
301
+ screenSize: () => store.screenSize,
302
+ transformationMatrix: () => store.transform,
303
+ polygonPathTexture: () => this.polygonPathTexture,
304
+ polygonPathLength: () => this.polygonPathLength,
305
+ },
306
+ })
307
+ }
308
+
283
309
  if (!this.clearHoveredFboCommand) {
284
310
  this.clearHoveredFboCommand = reglInstance({
285
311
  frag: clearFrag,
@@ -547,6 +573,49 @@ export class Points extends CoreModule {
547
573
  this.findPointsOnAreaSelectionCommand?.()
548
574
  }
549
575
 
576
+ public findPointsOnPolygonSelection (): void {
577
+ this.findPointsOnPolygonSelectionCommand?.()
578
+ }
579
+
580
+ public updatePolygonPath (polygonPath: [number, number][]): void {
581
+ const { reglInstance } = this
582
+ this.polygonPathLength = polygonPath.length
583
+
584
+ if (polygonPath.length === 0) {
585
+ this.polygonPathTexture = undefined
586
+ this.polygonPathFbo = undefined
587
+ return
588
+ }
589
+
590
+ // Calculate texture size (square texture)
591
+ const textureSize = Math.ceil(Math.sqrt(polygonPath.length))
592
+ const textureData = new Float32Array(textureSize * textureSize * 4)
593
+
594
+ // Fill texture with polygon path points
595
+ for (const [i, point] of polygonPath.entries()) {
596
+ const [x, y] = point
597
+ textureData[i * 4] = x
598
+ textureData[i * 4 + 1] = y
599
+ textureData[i * 4 + 2] = 0 // unused
600
+ textureData[i * 4 + 3] = 0 // unused
601
+ }
602
+
603
+ if (!this.polygonPathTexture) this.polygonPathTexture = reglInstance.texture()
604
+ this.polygonPathTexture({
605
+ data: textureData,
606
+ width: textureSize,
607
+ height: textureSize,
608
+ type: 'float',
609
+ })
610
+
611
+ if (!this.polygonPathFbo) this.polygonPathFbo = reglInstance.framebuffer()
612
+ this.polygonPathFbo({
613
+ color: this.polygonPathTexture,
614
+ depth: false,
615
+ stencil: false,
616
+ })
617
+ }
618
+
550
619
  public findHoveredPoint (): void {
551
620
  this.clearHoveredFboCommand?.()
552
621
  this.findHoveredPointCommand?.()
@@ -237,18 +237,55 @@ The `fitViewByPointPositions` method centers and zooms the view to fit the point
237
237
  * **`duration`** (Number, optional): The duration of the animation in milliseconds. Default is 250 ms.
238
238
  * **`padding`** (Number, optional): The padding around the viewport in percentage. This value should be between 0 and 1. Default is 0.1 (10% padding).
239
239
 
240
- ### <a name="get_points_in_range" href="#get_points_in_range">#</a> graph.<b>getPointsInRange</b>(<i>selection</i>)
240
+ ### <a name="get_points_in_rect" href="#get_points_in_rect">#</a> graph.<b>getPointsInRect</b>(<i>selection</i>)
241
241
 
242
242
  Get points as a Float32Array within a rectangular area defined by two corner points `[[left, top], [right, bottom]]`. The `left` and `right` values represent the horizontal position in pixels, relative to the left edge of the canvas, with `0` being the leftmost position and the width of the canvas being the rightmost position.
243
243
 
244
244
  The `top` and `bottom` values represent the vertical position in pixels, relative to the top edge of the canvas, with `0` being the topmost position and the height of the canvas being the bottommost position.
245
245
 
246
- ### <a name="select_points_in_range" href="#select_points_in_range">#</a> graph.<b>selectPointsInRange</b>(<i>selection</i>)
246
+ * **`selection`** (Array): An array containing two coordinate arrays representing the corners of the selection rectangle in the format `[[left, top], [right, bottom]]`.
247
+
248
+ **Returns:** A Float32Array containing the indices of points inside the rectangular area.
249
+
250
+ ### <a name="get_points_in_range" href="#get_points_in_range">#</a> graph.<b>getPointsInRange</b>(<i>selection</i>) <b style={{ color: 'orange' }}>[DEPRECATED]</b>
251
+
252
+ **⚠️ Deprecated:** Use `getPointsInRect` instead. This method will be removed in a future version.
253
+
254
+ Get points as a Float32Array within a rectangular area defined by two corner points `[[left, top], [right, bottom]]`. This method has the same functionality as `getPointsInRect`.
255
+
256
+ ### <a name="select_points_in_rect" href="#select_points_in_rect">#</a> graph.<b>selectPointsInRect</b>(<i>selection</i>)
247
257
 
248
258
  Select points within a rectangular area defined by two corner points `[[left, top], [right, bottom]]`. The `left` and `right` values represent the horizontal position in pixels, relative to the left edge of the canvas, with `0` being the leftmost position and the width of the canvas being the rightmost position.
249
259
 
250
260
  The `top` and `bottom` values represent the vertical position in pixels, relative to the top edge of the canvas, with `0` being the topmost position and the height of the canvas being the bottommost position.
251
261
 
262
+ * **`selection`** (Array | null): An array containing two coordinate arrays representing the corners of the selection rectangle in the format `[[left, top], [right, bottom]]`, or `null` to clear the current selection.
263
+
264
+ ### <a name="select_points_in_range" href="#select_points_in_range">#</a> graph.<b>selectPointsInRange</b>(<i>selection</i>) <b style={{ color: 'orange' }}>[DEPRECATED]</b>
265
+
266
+ **⚠️ Deprecated:** Use `selectPointsInRect` instead. This method will be removed in a future version.
267
+
268
+ Select points within a rectangular area defined by two corner points `[[left, top], [right, bottom]]`. This method has the same functionality as `selectPointsInRect`.
269
+
270
+ ### <a name="get_points_in_polygon" href="#get_points_in_polygon">#</a> graph.<b>getPointsInPolygon</b>(<i>polygonPath</i>)
271
+
272
+ Get points as a Float32Array within a polygon area defined by an array of coordinate points.
273
+
274
+ * **`polygonPath`** (Array): An array of coordinate points in the format `[[x1, y1], [x2, y2], ..., [xN, yN]]` that defines the polygon. The coordinates should be in pixels relative to the canvas, where:
275
+ - **`x`**: Horizontal position from 0 to the width of the canvas
276
+ - **`y`**: Vertical position from 0 to the height of the canvas
277
+ - The polygon requires at least 3 points to form a valid selection area
278
+
279
+ **Returns:** A Float32Array containing the indices of points inside the polygon area.
280
+
281
+ ### <a name="select_points_in_polygon" href="#select_points_in_polygon">#</a> graph.<b>selectPointsInPolygon</b>(<i>polygonPath</i>)
282
+
283
+ Select points within a polygon area defined by an array of coordinate points. This method combines the functionality of `getPointsInPolygon` with point selection, making the identified points visually selected in the graph.
284
+
285
+ * **`polygonPath`** (Array | null): An array of coordinate points in the format `[[x1, y1], [x2, y2], ..., [xN, yN]]` that defines the polygon, or `null` to clear the current selection. The coordinates should be in pixels relative to the canvas, where:
286
+ - **`x`**: Horizontal position from 0 to the width of the canvas
287
+ - **`y`**: Vertical position from 0 to the height of the canvas
288
+ - The polygon requires at least 3 points to form a valid selection area
252
289
 
253
290
  ### <a name="select_point_by_index" href="#select_point_by_index">#</a> graph.<b>selectPointByIndex</b>(<i>index</i>, [<i>selectAdjacentPoints</i>])
254
291
 
@@ -119,7 +119,7 @@ export const basicSetUp = (): { graph: Graph; div: HTMLDivElement} => {
119
119
  const top = getRandomInRange([h / 4, h / 2])
120
120
  const bottom = getRandomInRange([top, (h * 3) / 4])
121
121
  pause()
122
- graph.selectPointsInRange([
122
+ graph.selectPointsInRect([
123
123
  [left, top],
124
124
  [right, bottom],
125
125
  ])
@@ -0,0 +1,53 @@
1
+ import { Graph } from '@cosmos.gl/graph'
2
+ import { createCosmos } from '../../create-cosmos'
3
+ import { generateMeshData } from '../../generate-mesh-data'
4
+ import { PolygonSelection } from './polygon'
5
+
6
+ export const polygonSelection = (): {div: HTMLDivElement; graph: Graph; destroy: () => void } => {
7
+ const nClusters = 25
8
+ const { pointPositions, pointColors, pointClusters } = generateMeshData(150, 150, nClusters, 1.0)
9
+
10
+ const { div, graph } = createCosmos({
11
+ pointPositions,
12
+ pointColors,
13
+ pointClusters,
14
+ simulationGravity: 1.5,
15
+ simulationCluster: 0.3,
16
+ simulationRepulsion: 8,
17
+ pointSize: 8,
18
+ backgroundColor: '#1a1a2e',
19
+ pointGreyoutOpacity: 0.2,
20
+ onClick: (index: number | undefined): void => {
21
+ if (index === undefined) {
22
+ graph.unselectPoints()
23
+ }
24
+ },
25
+ })
26
+
27
+ graph.setZoomLevel(0.4)
28
+
29
+ const polygonSelection = new PolygonSelection(div, (polygonPoints) => {
30
+ graph.selectPointsInPolygon(polygonPoints)
31
+ })
32
+
33
+ const actionsDiv = document.createElement('div')
34
+ actionsDiv.className = 'actions'
35
+ div.appendChild(actionsDiv)
36
+
37
+ const polygonButton = document.createElement('div')
38
+ polygonButton.className = 'action'
39
+ polygonButton.textContent = 'Enable Polygon Selection'
40
+ polygonButton.addEventListener('click', () => {
41
+ polygonSelection.enablePolygonMode()
42
+ })
43
+ actionsDiv.appendChild(polygonButton)
44
+
45
+ const destroy = (): void => {
46
+ polygonSelection.destroy()
47
+ if (actionsDiv.parentNode) {
48
+ actionsDiv.parentNode.removeChild(actionsDiv)
49
+ }
50
+ }
51
+
52
+ return { div, graph, destroy }
53
+ }
@@ -0,0 +1,143 @@
1
+ import './style.css'
2
+
3
+ export class PolygonSelection {
4
+ private canvas: HTMLCanvasElement
5
+ private ctx: CanvasRenderingContext2D
6
+ private isDrawing = false
7
+ private points: Array<{ x: number; y: number }> = []
8
+ private graphDiv: HTMLElement
9
+ private onPolygonComplete?: (points: [number, number][]) => void
10
+ private boundStartDrawing: (e: MouseEvent) => void
11
+ private boundDraw: (e: MouseEvent) => void
12
+ private boundStopDrawing: () => void
13
+ private resizeObserver: ResizeObserver
14
+
15
+ public constructor (graphDiv: HTMLElement, onPolygonComplete?: (points: [number, number][]) => void) {
16
+ this.graphDiv = graphDiv
17
+ this.onPolygonComplete = onPolygonComplete
18
+
19
+ // Bind event handlers
20
+ this.boundStartDrawing = this.startDrawing.bind(this)
21
+ this.boundDraw = this.draw.bind(this)
22
+ this.boundStopDrawing = this.stopDrawing.bind(this)
23
+
24
+ // Create canvas
25
+ this.canvas = document.createElement('canvas')
26
+ this.canvas.className = 'polygon-canvas'
27
+
28
+ const ctx = this.canvas.getContext('2d')
29
+ if (!ctx) throw new Error('Could not get canvas context')
30
+ this.ctx = ctx
31
+
32
+ this.graphDiv.appendChild(this.canvas)
33
+
34
+ this.resizeObserver = new ResizeObserver(() => {
35
+ this.resizeCanvas()
36
+ })
37
+ this.resizeObserver.observe(this.graphDiv)
38
+ }
39
+
40
+ public enablePolygonMode (): void {
41
+ this.canvas.style.pointerEvents = 'auto'
42
+ this.canvas.style.cursor = 'crosshair'
43
+
44
+ // Add event listeners
45
+ this.canvas.addEventListener('mousedown', this.boundStartDrawing)
46
+ this.canvas.addEventListener('mousemove', this.boundDraw)
47
+ this.canvas.addEventListener('mouseup', this.boundStopDrawing)
48
+ }
49
+
50
+ public disablePolygonMode (): void {
51
+ this.canvas.style.pointerEvents = 'none'
52
+ this.canvas.style.cursor = 'default'
53
+
54
+ // Remove event listeners
55
+ this.canvas.removeEventListener('mousedown', this.boundStartDrawing)
56
+ this.canvas.removeEventListener('mousemove', this.boundDraw)
57
+ this.canvas.removeEventListener('mouseup', this.boundStopDrawing)
58
+
59
+ // Clear canvas
60
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
61
+ }
62
+
63
+ public destroy (): void {
64
+ this.disablePolygonMode()
65
+ this.resizeObserver.disconnect()
66
+ if (this.canvas.parentNode) {
67
+ this.canvas.parentNode.removeChild(this.canvas)
68
+ }
69
+ }
70
+
71
+ private resizeCanvas (): void {
72
+ const rect = this.graphDiv.getBoundingClientRect()
73
+
74
+ // Apply pixel ratio for crisp rendering
75
+ const pixelRatio = window.devicePixelRatio || 1
76
+ this.canvas.width = rect.width * pixelRatio
77
+ this.canvas.height = rect.height * pixelRatio
78
+
79
+ // Reset transform and scale the context to match the pixel ratio
80
+ this.ctx.resetTransform()
81
+ this.ctx.scale(pixelRatio, pixelRatio)
82
+ }
83
+
84
+ private startDrawing (e: MouseEvent): void {
85
+ this.isDrawing = true
86
+ this.points = []
87
+ const rect = this.canvas.getBoundingClientRect()
88
+ this.points.push({ x: e.clientX - rect.left, y: e.clientY - rect.top })
89
+ this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
90
+ }
91
+
92
+ private draw (e: MouseEvent): void {
93
+ if (!this.isDrawing) return
94
+
95
+ const rect = this.canvas.getBoundingClientRect()
96
+ this.points.push({ x: e.clientX - rect.left, y: e.clientY - rect.top })
97
+
98
+ // Clear the entire canvas accounting for pixel ratio
99
+ const pixelRatio = window.devicePixelRatio || 1
100
+ this.ctx.clearRect(0, 0, this.canvas.width / pixelRatio, this.canvas.height / pixelRatio)
101
+
102
+ this.ctx.beginPath()
103
+ if (this.points.length > 0 && this.points[0]) {
104
+ this.ctx.moveTo(this.points[0].x, this.points[0].y)
105
+ }
106
+
107
+ for (let i = 1; i < this.points.length; i++) {
108
+ const point = this.points[i]
109
+ if (point) {
110
+ this.ctx.lineTo(point.x, point.y)
111
+ }
112
+ }
113
+
114
+ this.ctx.strokeStyle = '#ffffff'
115
+ this.ctx.lineWidth = 2
116
+ this.ctx.stroke()
117
+ }
118
+
119
+ private stopDrawing (): void {
120
+ if (!this.isDrawing) return
121
+ this.isDrawing = false
122
+
123
+ if (this.points.length > 2) {
124
+ this.ctx.closePath()
125
+ this.ctx.stroke()
126
+
127
+ const polygonPoints: [number, number][] = this.points.map(p => [p.x, p.y])
128
+ const firstPolygonPoint = polygonPoints[0]
129
+ const lastPolygonPoint = polygonPoints[polygonPoints.length - 1]
130
+ if (firstPolygonPoint && lastPolygonPoint && (firstPolygonPoint[0] !== lastPolygonPoint[0] || firstPolygonPoint[1] !== lastPolygonPoint[1])) {
131
+ polygonPoints.push(firstPolygonPoint)
132
+ }
133
+
134
+ if (this.onPolygonComplete) {
135
+ this.onPolygonComplete(polygonPoints)
136
+ }
137
+ }
138
+
139
+ const pixelRatio = window.devicePixelRatio || 1
140
+ this.ctx.clearRect(0, 0, this.canvas.width / pixelRatio, this.canvas.height / pixelRatio)
141
+ this.disablePolygonMode()
142
+ }
143
+ }
@@ -0,0 +1,8 @@
1
+ .polygon-canvas {
2
+ position: absolute;
3
+ top: 0;
4
+ left: 0;
5
+ pointer-events: none;
6
+ width: 100%;
7
+ height: 100%;
8
+ }
@@ -4,6 +4,7 @@ import { createStory, Story } from '@/graph/stories/create-story'
4
4
  import { withLabels } from './clusters/with-labels'
5
5
  import { worm } from './clusters/worm'
6
6
  import { radial } from './clusters/radial'
7
+ import { polygonSelection } from './clusters/polygon-selection'
7
8
 
8
9
  import createCosmosRaw from './create-cosmos?raw'
9
10
  import generateMeshDataRaw from './generate-mesh-data?raw'
@@ -11,6 +12,9 @@ import withLabelsStoryRaw from './clusters/with-labels?raw'
11
12
  import createClusterLabelsRaw from './create-cluster-labels?raw'
12
13
  import wormStory from './clusters/worm?raw'
13
14
  import radialStory from './clusters/radial?raw'
15
+ import polygonSelectionStory from './clusters/polygon-selection?raw'
16
+ import polygonSelectionStyleRaw from './clusters/polygon-selection/style.css?raw'
17
+ import polygonSelectionPolygonRaw from './clusters/polygon-selection/polygon.ts?raw'
14
18
 
15
19
  const meta: Meta<CosmosStoryProps> = {
16
20
  title: 'Examples/Clusters',
@@ -57,5 +61,17 @@ export const WithLabels: Story = {
57
61
  },
58
62
  }
59
63
 
64
+ export const PolygonSelection: Story = {
65
+ ...createStory(polygonSelection),
66
+ parameters: {
67
+ sourceCode: [
68
+ { name: 'Story', code: polygonSelectionStory },
69
+ { name: 'polygon.ts', code: polygonSelectionPolygonRaw },
70
+ ...sourceCodeAddonParams,
71
+ { name: 'style.css', code: polygonSelectionStyleRaw },
72
+ ],
73
+ },
74
+ }
75
+
60
76
  // eslint-disable-next-line import/no-default-export
61
77
  export default meta