@fusion.dev/helix-graph 0.120.0 → 0.121.0

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.
@@ -54,12 +54,21 @@ export const mixRgb = (from, to, amount) => {
54
54
  const channel = (at) => Math.round((one[at] ?? 0) + ((other[at] ?? 0) - (one[at] ?? 0)) * amount);
55
55
  return `rgb(${channel(0)}, ${channel(1)}, ${channel(2)})`;
56
56
  };
57
+ export const toneColor = (colors, tone) => tone === undefined || tone === 'primary'
58
+ ? colors.primary
59
+ : tone === 'neutral'
60
+ ? colors.muted
61
+ : colors[tone];
57
62
  const FADED = 0.75;
58
63
  export const looksOf = (options) => {
59
- const { colors, groupOf, near, selected, shown } = options;
64
+ const { colors, near, noteOf, selected, shown } = options;
60
65
  const order = new Map([...new Set(options.groups)].sort().map((group, at) => [group, at]));
61
66
  const own = (id) => {
62
- const group = groupOf(id);
67
+ const note = noteOf(id);
68
+ if (note?.tone) {
69
+ return toneColor(colors, note.tone);
70
+ }
71
+ const group = note?.group;
63
72
  const at = group === undefined ? undefined : order.get(group);
64
73
  return at === undefined || colors.series.length === 0
65
74
  ? colors.muted
@@ -68,24 +77,30 @@ export const looksOf = (options) => {
68
77
  const focused = selected !== undefined;
69
78
  const hidden = (id) => shown !== undefined && !shown.has(id);
70
79
  return {
80
+ colorOf: own,
71
81
  font: options.font || 'sans-serif',
82
+ ground: colors.ground,
72
83
  ink: colors.ink,
73
84
  link: (source, target) => {
74
85
  const touches = focused && (source === selected || target === selected);
75
86
  return {
76
87
  color: touches
77
- ? colors.muted
88
+ ? own(selected)
78
89
  : focused
79
- ? mixRgb(colors.line, colors.ground, 0.5)
80
- : colors.line,
90
+ ? mixRgb(colors.line, colors.ground, 0.6)
91
+ : mixRgb(own(source), colors.ground, 0.72),
81
92
  hidden: hidden(source) || hidden(target),
82
93
  raised: touches,
83
94
  };
84
95
  },
85
96
  node: (id) => {
97
+ const note = noteOf(id);
98
+ const facts = { emphasis: note?.emphasis, material: note?.material, shape: note?.shape };
86
99
  if (id === selected) {
87
100
  return {
88
- color: colors.primary,
101
+ ...facts,
102
+ color: own(id),
103
+ glow: 1,
89
104
  hidden: hidden(id),
90
105
  label: 'always',
91
106
  raised: true,
@@ -94,7 +109,9 @@ export const looksOf = (options) => {
94
109
  }
95
110
  const neighbor = focused && near.has(id);
96
111
  return {
112
+ ...facts,
97
113
  color: focused && !neighbor ? mixRgb(own(id), colors.ground, FADED) : own(id),
114
+ glow: 0,
98
115
  hidden: hidden(id),
99
116
  label: focused && !neighbor ? 'never' : 'room',
100
117
  raised: neighbor,
@@ -102,5 +119,6 @@ export const looksOf = (options) => {
102
119
  };
103
120
  },
104
121
  overlay: colors.overlay,
122
+ quiet: colors.muted,
105
123
  };
106
124
  };
@@ -23,9 +23,18 @@ export const seedPositions = (nodes, links, known = new Map()) => {
23
23
  }
24
24
  }
25
25
  const neighbors = new Map();
26
+ const add = (from, to) => {
27
+ const list = neighbors.get(from);
28
+ if (list) {
29
+ list.push(to);
30
+ }
31
+ else {
32
+ neighbors.set(from, [to]);
33
+ }
34
+ };
26
35
  for (const { source, target } of links) {
27
- neighbors.set(source, [...(neighbors.get(source) ?? []), target]);
28
- neighbors.set(target, [...(neighbors.get(target) ?? []), source]);
36
+ add(source, target);
37
+ add(target, source);
29
38
  }
30
39
  let spiral = positions.size;
31
40
  for (const node of nodes) {
@@ -0,0 +1,34 @@
1
+ import type { GraphPoint } from './seed.js';
2
+ /**
3
+ * A shape the notes are fitted into once a layout settles, in a box `width`
4
+ * by `height`, as an SVG's `viewBox="0 0 width height"` gives one: an SVG
5
+ * path, whose holes are cut by the even-odd rule, or a test of whether a
6
+ * point in the box is inside.
7
+ */
8
+ export type GraphStencil = {
9
+ height: number;
10
+ path: string;
11
+ width: number;
12
+ } | {
13
+ height: number;
14
+ inside: (x: number, y: number) => boolean;
15
+ width: number;
16
+ };
17
+ /**
18
+ * `count` places spread evenly over the inside of a shape: a grid sized to
19
+ * the shape's area, each place shaken within its cell so no rows show, and
20
+ * the grid made finer until it holds enough.
21
+ */
22
+ export declare const placesIn: (inside: (x: number, y: number) => boolean, width: number, height: number, count: number) => GraphPoint[];
23
+ /**
24
+ * Places a layout put anywhere, moved into the stencil's shape and filling it
25
+ * evenly. **The notes keep their order**, left to right and, within each
26
+ * strip, top to bottom, so notes the layout put together land together, and
27
+ * each group takes a region of the shape.
28
+ *
29
+ * Centered where the layout was, and as large. The stencil's y points down,
30
+ * as an SVG's does, and the layout's up, as Sigma and cosmos.gl draw it.
31
+ * Places the shape cannot hold, as when it has no inside, come back as they
32
+ * were.
33
+ */
34
+ export declare const fitToStencil: (positions: ReadonlyMap<string, GraphPoint>, stencil: GraphStencil) => Map<string, GraphPoint>;
@@ -0,0 +1,121 @@
1
+ const RASTER = 1024;
2
+ const PROBE = 64;
3
+ const WAVE = 0.07;
4
+ const seeded = (seed) => {
5
+ let state = seed >>> 0 || 1;
6
+ return () => {
7
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
8
+ return state / 4294967296;
9
+ };
10
+ };
11
+ const insideOf = (stencil) => {
12
+ if ('inside' in stencil) {
13
+ return stencil.inside;
14
+ }
15
+ const scale = RASTER / Math.max(stencil.width, stencil.height, 1);
16
+ const width = Math.max(1, Math.ceil(stencil.width * scale));
17
+ const height = Math.max(1, Math.ceil(stencil.height * scale));
18
+ const canvas = document.createElement('canvas');
19
+ canvas.width = width;
20
+ canvas.height = height;
21
+ const context = canvas.getContext('2d', { willReadFrequently: true });
22
+ if (!context) {
23
+ return () => true;
24
+ }
25
+ context.scale(scale, scale);
26
+ context.fill(new Path2D(stencil.path), 'evenodd');
27
+ const alpha = context.getImageData(0, 0, width, height).data;
28
+ return (x, y) => {
29
+ const column = Math.floor(x * scale);
30
+ const row = Math.floor(y * scale);
31
+ return (column >= 0 &&
32
+ row >= 0 &&
33
+ column < width &&
34
+ row < height &&
35
+ (alpha[(row * width + column) * 4 + 3] ?? 0) > 127);
36
+ };
37
+ };
38
+ export const placesIn = (inside, width, height, count) => {
39
+ let filled = 0;
40
+ for (let row = 0; row < PROBE; row++) {
41
+ for (let column = 0; column < PROBE; column++) {
42
+ if (inside(((column + 0.5) / PROBE) * width, ((row + 0.5) / PROBE) * height)) {
43
+ filled += 1;
44
+ }
45
+ }
46
+ }
47
+ if (count === 0 || filled === 0) {
48
+ return [];
49
+ }
50
+ const random = seeded(count);
51
+ let step = Math.sqrt(((filled / PROBE ** 2) * width * height) / count);
52
+ for (let tries = 0; tries < 8; tries++) {
53
+ const places = [];
54
+ for (let y = step / 2; y < height; y += step) {
55
+ for (let x = step / 2; x < width; x += step) {
56
+ const place = {
57
+ x: x + (random() - 0.5) * step * 0.8,
58
+ y: y + (random() - 0.5) * step * 0.8,
59
+ };
60
+ if (inside(place.x, place.y)) {
61
+ places.push(place);
62
+ }
63
+ }
64
+ }
65
+ if (places.length >= count) {
66
+ for (let at = places.length - 1; at > 0; at--) {
67
+ const other = Math.floor(random() * (at + 1));
68
+ [places[at], places[other]] = [places[other], places[at]];
69
+ }
70
+ return places.slice(0, count);
71
+ }
72
+ step *= Math.sqrt(places.length / count) * 0.97 || 0.5;
73
+ }
74
+ return [];
75
+ };
76
+ export const fitToStencil = (positions, stencil) => {
77
+ const notes = [...positions].map(([id, at]) => ({ at, id }));
78
+ if (notes.length === 0) {
79
+ return new Map();
80
+ }
81
+ const places = placesIn(insideOf(stencil), stencil.width, stencil.height, notes.length);
82
+ if (places.length < notes.length) {
83
+ return new Map(positions);
84
+ }
85
+ let left = Number.POSITIVE_INFINITY;
86
+ let right = Number.NEGATIVE_INFINITY;
87
+ let bottom = Number.POSITIVE_INFINITY;
88
+ let top = Number.NEGATIVE_INFINITY;
89
+ for (const { at } of notes) {
90
+ left = Math.min(left, at.x);
91
+ right = Math.max(right, at.x);
92
+ bottom = Math.min(bottom, at.y);
93
+ top = Math.max(top, at.y);
94
+ }
95
+ const scale = Math.max(right - left, top - bottom, 1) / Math.max(stencil.width, stencil.height);
96
+ const { height, width } = stencil;
97
+ const spots = places
98
+ .map((place) => ({
99
+ across: place.x + WAVE * width * Math.sin((place.y / height) * Math.PI * 3 + 1.3),
100
+ at: {
101
+ x: (left + right) / 2 + (place.x - width / 2) * scale,
102
+ y: (bottom + top) / 2 - (place.y - height / 2) * scale,
103
+ },
104
+ down: -place.y + WAVE * height * Math.sin((place.x / width) * Math.PI * 3 + 0.4),
105
+ }))
106
+ .sort((one, other) => one.across - other.across);
107
+ notes.sort((one, other) => one.at.x - other.at.x || one.at.y - other.at.y);
108
+ const strip = Math.ceil(Math.sqrt(notes.length));
109
+ const fitted = new Map();
110
+ for (let from = 0; from < notes.length; from += strip) {
111
+ const ours = notes.slice(from, from + strip).sort((one, other) => one.at.y - other.at.y);
112
+ const theirs = spots
113
+ .slice(from, from + strip)
114
+ .sort((one, other) => one.down - other.down)
115
+ .map((spot) => spot.at);
116
+ ours.forEach((note, at) => {
117
+ fitted.set(note.id, theirs[at]);
118
+ });
119
+ }
120
+ return fitted;
121
+ };
@@ -0,0 +1,67 @@
1
+ import type { GraphDriver } from '../connect-graph/connect-graph.js';
2
+ import type { NodeLook } from '../connect-graph/looks.js';
3
+ /** A cosmos.gl graph, as far as the driver calls it. */
4
+ export interface CosmosGraph {
5
+ fitView(duration?: number, padding?: number): void;
6
+ fitViewByPointIndices(indices: number[], duration?: number, padding?: number): void;
7
+ getPointPositions(): number[];
8
+ getTrackedPointPositionsMap(): ReadonlyMap<number, [number, number]>;
9
+ getZoomLevel(): number;
10
+ readonly isReady: boolean;
11
+ readonly ready: Promise<void>;
12
+ pause(): void;
13
+ render(simulationAlpha?: number, transitionDuration?: number): void;
14
+ screenToSpacePosition(screenPosition: [number, number]): [number, number];
15
+ setConfigPartial(config: Record<string, unknown>): void;
16
+ setLinkColors(linkColors: Float32Array): void;
17
+ setLinks(links: Float32Array): void;
18
+ setLinkWidths(linkWidths: Float32Array): void;
19
+ setClusterPositions(clusterPositions: (number | undefined)[]): void;
20
+ setPinnedPoints(pinnedIndices: number[] | null): void;
21
+ setPointClusters(pointClusters: (number | undefined)[]): void;
22
+ setPointColors(pointColors: Float32Array): void;
23
+ setPointPositions(pointPositions: Float32Array, dontRescale?: boolean): void;
24
+ setPointShapes(pointShapes: Float32Array): void;
25
+ setPointSizes(pointSizes: Float32Array): void;
26
+ setZoomLevel(value: number, duration?: number): void;
27
+ setZoomTransformByPointPositions(positions: Float32Array, duration?: number, scale?: number): void;
28
+ spaceToScreenPosition(spacePosition: [number, number]): [number, number];
29
+ spaceToScreenRadius(spaceRadius: number): number;
30
+ start(alpha?: number): void;
31
+ trackPointPositionsByIndices(indices: number[]): void;
32
+ zoomToPointByIndex(index: number, duration?: number, scale?: number, canZoomOut?: boolean): void;
33
+ }
34
+ /** How the cosmos.gl driver behaves where the page does not say. */
35
+ export interface CosmosDriverOptions {
36
+ /** Whether each group's name is drawn over its notes. On by default. */
37
+ groupLabels?: boolean | undefined;
38
+ /**
39
+ * The longest a layout runs, in milliseconds, settled or not. 15 seconds by
40
+ * default, past where twenty thousand notes settle on a laptop's GPU.
41
+ */
42
+ layoutLimit?: number | undefined;
43
+ /** A note's shape where it does not say: `circle` by default, `square` or `diamond`. */
44
+ shape?: NodeLook['shape'];
45
+ /** The size of cosmos.gl's space, as its own `spaceSize` says. 4096 by default. */
46
+ spaceSize?: number | undefined;
47
+ }
48
+ /**
49
+ * Drives cosmos.gl for a Graph frame: the layout and the drawing both run on
50
+ * the GPU, which is the renderer to reach for past fifty thousand notes. Make
51
+ * the graph in a box inside the frame and pass both here, and
52
+ * `connectGraph` fills it:
53
+ *
54
+ * ```js
55
+ * const graph = new Graph(box, { enableSimulation: true })
56
+ * connectGraph(frame, cosmosDriver(graph, box))
57
+ * ```
58
+ *
59
+ * **cosmos.gl draws notes with its own shaders**, so a note's shape is
60
+ * honored and its `material` and `emphasis` are not, and it names nothing:
61
+ * the driver names the hovered note, the selected one and its neighbors, and
62
+ * each group, over the drawing, as the Sigma driver does.
63
+ *
64
+ * **It is `@cosmos.gl/graph`, which is MIT**, and not Cosmograph, which is
65
+ * licensed for noncommercial use only.
66
+ */
67
+ export declare const cosmosDriver: (graph: CosmosGraph, container: HTMLElement, options?: CosmosDriverOptions) => GraphDriver;