@fusion.dev/helix-graph 0.185.0 → 0.186.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # @fusion.dev/helix-graph
2
2
 
3
3
  Connects the Helix `Graph` frame to whatever draws the graph: Sigma.js, cosmos.gl,
4
- three.js, or a driver of your own, colored from your tokens.
4
+ three.js, an isometric diagram in SVG, or a driver of your own, colored from
5
+ your tokens.
5
6
 
6
7
  ```sh
7
8
  bun add @fusion.dev/helix @fusion.dev/helix-graph sigma graphology graphology-layout-forceatlas2
@@ -332,24 +333,76 @@ connectGraph(frame, threeDriver(box), {
332
333
  with every fold the field's cells are too coarse to hold.
333
334
  - **Without a stencil, a layout runs in three dimensions** with d3-force-3d.
334
335
  - **Dragging turns the graph, and the wheel and a pinch bring it nearer.** It
335
- turns slowly by itself while nobody is handling it, and never for a reader
336
- who asks for less motion.
336
+ turns slowly by itself for its first five seconds, stops for good the moment
337
+ the reader handles it, and never turns for a reader who asks for less
338
+ motion. Motion that starts by itself and runs longer than five seconds needs
339
+ a pause control, and this one ends first.
337
340
  - **The far side recedes.** Through a graph, its far notes are a little
338
341
  quieter than its near ones.
339
- - **Edges are smooth at any density.** The drawing has half as many pixels again as the screen, at least two for each of the page's, and links are lines of a set width with smooth edges, where WebGL draws its own a device pixel wide.
342
+ - **Edges are smooth at any density.** On a screen worked with a mouse, the drawing has half as many pixels again as the screen, at least two for each of the page's. On a touch screen it has the screen's own, at most two, so a phone draws less than half the pixels it did. Links are lines of a set width with smooth edges, where WebGL draws its own a device pixel wide.
340
343
  - **Each group is named over its near side**, and a group's name moves a line
341
344
  where it would cover another's.
342
345
  - **It draws only while something moves**: the reader's hands, the turning, a
343
- layout, or a glide. It draws nothing while its box is out of view.
346
+ layout, or a glide. At rest it asks the browser for no frames at all, and it
347
+ draws nothing while its box is out of view.
348
+ - **Every graph on a page shares six WebGL contexts.** A browser holds only
349
+ about sixteen and drops the oldest past that. Past six, a graph far off
350
+ screen gives its context back, the one away longest first, and takes it
351
+ again within half a screen of coming into view. A graph whose context the
352
+ browser restores is drawn again at once.
344
353
 
345
354
  | `threeDriver` option | Default | What else it takes |
346
355
  |---|---|---|
347
- | `autoRotate` | `true` | `false` leaves the graph still until the reader turns it |
348
- | `density` | screen × 1.5, 2 to 3 | How many pixels the drawing has for each of the page's; fewer is faster on a slow GPU |
356
+ | `autoRotate` | `true` | `false` leaves the graph still until the reader turns it. It turns for five seconds at most |
357
+ | `density` | screen × 1.5, 2 to 3; on a touch screen, the screen's own, at most 2 | How many pixels the drawing has for each of the page's; fewer is faster on a slow GPU |
349
358
  | `shape` | `circle` | `square` or `diamond`, for every note that does not say; a `ring` is drawn as a circle |
350
359
  | `groupLabels` | `true` | `false` leaves each group unnamed |
351
360
  | `layoutLimit` | `10000` | The longest a layout runs, in milliseconds |
352
361
 
362
+ ## An isometric diagram
363
+
364
+ **`isometricDriver(box)` draws the graph as an isometric diagram in SVG**, for
365
+ architecture and flow diagrams. It needs no renderer:
366
+
367
+ ```js
368
+ import { isometricDriver } from '@fusion.dev/helix-graph/isometric'
369
+
370
+ connectGraph(frame, isometricDriver(frame.querySelector('div')))
371
+ ```
372
+
373
+ - Each note is a solid standing on a cell of the floor at its `x` and `y`. A
374
+ diagram that gives no note a place is laid out in columns along its links,
375
+ and a note added without one goes to the nearest free cell beside the notes
376
+ it links to.
377
+ - A note's `shape` picks its solid: `square` a block, `circle` or `ring` a
378
+ cylinder, and `diamond` a pyramid. Its size sets its height.
379
+ - Each face is shaded from the note's color, and each edge is drawn in an ink
380
+ that contrasts with its face, so the darkest note still reads as a solid.
381
+ - Each group stands on a plate in its color, with its name written along the
382
+ floor. Links run along the floor with an arrow halfway.
383
+ - The view frames the whole diagram, and frames it again when the box changes
384
+ size until the reader pans or zooms. Drag to pan and use the wheel to zoom,
385
+ as the frame's `gestures` allows.
386
+
387
+ - `turn` and `tilt` set where the diagram is seen from. `driver.turnBy(90)`
388
+ brings the next corner round, `driver.turnTo({ tilt: 90 })` looks from
389
+ straight above, and Shift with a drag turns and tilts it. The faces each
390
+ solid shows, and their shading, follow the camera.
391
+
392
+ | `isometricDriver` option | Default | What else it takes |
393
+ |---|---|---|
394
+ | `flat` | `false` | `true` draws every note at no height |
395
+ | `groupLabels` | `true` | `false` leaves each group's plate unnamed |
396
+ | `shape` | `square` | `circle`, `ring` or `diamond`, for every note that does not say |
397
+ | `tilt` | `35.26` | The camera's height above the floor in degrees, from `15` to `90`, a plan from straight above |
398
+ | `turn` | `45` | The compass bearing the diagram is seen from, in degrees |
399
+
400
+ | `isometricDriver` method | What it does |
401
+ |---|---|
402
+ | `camera()` | Where the diagram is seen from now: `{ turn, tilt }` |
403
+ | `turnBy(degrees, animate)` | Turns the view by `degrees`. Animated unless `animate` is false or the reader asked for reduced motion |
404
+ | `turnTo({ turn, tilt }, animate)` | Moves the camera to a bearing, a height, or both |
405
+
353
406
  ## What it exports
354
407
 
355
408
  | Import | What |
@@ -358,6 +411,7 @@ connectGraph(frame, threeDriver(box), {
358
411
  | `@fusion.dev/helix-graph/sigma` | `sigmaDriver(sigma, options)`, and the types `SigmaDriverOptions`, `SigmaGraph`, `SigmaLayout` and `SigmaRenderer` |
359
412
  | `@fusion.dev/helix-graph/cosmos` | `cosmosDriver(graph, box, options)`, and the types `CosmosDriverOptions` and `CosmosGraph` |
360
413
  | `@fusion.dev/helix-graph/three` | `threeDriver(box, options)`, and the type `ThreeDriverOptions` |
414
+ | `@fusion.dev/helix-graph/isometric` | `isometricDriver(box, options)`, `ISOMETRIC`, the camera it starts from, and the types `Camera`, `IsometricDriver` and `IsometricDriverOptions` |
361
415
  | `@fusion.dev/helix-graph/shapes` | `shapeFromTriangles`, `shapeFromSvg`, `shapeFromImage`, `shapeFromPath`, `shapeFromInside`, `inflate`, `sphere`, `torus`, `cuboid`, `onSphere`, `surfaceOf`, and the types `ImageOptions`, `ShapeField` and `TriangleOptions` |
362
416
  | `@fusion.dev/helix-graph/model` | `shapeFromModel(source, options)`, which needs three.js |
363
417
  | `@fusion.dev/helix-graph/snapshot` | `graphSnapshot(nodes, links, options)`, an SVG of the graph to paint before the renderer loads, and the types `GraphSnapshotOptions`, `SnapshotLink` and `SnapshotNode` |
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Where an isometric diagram puts each note, link, and group, as SVG path
3
+ * data, seen from a camera that can turn and tilt. Pure, so the arithmetic a
4
+ * picture hides is held by a spec: a solid painted in the wrong order is
5
+ * covered by the one it should cover, and a face turned away is drawn over
6
+ * the faces in front of it.
7
+ *
8
+ * **The floor is measured in cells.** A note at `x: 2, y: 1` stands two cells
9
+ * across and one back, and a solid's height is in the same unit. From the
10
+ * isometric camera, across runs down to the right on the page and back runs
11
+ * up to the right, as a plan is seen from its front left corner.
12
+ */
13
+ /** A point on the page, in cells. */
14
+ export interface Point {
15
+ x: number;
16
+ y: number;
17
+ }
18
+ /**
19
+ * Where the diagram is seen from, in degrees: `turn`, the compass bearing
20
+ * the camera looks from, and `tilt`, its height above the floor, from a low
21
+ * angle to 90, a plan seen straight down.
22
+ */
23
+ export interface Camera {
24
+ tilt: number;
25
+ turn: number;
26
+ }
27
+ /**
28
+ * The isometric camera: from the front left corner, at the height where the
29
+ * floor's two axes and the vertical are drawn the same length, 35.26 degrees.
30
+ */
31
+ export declare const ISOMETRIC: Camera;
32
+ /** The lowest a camera may tilt, below which the floor folds to a line. */
33
+ export declare const LOWEST_TILT = 15;
34
+ /** A camera's view of the floor. */
35
+ export interface Projection {
36
+ /** A place on the floor and a height, on the page. */
37
+ at(x: number, y: number, z?: number): Point;
38
+ /** How near the camera a place on the floor stands: the larger, the nearer. */
39
+ depth(x: number, y: number): number;
40
+ /** The place on the floor drawn at a point on the page. */
41
+ floorAt(point: Point): Point;
42
+ /** Toward the camera from the floor, a unit length: across, back, and up. */
43
+ toward: readonly [number, number, number];
44
+ /** Across the page, in floor directions: how far a step across or back goes to the right. */
45
+ across: readonly [number, number];
46
+ }
47
+ /**
48
+ * The view from `camera`, an orthographic one: the floor turned by `turn`
49
+ * and seen from `tilt` above it, with no perspective, so a cell is the same
50
+ * size wherever it stands.
51
+ */
52
+ export declare const projectionOf: (camera: Camera) => Projection;
53
+ /** The solids a note can be drawn as, one for each of the frame's shapes. */
54
+ export type Solid = 'block' | 'cylinder' | 'pyramid';
55
+ /** A note's shape, as the frame names it, and the solid that stands for it. */
56
+ export declare const solidOf: (shape: string | undefined) => Solid;
57
+ /** One face of a solid, as path data, and how far it is darkened toward black. */
58
+ export interface Face {
59
+ d: string;
60
+ /** From 0, the note's own color, as a top facing the light takes it. */
61
+ darken: number;
62
+ }
63
+ /** One note drawn as a solid: the faces the camera sees, in the order to paint them. */
64
+ export interface Drawn {
65
+ /** How high the solid reaches on the page: the top of its outline, which its name is drawn above. */
66
+ crown: number;
67
+ /** The faces the camera sees, the sides first and the top last. */
68
+ faces: Face[];
69
+ /** The outline of the whole solid, which a ring is drawn along. */
70
+ outline: string;
71
+ /** The middle of the solid's top, which its name is centered over. */
72
+ peak: Point;
73
+ }
74
+ /**
75
+ * A note as a solid, standing on the floor at `x` and `y`, `width` cells
76
+ * across its footprint and `height` cells tall, as the camera sees it.
77
+ *
78
+ * **Each face is drawn only where it turns toward the camera**, so a block
79
+ * shows its top and the two sides that face the viewer from any turn, and a
80
+ * plan seen from straight above shows tops alone.
81
+ */
82
+ export declare const solid: (projection: Projection, kind: Solid, x: number, y: number, width: number, height: number) => Drawn;
83
+ /**
84
+ * The order to paint notes in, back to front: by how near the camera each
85
+ * stands on the floor, and the taller first where two stand level.
86
+ *
87
+ * Exact for notes on whole cells, which do not overlap on the floor; two
88
+ * notes placed inside one cell can still cross.
89
+ */
90
+ export declare const depthOrder: <T extends {
91
+ height: number;
92
+ x: number;
93
+ y: number;
94
+ }>(projection: Projection, notes: readonly T[]) => T[];
95
+ /** A link drawn on the floor from the middle of one note's footprint to the other's. */
96
+ export declare const floorLine: (projection: Projection, from: Point, to: Point) => string;
97
+ /**
98
+ * A group's plate on the floor: the rectangle around its notes' cells,
99
+ * `margin` cells wider on every side, and where its name is written.
100
+ *
101
+ * **The name runs along the edge nearest the camera**, left to right on the
102
+ * page, laid on the floor: `transform` is the SVG matrix that writes text
103
+ * there, a font size in cells, from `start`, a cell of text running `along`
104
+ * on the page.
105
+ */
106
+ export declare const plate: (projection: Projection, places: readonly Point[], margin: number) => {
107
+ along: Point;
108
+ d: string;
109
+ start: Point;
110
+ transform: string;
111
+ } | undefined;
112
+ /**
113
+ * Places each note in `unplaced` on the nearest whole cell that keeps
114
+ * `spacing` cells from every note already placed, starting from where it was
115
+ * seeded, so a diagram given no places is a tidy grid with each note beside
116
+ * the ones it links to. A note the page placed keeps its place.
117
+ */
118
+ export declare const snapToCells: (seeded: ReadonlyMap<string, Point>, unplaced: ReadonlySet<string>, spacing?: number) => Map<string, Point>;
119
+ /**
120
+ * Places a diagram that gives no note a place, in columns along the flow of
121
+ * its links, `spacing` cells apart.
122
+ *
123
+ * **A note's column is the longest run of links that leads to it**, so a
124
+ * source stands first and everything it feeds stands after it, as a pipeline
125
+ * or a request through a service is read. Within a column the notes sort by
126
+ * their group, so a group's plate covers one stretch, and then by where the
127
+ * notes leading to them stand, so links cross as little as one pass can
128
+ * make them. A loop is cut where it would carry a note past the last column.
129
+ */
130
+ export declare const layered: (notes: readonly {
131
+ group?: string | undefined;
132
+ id: string;
133
+ }[], links: readonly {
134
+ source: string;
135
+ target: string;
136
+ }[], spacing?: number) => Map<string, Point>;
@@ -0,0 +1,280 @@
1
+ export const ISOMETRIC = { tilt: (Math.atan(Math.SQRT1_2) * 180) / Math.PI, turn: 45 };
2
+ export const LOWEST_TILT = 15;
3
+ const SCALE = Math.sqrt(1.5);
4
+ export const projectionOf = (camera) => {
5
+ const turn = (camera.turn * Math.PI) / 180;
6
+ const tilt = (Math.min(Math.max(camera.tilt, LOWEST_TILT), 90) * Math.PI) / 180;
7
+ const [cos, sin] = [Math.cos(turn), Math.sin(turn)];
8
+ const [rise, fall] = [Math.sin(tilt), Math.cos(tilt)];
9
+ return {
10
+ across: [cos, sin],
11
+ at: (x, y, z = 0) => ({
12
+ x: (x * cos + y * sin) * SCALE,
13
+ y: ((x * sin - y * cos) * rise - z * fall) * SCALE,
14
+ }),
15
+ depth: (x, y) => x * sin - y * cos,
16
+ floorAt: (point) => {
17
+ const u = point.x / SCALE;
18
+ const v = point.y / SCALE / rise;
19
+ return { x: u * cos + v * sin, y: u * sin - v * cos };
20
+ },
21
+ toward: [sin * fall, -cos * fall, rise],
22
+ };
23
+ };
24
+ export const solidOf = (shape) => shape === 'circle' || shape === 'ring' ? 'cylinder' : shape === 'diamond' ? 'pyramid' : 'block';
25
+ const short = (value) => String(Math.round(value * 1000) / 1000 || 0);
26
+ const path = (points) => `M${points.map((point) => `${short(point.x)} ${short(point.y)}`).join('L')}Z`;
27
+ const hullOf = (points) => {
28
+ const sorted = [...points].sort((one, other) => one.x - other.x || one.y - other.y);
29
+ if (sorted.length < 3) {
30
+ return sorted;
31
+ }
32
+ const cross = (o, a, b) => (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
33
+ const half = (list) => {
34
+ const kept = [];
35
+ for (const point of list) {
36
+ while (kept.length >= 2 && cross(kept.at(-2), kept.at(-1), point) <= 0) {
37
+ kept.pop();
38
+ }
39
+ kept.push(point);
40
+ }
41
+ kept.pop();
42
+ return kept;
43
+ };
44
+ return [...half(sorted), ...half([...sorted].reverse())];
45
+ };
46
+ const darkenOf = (normal, projection) => {
47
+ const [x, y, z] = normal;
48
+ const length = Math.hypot(x, y, z) || 1;
49
+ const level = Math.hypot(x, y) || 1;
50
+ const right = (x * projection.across[0] + y * projection.across[1]) / level;
51
+ return Math.max(0, 0.28 + 0.113 * right) * (1 - z / length);
52
+ };
53
+ const facing = (normal, projection) => normal[0] * projection.toward[0] +
54
+ normal[1] * projection.toward[1] +
55
+ normal[2] * projection.toward[2] >
56
+ 1e-6;
57
+ const ARC = 12;
58
+ export const solid = (projection, kind, x, y, width, height) => {
59
+ const half = width / 2;
60
+ const tall = Math.max(0, height);
61
+ const on = (corner) => projection.at(corner[0], corner[1], corner[2]);
62
+ if (kind === 'cylinder') {
63
+ const turn = Math.atan2(projection.across[1], projection.across[0]);
64
+ const rim = (z, from, to) => Array.from({ length: ARC + 1 }, (_, step) => {
65
+ const angle = from + ((to - from) * step) / ARC;
66
+ return projection.at(x + Math.cos(angle) * half, y + Math.sin(angle) * half, z);
67
+ });
68
+ const near = rim(0, turn + Math.PI, turn + 2 * Math.PI);
69
+ const nearTop = rim(tall, turn + Math.PI, turn + 2 * Math.PI);
70
+ const farTop = rim(tall, turn, turn + Math.PI);
71
+ const top = rim(tall, 0, 2 * Math.PI).slice(0, -1);
72
+ const side = projection.toward[2] < 0.9999 && tall > 0;
73
+ const outline = hullOf([...farTop, ...nearTop, ...near]);
74
+ return {
75
+ crown: Math.min(...outline.map((point) => point.y)),
76
+ faces: [
77
+ ...(side ? [{ d: path([...nearTop, ...[...near].reverse()]), darken: 0.28 }] : []),
78
+ { d: path(top), darken: 0 },
79
+ ],
80
+ outline: path(outline),
81
+ peak: projection.at(x, y, tall),
82
+ };
83
+ }
84
+ const floor = [
85
+ [x - half, y - half, 0],
86
+ [x + half, y - half, 0],
87
+ [x + half, y + half, 0],
88
+ [x - half, y + half, 0],
89
+ ];
90
+ const outward = [
91
+ [0, -1, 0],
92
+ [1, 0, 0],
93
+ [0, 1, 0],
94
+ [-1, 0, 0],
95
+ ];
96
+ const faces = [];
97
+ let corners;
98
+ let peak;
99
+ if (kind === 'pyramid') {
100
+ const apex = [x, y, Math.max(tall, width)];
101
+ corners = [...floor, apex];
102
+ peak = on(apex);
103
+ floor.forEach((corner, at) => {
104
+ const next = floor[(at + 1) % 4];
105
+ const [ox, oy] = outward[at];
106
+ const normal = [ox * apex[2], oy * apex[2], half];
107
+ if (facing(normal, projection)) {
108
+ faces.push({
109
+ d: path([on(corner), on(next), on(apex)]),
110
+ darken: darkenOf(normal, projection),
111
+ });
112
+ }
113
+ });
114
+ }
115
+ else {
116
+ const up = (corner) => [corner[0], corner[1], tall];
117
+ corners = [...floor, ...floor.map(up)];
118
+ peak = projection.at(x, y, tall);
119
+ if (tall > 0) {
120
+ floor.forEach((corner, at) => {
121
+ const next = floor[(at + 1) % 4];
122
+ const normal = outward[at];
123
+ if (facing(normal, projection)) {
124
+ faces.push({
125
+ d: path([on(up(corner)), on(up(next)), on(next), on(corner)]),
126
+ darken: darkenOf(normal, projection),
127
+ });
128
+ }
129
+ });
130
+ }
131
+ faces.push({ d: path(floor.map(up).map(on)), darken: 0 });
132
+ }
133
+ const outline = hullOf(corners.map(on));
134
+ return {
135
+ crown: Math.min(...outline.map((point) => point.y)),
136
+ faces,
137
+ outline: path(outline),
138
+ peak,
139
+ };
140
+ };
141
+ export const depthOrder = (projection, notes) => [...notes].sort((one, other) => {
142
+ const nearer = projection.depth(one.x, one.y) - projection.depth(other.x, other.y);
143
+ return Math.abs(nearer) > 1e-9 ? nearer : other.height - one.height;
144
+ });
145
+ export const floorLine = (projection, from, to) => {
146
+ const start = projection.at(from.x, from.y);
147
+ const end = projection.at(to.x, to.y);
148
+ return `M${short(start.x)} ${short(start.y)}L${short(end.x)} ${short(end.y)}`;
149
+ };
150
+ export const plate = (projection, places, margin) => {
151
+ if (places.length === 0) {
152
+ return undefined;
153
+ }
154
+ const xs = places.map((place) => place.x);
155
+ const ys = places.map((place) => place.y);
156
+ const low = { x: Math.min(...xs) - margin, y: Math.min(...ys) - margin };
157
+ const high = { x: Math.max(...xs) + margin, y: Math.max(...ys) + margin };
158
+ const corners = [
159
+ { x: low.x, y: low.y },
160
+ { x: high.x, y: low.y },
161
+ { x: high.x, y: high.y },
162
+ { x: low.x, y: high.y },
163
+ ];
164
+ const outward = [
165
+ { x: 0, y: -1 },
166
+ { x: 1, y: 0 },
167
+ { x: 0, y: 1 },
168
+ { x: -1, y: 0 },
169
+ ];
170
+ const facingOf = (normal) => normal.x * projection.toward[0] + normal.y * projection.toward[1];
171
+ const edge = [0, 1, 2, 3].reduce((best, at) => facingOf(outward[at]) > facingOf(outward[best]) ? at : best);
172
+ const normal = outward[edge];
173
+ let [from, to] = [corners[edge], corners[(edge + 1) % 4]];
174
+ if (projection.at(to.x, to.y).x < projection.at(from.x, from.y).x) {
175
+ ;
176
+ [from, to] = [to, from];
177
+ }
178
+ const length = Math.hypot(to.x - from.x, to.y - from.y) || 1;
179
+ const along = { x: (to.x - from.x) / length, y: (to.y - from.y) / length };
180
+ const origin = projection.at(0, 0);
181
+ const step = (direction) => {
182
+ const at = projection.at(direction.x, direction.y);
183
+ return { x: at.x - origin.x, y: at.y - origin.y };
184
+ };
185
+ const a = step(along);
186
+ const b = step(normal);
187
+ const start = projection.at(from.x + along.x * 0.15 - normal.x * 0.12, from.y + along.y * 0.15 - normal.y * 0.12);
188
+ return {
189
+ along: a,
190
+ d: path(corners.map((corner) => projection.at(corner.x, corner.y))),
191
+ start,
192
+ transform: `matrix(${[a.x, a.y, b.x, b.y, start.x, start.y].map(short).join(' ')})`,
193
+ };
194
+ };
195
+ export const snapToCells = (seeded, unplaced, spacing = 2) => {
196
+ const taken = [];
197
+ for (const [id, at] of seeded) {
198
+ if (!unplaced.has(id)) {
199
+ taken.push({ x: Math.round(at.x), y: Math.round(at.y) });
200
+ }
201
+ }
202
+ const free = (cell) => taken.every((other) => Math.max(Math.abs(other.x - cell.x), Math.abs(other.y - cell.y)) >= spacing);
203
+ const placed = new Map();
204
+ for (const id of unplaced) {
205
+ const at = seeded.get(id) ?? { x: 0, y: 0 };
206
+ const home = { x: Math.round(at.x), y: Math.round(at.y) };
207
+ for (let ring = 0;; ring++) {
208
+ const cells = [];
209
+ for (let dx = -ring; dx <= ring; dx++) {
210
+ for (let dy = -ring; dy <= ring; dy++) {
211
+ if (Math.max(Math.abs(dx), Math.abs(dy)) === ring) {
212
+ cells.push({ x: home.x + dx, y: home.y + dy });
213
+ }
214
+ }
215
+ }
216
+ cells.sort((one, other) => Math.hypot(one.x - at.x, one.y - at.y) - Math.hypot(other.x - at.x, other.y - at.y));
217
+ const cell = cells.find(free);
218
+ if (cell) {
219
+ taken.push(cell);
220
+ placed.set(id, cell);
221
+ break;
222
+ }
223
+ }
224
+ }
225
+ return placed;
226
+ };
227
+ export const layered = (notes, links, spacing = 2) => {
228
+ const ids = notes.map((note) => note.id);
229
+ const known = new Set(ids);
230
+ const into = new Map(ids.map((id) => [id, []]));
231
+ for (const { source, target } of links) {
232
+ if (known.has(source) && known.has(target) && source !== target) {
233
+ into.get(target)?.push(source);
234
+ }
235
+ }
236
+ const column = new Map(ids.map((id) => [id, 0]));
237
+ for (let pass = 0; pass < ids.length; pass++) {
238
+ let moved = false;
239
+ for (const id of ids) {
240
+ for (const from of into.get(id) ?? []) {
241
+ const next = (column.get(from) ?? 0) + 1;
242
+ if (next > (column.get(id) ?? 0) && next < ids.length) {
243
+ column.set(id, next);
244
+ moved = true;
245
+ }
246
+ }
247
+ }
248
+ if (!moved) {
249
+ break;
250
+ }
251
+ }
252
+ const groupOf = new Map(notes.map((note) => [note.id, note.group ?? '']));
253
+ const columns = [];
254
+ for (const id of ids) {
255
+ const at = column.get(id) ?? 0;
256
+ columns[at] = [...(columns[at] ?? []), id];
257
+ }
258
+ const row = new Map();
259
+ const placed = new Map();
260
+ columns.forEach((members = [], at) => {
261
+ const pull = (id) => {
262
+ const rows = (into.get(id) ?? []).flatMap((from) => {
263
+ const one = row.get(from);
264
+ return one === undefined ? [] : [one];
265
+ });
266
+ return rows.length === 0
267
+ ? members.indexOf(id)
268
+ : rows.reduce((sum, one) => sum + one, 0) / rows.length;
269
+ };
270
+ const sorted = [...members].sort((one, other) => (groupOf.get(one) ?? '').localeCompare(groupOf.get(other) ?? '') || pull(one) - pull(other));
271
+ sorted.forEach((id, index) => {
272
+ row.set(id, index - (sorted.length - 1) / 2);
273
+ placed.set(id, {
274
+ x: at * spacing,
275
+ y: -Math.round((index - (sorted.length - 1) / 2) * spacing) || 0,
276
+ });
277
+ });
278
+ });
279
+ return placed;
280
+ };
@@ -0,0 +1,2 @@
1
+ export { type Camera, ISOMETRIC } from './geometry.js';
2
+ export { type IsometricDriver, type IsometricDriverOptions, isometricDriver, } from './isometric-driver.js';
@@ -0,0 +1,2 @@
1
+ export { ISOMETRIC } from './geometry.js';
2
+ export { isometricDriver, } from './isometric-driver.js';
@@ -0,0 +1,75 @@
1
+ import type { GraphDriver } from '../connect-graph/connect-graph.js';
2
+ import { type NodeLook } from '../connect-graph/looks.js';
3
+ import { type Camera } from './geometry.js';
4
+ /** How the diagram is drawn where a note does not say. */
5
+ export interface IsometricDriverOptions {
6
+ /** Draws every note at no height, which lays the diagram flat on its floor. */
7
+ flat?: boolean | undefined;
8
+ /** Whether each group's name is written on its plate. On by default. */
9
+ groupLabels?: boolean | undefined;
10
+ /**
11
+ * A note's solid where it gives no `shape`: `square`, a block, by default;
12
+ * `circle` or `ring`, a cylinder; `diamond`, a pyramid.
13
+ */
14
+ shape?: NodeLook['shape'];
15
+ /**
16
+ * How high above the floor the diagram is seen from, in degrees: 35.26,
17
+ * the isometric angle, by default, down to 15, and up to 90, a plan seen
18
+ * from straight above.
19
+ */
20
+ tilt?: number | undefined;
21
+ /**
22
+ * The compass bearing the diagram is seen from, in degrees: 45, its front
23
+ * left corner, by default. 135 is its front right, 225 its back right, and
24
+ * 315 its back left.
25
+ */
26
+ turn?: number | undefined;
27
+ }
28
+ /** The isometric driver, which a page can also turn. */
29
+ export interface IsometricDriver extends GraphDriver {
30
+ /** Where the diagram is seen from now: its `turn` and `tilt`, in degrees. */
31
+ camera(): Camera;
32
+ /**
33
+ * Turns the view about the middle of the box by `degrees`: 90 brings the
34
+ * next corner round to the front. Animated unless `animate` is false or the
35
+ * reader asked for less motion.
36
+ */
37
+ turnBy(degrees: number, animate?: boolean): void;
38
+ /** Moves the camera to a `turn` or a `tilt`, or both, as `turnBy` moves it. */
39
+ turnTo(camera: Partial<Camera>, animate?: boolean): void;
40
+ }
41
+ /**
42
+ * Draws the graph of a `helix-graph` frame as an isometric diagram in SVG,
43
+ * for architecture and flow diagrams. Pass it to `connectGraph` with the
44
+ * element to draw in:
45
+ *
46
+ * ```js
47
+ * import { connectGraph } from '@fusion.dev/helix-graph'
48
+ * import { isometricDriver } from '@fusion.dev/helix-graph/isometric'
49
+ *
50
+ * connectGraph(frame, isometricDriver(frame.querySelector('.diagram')))
51
+ * ```
52
+ *
53
+ * **Each note is a solid on a floor of cells**, standing at its `x` and `y`:
54
+ * a note at `x: 2, y: 1` is two cells across and one back. Its `shape` picks
55
+ * the solid, a square a block, a circle a cylinder, and a diamond a pyramid,
56
+ * and its size sets its height. A diagram that gives no note a place is laid
57
+ * out in columns along its links, and a note added without one goes to the
58
+ * nearest free cell beside the notes it links to.
59
+ *
60
+ * **The faces are shaded from the note's own color**, its tone's or its
61
+ * group's, so the diagram takes the theme. Links run along the floor with an
62
+ * arrow halfway that points the way each runs, and each group stands on a
63
+ * plate of its color with its name written on the floor.
64
+ *
65
+ * **SVG and nothing else**: no renderer to install, no WebGL context, and
66
+ * text as crisp as the page's. The frame's list, search, and keys work as
67
+ * they do with every driver. Drag to pan and use the wheel to zoom, as the
68
+ * frame's `gestures` allows.
69
+ *
70
+ * **The view turns and tilts.** `turn` and `tilt` set where it is seen from,
71
+ * `turnBy(90)` brings the next corner round, and Shift with a drag turns it
72
+ * across and tilts it up and down. The faces a solid shows and how each is
73
+ * shaded follow the camera, with the light from the upper left of the page.
74
+ */
75
+ export declare const isometricDriver: (container: HTMLElement, options?: IsometricDriverOptions) => IsometricDriver;