grid-router 0.1.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.
@@ -0,0 +1,366 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte';
3
+ import { corridorGaps } from '../corridors.js';
4
+ import {
5
+ routeGrid,
6
+ type GridBox,
7
+ type GridConn,
8
+ type GridDebugCell,
9
+ type GridEdge,
10
+ type GridOpts
11
+ } from '../router.js';
12
+ import type { ConnStyle, RoutedInfo } from './types.js';
13
+
14
+ /**
15
+ * Generic grid-routed diagram canvas.
16
+ *
17
+ * The CONSUMER owns the chips: lay out any markup inside the default
18
+ * snippet and tag each connectable element with the provided `register`
19
+ * action (`use:register={'node-id'}`). The component measures registered
20
+ * elements, routes the edges on a grid, and draws the connector overlay.
21
+ * Chip events belong to the consumer's own DOM; connector events are
22
+ * surfaced via the onconn* callbacks, and per-connector styling (color,
23
+ * dash, arrow end, highlight classes) via the reactive `connStyle` hook.
24
+ *
25
+ * The canvas is sized by the consumer like any element — the component
26
+ * measures its own container; there is no width/height prop.
27
+ */
28
+ type Register = (el: HTMLElement, id: string) => { destroy(): void };
29
+
30
+ let {
31
+ edges,
32
+ opts = {},
33
+ connStyle,
34
+ hops = true,
35
+ showGrid = false,
36
+ showOccupancy = false,
37
+ extraHeight = 40,
38
+ revision = undefined,
39
+ onrouted,
40
+ onconnenter,
41
+ onconnleave,
42
+ onconnclick,
43
+ children
44
+ }: {
45
+ edges: GridEdge[];
46
+ opts?: GridOpts;
47
+ /** per-connector rendering directives; called reactively */
48
+ connStyle?: (conn: GridConn) => ConnStyle;
49
+ /** crossing disambiguation: each bus cuts a small gap into the buses it
50
+ * crosses (the cut line "tunnels" under) */
51
+ hops?: boolean;
52
+ showGrid?: boolean;
53
+ showOccupancy?: boolean;
54
+ /** extra routable px below the content */
55
+ extraHeight?: number;
56
+ /** bump/replace to force a re-measure + re-route when chips MOVE without
57
+ * the canvas resizing (dynamic layouts) — pass e.g. your positions array */
58
+ revision?: unknown;
59
+ onrouted?: (info: RoutedInfo) => void;
60
+ onconnenter?: (conn: GridConn) => void;
61
+ onconnleave?: (conn: GridConn) => void;
62
+ onconnclick?: (conn: GridConn, ev: MouseEvent) => void;
63
+ children: Snippet<[Register]>;
64
+ } = $props();
65
+
66
+ const uid = $props.id();
67
+ const res = $derived(opts.res ?? 12);
68
+ const gaps = $derived(corridorGaps(res));
69
+
70
+ const DEFAULT_COLOR = '#8b94a1';
71
+ const styleOf = (c: GridConn): Required<ConnStyle> => {
72
+ const s = connStyle?.(c) ?? {};
73
+ return {
74
+ color: s.color ?? DEFAULT_COLOR,
75
+ dashed: s.dashed ?? false,
76
+ arrowAt: s.arrowAt ?? 'end',
77
+ class: s.class ?? ''
78
+ };
79
+ };
80
+
81
+ // --- measure registered chips, route, expose results ---
82
+ let canvasEl = $state<HTMLElement>();
83
+ const chipEls = new Map<string, HTMLElement>();
84
+ let conns = $state<GridConn[]>([]);
85
+ let debugCells = $state<GridDebugCell[]>([]);
86
+ let violations = $state(0);
87
+ let svgW = $state(0);
88
+ let svgH = $state(0);
89
+ let overhang = $state(16);
90
+
91
+ let raf = 0;
92
+ function scheduleMeasure() {
93
+ if (typeof requestAnimationFrame === 'undefined') return;
94
+ cancelAnimationFrame(raf);
95
+ raf = requestAnimationFrame(measure);
96
+ }
97
+
98
+ const register: Register = (el, id) => {
99
+ chipEls.set(id, el);
100
+ scheduleMeasure();
101
+ return {
102
+ destroy() {
103
+ chipEls.delete(id);
104
+ scheduleMeasure();
105
+ }
106
+ };
107
+ };
108
+
109
+ function measure() {
110
+ const canvas = canvasEl;
111
+ if (!canvas) return;
112
+ const base = canvas.getBoundingClientRect();
113
+ const boxes = new Map<string, GridBox>();
114
+ for (const [id, el] of chipEls) {
115
+ const r = el.getBoundingClientRect();
116
+ boxes.set(id, {
117
+ t: r.top - base.top,
118
+ b: r.bottom - base.top,
119
+ l: r.left - base.left,
120
+ r: r.right - base.left,
121
+ cx: r.left - base.left + r.width / 2,
122
+ cy: r.top - base.top + r.height / 2
123
+ });
124
+ }
125
+ const t0 = performance.now();
126
+ const routed = routeGrid(boxes, edges, base.width, canvas.scrollHeight + extraHeight, opts);
127
+ const ms = Math.round((performance.now() - t0) * 10) / 10;
128
+ conns = routed.conns;
129
+ violations = routed.violations;
130
+ debugCells = routed.debug.cells;
131
+ const contentBottom = Math.max(0, ...[...boxes.values()].map((b) => b.b));
132
+ overhang = Math.max(16, Math.ceil(routed.bottom - contentBottom + 10));
133
+ svgW = base.width;
134
+ svgH = Math.max(canvas.scrollHeight, Math.ceil(routed.bottom + 10));
135
+ onrouted?.({ conns: routed.conns, violations: routed.violations, ms });
136
+ }
137
+
138
+ $effect(() => {
139
+ void edges;
140
+ void opts;
141
+ void revision;
142
+ scheduleMeasure();
143
+ });
144
+
145
+ $effect(() => {
146
+ const canvas = canvasEl;
147
+ if (!canvas || typeof ResizeObserver === 'undefined') return;
148
+ const ro = new ResizeObserver(() => scheduleMeasure());
149
+ ro.observe(canvas);
150
+ return () => ro.disconnect();
151
+ });
152
+
153
+ // One marker pair (normal + start-reversed) per distinct color.
154
+ const markerColors = $derived([...new Set(conns.map((c) => styleOf(c).color))]);
155
+ const markerId = (color: string, rev: boolean) =>
156
+ `gr-arrow${rev ? '-rev' : ''}-${uid}-${markerColors.indexOf(color)}`;
157
+
158
+ // Bus groups for crossing "hops": each group paints a background halo under
159
+ // its colored paths, cutting a gap into every EARLIER bus it crosses — the
160
+ // cut line reads as tunneling under. Per-bus grouping keeps a bus's own
161
+ // T-junctions intact (its color always repaints over its own halo).
162
+ const busGroups = $derived.by(() => {
163
+ const m = new Map<string, GridConn[]>();
164
+ for (const c of conns) {
165
+ const arr = m.get(c.bus);
166
+ if (arr) arr.push(c);
167
+ else m.set(c.bus, [c]);
168
+ }
169
+ return [...m.values()];
170
+ });
171
+
172
+ const hasConnEvents = $derived(!!(onconnenter || onconnleave || onconnclick));
173
+ </script>
174
+
175
+ <div
176
+ class="gr-canvas"
177
+ bind:this={canvasEl}
178
+ style:padding-bottom="{overhang}px"
179
+ style:padding-inline="{gaps.sidePad}px"
180
+ style:--gr-row-gap="{gaps.rowGap}px"
181
+ style:--gr-chip-gap="{gaps.chipGap}px"
182
+ >
183
+ <svg class="gr-connectors" width={svgW} height={svgH} viewBox="0 0 {svgW} {svgH}" aria-hidden="true">
184
+ {#if showGrid}
185
+ <defs>
186
+ <pattern id="gr-grid-{uid}" width={res} height={res} patternUnits="userSpaceOnUse">
187
+ <path d="M {res} 0 L 0 0 0 {res}" fill="none" stroke="currentColor" stroke-width="0.4" />
188
+ </pattern>
189
+ </defs>
190
+ <rect width={svgW} height={svgH} class="gr-grid-fill" fill="url(#gr-grid-{uid})" />
191
+ {/if}
192
+ {#if showOccupancy}
193
+ {#each debugCells as c (c.y * 100000 + c.x)}
194
+ <rect class="gr-occ gr-occ-{c.kind}" x={c.x * res} y={c.y * res} width={res} height={res} />
195
+ {/each}
196
+ {/if}
197
+ <defs>
198
+ {#each markerColors as color, i (color)}
199
+ <marker
200
+ id="gr-arrow-{uid}-{i}"
201
+ markerWidth="8"
202
+ markerHeight="7"
203
+ refX="7"
204
+ refY="3.5"
205
+ orient="auto"
206
+ markerUnits="userSpaceOnUse"
207
+ >
208
+ <path d="M1,1 L7,3.5 L1,6 Z" fill={color} />
209
+ </marker>
210
+ <marker
211
+ id="gr-arrow-rev-{uid}-{i}"
212
+ markerWidth="8"
213
+ markerHeight="7"
214
+ refX="7"
215
+ refY="3.5"
216
+ orient="auto-start-reverse"
217
+ markerUnits="userSpaceOnUse"
218
+ >
219
+ <path d="M1,1 L7,3.5 L1,6 Z" fill={color} />
220
+ </marker>
221
+ {/each}
222
+ </defs>
223
+ {#each busGroups as group, gi (gi)}
224
+ <g>
225
+ {#if hops}
226
+ <!-- halo capped below the lane pitch so it can't eat an
227
+ adjacent lane at small cell sizes -->
228
+ {#each group as c (`${c.id}:halo`)}
229
+ <path class="gr-halo" style:stroke-width="{Math.min(5.5, res - 1.5)}px" d={c.d} />
230
+ {/each}
231
+ {/if}
232
+ {#each group as c (c.id)}
233
+ {@const s = styleOf(c)}
234
+ <path
235
+ class="gr-conn {s.class}"
236
+ d={c.d}
237
+ stroke={s.color}
238
+ stroke-dasharray={s.dashed ? '4 3' : undefined}
239
+ marker-start={s.arrowAt === 'start' ? `url(#${markerId(s.color, true)})` : undefined}
240
+ marker-end={s.arrowAt === 'end' ? `url(#${markerId(s.color, false)})` : undefined}
241
+ />
242
+ {/each}
243
+ </g>
244
+ {/each}
245
+ </svg>
246
+
247
+ <div class="gr-content">
248
+ {@render children(register)}
249
+ </div>
250
+
251
+ {#if hasConnEvents}
252
+ <!-- Invisible wide hit paths so thin connectors are hoverable. Own layer
253
+ ABOVE the content: the content div would otherwise swallow pointer
254
+ events over the whole canvas. Safe because routed paths never run
255
+ under chips and hit strokes end short of chip edges. -->
256
+ <svg class="gr-hits" width={svgW} height={svgH} viewBox="0 0 {svgW} {svgH}" aria-hidden="true">
257
+ <!-- hit zone = the arrow's LANE: one cell wide, matching the territory
258
+ occupancy reserved for it — zones never overlap ambiguously -->
259
+ {#each conns as c (`${c.id}:hit`)}
260
+ <path
261
+ class="gr-hit"
262
+ style:stroke-width="{res}px"
263
+ d={c.d}
264
+ role="presentation"
265
+ onpointerenter={() => onconnenter?.(c)}
266
+ onpointerleave={() => onconnleave?.(c)}
267
+ onclick={(ev) => onconnclick?.(c, ev)}
268
+ />
269
+ {/each}
270
+ </svg>
271
+ {/if}
272
+ </div>
273
+
274
+ {#if violations > 0}
275
+ <div class="gr-violations" role="status">
276
+ {violations} lane violation{violations === 1 ? '' : 's'} — corridor supply too tight (see
277
+ corridorGaps)
278
+ </div>
279
+ {/if}
280
+
281
+ <style>
282
+ /* Positioning context for the connector overlay. Side padding keeps the
283
+ outer grid columns routable; bottom padding reserves the routed extent.
284
+ --gr-row-gap / --gr-chip-gap are published for the consumer's layout. */
285
+ .gr-canvas {
286
+ position: relative;
287
+ }
288
+
289
+ .gr-connectors {
290
+ position: absolute;
291
+ top: 0;
292
+ left: 0;
293
+ overflow: visible;
294
+ pointer-events: none;
295
+ z-index: 0;
296
+ }
297
+
298
+ .gr-content {
299
+ position: relative;
300
+ z-index: 1;
301
+ }
302
+
303
+ .gr-conn {
304
+ fill: none;
305
+ stroke-width: 1.3;
306
+ }
307
+
308
+ /* Crossing hop: background-colored halo painted under each bus; set
309
+ --grid-diagram-bg to the page/panel background color. */
310
+ .gr-halo {
311
+ fill: none;
312
+ stroke: var(--grid-diagram-bg, #ffffff);
313
+ }
314
+
315
+ .gr-hits {
316
+ position: absolute;
317
+ top: 0;
318
+ left: 0;
319
+ overflow: visible;
320
+ pointer-events: none;
321
+ z-index: 2;
322
+ }
323
+
324
+ .gr-hit {
325
+ fill: none;
326
+ stroke: transparent;
327
+ pointer-events: stroke;
328
+ }
329
+
330
+ .gr-grid-fill {
331
+ color: currentColor;
332
+ opacity: 0.15;
333
+ }
334
+
335
+ .gr-occ {
336
+ opacity: 0.22;
337
+ }
338
+
339
+ .gr-occ-chip {
340
+ fill: #8b94a1;
341
+ }
342
+
343
+ .gr-occ-h {
344
+ fill: #6e8fe8;
345
+ }
346
+
347
+ .gr-occ-v {
348
+ fill: #d55181;
349
+ }
350
+
351
+ .gr-occ-hv {
352
+ fill: #c2842f;
353
+ }
354
+
355
+ .gr-occ-bad {
356
+ fill: #f0564f;
357
+ opacity: 0.8;
358
+ }
359
+
360
+ .gr-violations {
361
+ margin-top: 6px;
362
+ font-size: 11px;
363
+ font-weight: 700;
364
+ color: #f0564f;
365
+ }
366
+ </style>
@@ -0,0 +1,21 @@
1
+ export { default as GridDiagram } from './GridDiagram.svelte';
2
+ export type { ConnStyle, RoutedInfo } from './types.js';
3
+ export {
4
+ routeGrid,
5
+ corridorGaps,
6
+ withLayers,
7
+ portsLayer,
8
+ keepOutLayer,
9
+ corridorLayer,
10
+ BOX_INFLATE,
11
+ type Rect,
12
+ type RouterLayer,
13
+ type GridBox,
14
+ type GridEdge,
15
+ type GridConn,
16
+ type GridOpts,
17
+ type GridResult,
18
+ type GridDebugCell,
19
+ type BoxSide,
20
+ type CorridorGaps
21
+ } from '../index.js';
@@ -0,0 +1,23 @@
1
+ import type { GridConn } from '../router.js';
2
+
3
+ /** Per-connector rendering directives, returned by the consumer's
4
+ * `connStyle(conn)` callback (called reactively on every render). */
5
+ export interface ConnStyle {
6
+ /** stroke color (default #8b94a1) */
7
+ color?: string;
8
+ /** dashed stroke (e.g. "pending" semantics) */
9
+ dashed?: boolean;
10
+ /** which end carries the arrowhead: 'end' = at target (default),
11
+ * 'start' = back at the source (reversed buses), 'none' */
12
+ arrowAt?: 'start' | 'end' | 'none';
13
+ /** extra class(es) on the path — hook for consumer-driven highlight/dim
14
+ * (target them with :global() from the consumer component) */
15
+ class?: string;
16
+ }
17
+
18
+ export interface RoutedInfo {
19
+ conns: GridConn[];
20
+ violations: number;
21
+ /** routing time in ms */
22
+ ms: number;
23
+ }