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.
- package/LICENSE +21 -0
- package/README.md +139 -0
- package/dist/corridors.d.ts +20 -0
- package/dist/corridors.js +8 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/layers.d.ts +39 -0
- package/dist/layers.js +36 -0
- package/dist/router.d.ts +109 -0
- package/dist/router.js +432 -0
- package/package.json +54 -0
- package/src/corridors.ts +29 -0
- package/src/index.ts +20 -0
- package/src/layers.ts +67 -0
- package/src/router.ts +519 -0
- package/src/svelte/GridDiagram.svelte +366 -0
- package/src/svelte/index.ts +21 -0
- package/src/svelte/types.ts +23 -0
|
@@ -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
|
+
}
|