@poodle64/ui 2026.10.16 → 2026.10.18
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 +135 -11
- package/dist/components/feedback/report-widget.svelte +4 -1
- package/dist/components/feedback/report-widget.svelte.d.ts +1 -0
- package/dist/components/ui/app-identity/app-identity.svelte +7 -2
- package/dist/components/ui/app-shell/texture.d.ts +5 -5
- package/dist/components/ui/app-shell/texture.js +3 -3
- package/dist/components/ui/breadcrumbs/breadcrumbs.svelte +70 -0
- package/dist/components/ui/breadcrumbs/breadcrumbs.svelte.d.ts +32 -0
- package/dist/components/ui/breadcrumbs/index.d.ts +2 -0
- package/dist/components/ui/breadcrumbs/index.js +2 -0
- package/dist/components/ui/content-skeleton/content-skeleton.svelte +94 -0
- package/dist/components/ui/content-skeleton/content-skeleton.svelte.d.ts +25 -0
- package/dist/components/ui/content-skeleton/index.d.ts +2 -0
- package/dist/components/ui/content-skeleton/index.js +2 -0
- package/dist/components/ui/document-reader/document-reader.svelte +336 -0
- package/dist/components/ui/document-reader/document-reader.svelte.d.ts +65 -0
- package/dist/components/ui/document-reader/index.d.ts +2 -0
- package/dist/components/ui/document-reader/index.js +2 -0
- package/dist/components/ui/graph-canvas/graph-canvas.svelte +922 -0
- package/dist/components/ui/graph-canvas/graph-canvas.svelte.d.ts +73 -0
- package/dist/components/ui/graph-canvas/index.d.ts +3 -0
- package/dist/components/ui/graph-canvas/index.js +2 -0
- package/dist/components/ui/graph-canvas/scene.d.ts +107 -0
- package/dist/components/ui/graph-canvas/scene.js +562 -0
- package/dist/components/ui/graph-canvas/types.d.ts +33 -0
- package/dist/components/ui/graph-canvas/types.js +1 -0
- package/dist/components/ui/page-container/index.d.ts +2 -0
- package/dist/components/ui/page-container/index.js +2 -0
- package/dist/components/ui/page-container/page-container.svelte +39 -0
- package/dist/components/ui/page-container/page-container.svelte.d.ts +24 -0
- package/dist/components/ui/page-header/page-header.svelte +12 -7
- package/dist/components/ui/page-header/page-header.svelte.d.ts +3 -2
- package/dist/components/ui/page-section/index.d.ts +2 -0
- package/dist/components/ui/page-section/index.js +2 -0
- package/dist/components/ui/page-section/page-section.svelte +92 -0
- package/dist/components/ui/page-section/page-section.svelte.d.ts +36 -0
- package/dist/components/ui/sheet/index.d.ts +13 -0
- package/dist/components/ui/sheet/index.js +15 -0
- package/dist/components/ui/sheet/sheet-body.svelte +21 -0
- package/dist/components/ui/sheet/sheet-body.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-close.svelte +11 -0
- package/dist/components/ui/sheet/sheet-close.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-content.svelte +70 -0
- package/dist/components/ui/sheet/sheet-content.svelte.d.ts +37 -0
- package/dist/components/ui/sheet/sheet-description.svelte +20 -0
- package/dist/components/ui/sheet/sheet-description.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-footer.svelte +23 -0
- package/dist/components/ui/sheet/sheet-footer.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-header.svelte +21 -0
- package/dist/components/ui/sheet/sheet-header.svelte.d.ts +5 -0
- package/dist/components/ui/sheet/sheet-overlay.svelte +20 -0
- package/dist/components/ui/sheet/sheet-overlay.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-portal.svelte +7 -0
- package/dist/components/ui/sheet/sheet-portal.svelte.d.ts +3 -0
- package/dist/components/ui/sheet/sheet-title.svelte +21 -0
- package/dist/components/ui/sheet/sheet-title.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet-trigger.svelte +11 -0
- package/dist/components/ui/sheet/sheet-trigger.svelte.d.ts +4 -0
- package/dist/components/ui/sheet/sheet.svelte +7 -0
- package/dist/components/ui/sheet/sheet.svelte.d.ts +3 -0
- package/dist/components/ui/skeleton/skeleton.svelte +1 -1
- package/dist/styles.css +8 -0
- package/package.json +4 -2
- package/registry/component-map.json +58 -5
- package/registry/component-map.md +17 -4
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph's world: d3-force for the layout, a Canvas 2D painter, the camera
|
|
3
|
+
* and hit-testing. No Svelte in here; the component owns the loop, the
|
|
4
|
+
* pointer and the chrome, and calls in.
|
|
5
|
+
*
|
|
6
|
+
* Paint cost is bounded at any size: edges and nodes are culled to the
|
|
7
|
+
* viewport and stroked as a handful of batched paths, labels are placed
|
|
8
|
+
* greedily in screen space against a grid of what is already drawn, and the
|
|
9
|
+
* label pass stops at a fixed budget, so a few thousand nodes paint in a few
|
|
10
|
+
* milliseconds and the layout's own ticks are the cost that dominates.
|
|
11
|
+
*/
|
|
12
|
+
import { forceCollide, forceLink, forceManyBody, forceSimulation, forceX, forceY } from 'd3-force';
|
|
13
|
+
const MAX_LABELS = 240;
|
|
14
|
+
/** Screen area per label at rest; past the quota only a node drawn large enough is named. */
|
|
15
|
+
const LABEL_AREA = 30_000;
|
|
16
|
+
const MAX_LABEL_TRIES = 1500;
|
|
17
|
+
const MAX_EDGE_LABELS = 160;
|
|
18
|
+
const CELL = 48;
|
|
19
|
+
export class GraphScene {
|
|
20
|
+
nodes = [];
|
|
21
|
+
edges = [];
|
|
22
|
+
byId = new Map();
|
|
23
|
+
adj = new Map();
|
|
24
|
+
/** Label priority, fixed per data set: emphasis, then degree. */
|
|
25
|
+
ranked = [];
|
|
26
|
+
cam = { k: 1, x: 0, y: 0 };
|
|
27
|
+
width = 0;
|
|
28
|
+
height = 0;
|
|
29
|
+
dpr = 1;
|
|
30
|
+
palette = null;
|
|
31
|
+
shapes = new Map();
|
|
32
|
+
hidden = new Set();
|
|
33
|
+
directed = false;
|
|
34
|
+
selected = null;
|
|
35
|
+
hovered = null;
|
|
36
|
+
matches = new Set();
|
|
37
|
+
sim;
|
|
38
|
+
link = forceLink([]);
|
|
39
|
+
charge = forceManyBody().strength((d) => -28 - 14 * Math.sqrt(d.degree));
|
|
40
|
+
collide = forceCollide((d) => d.r + 3);
|
|
41
|
+
ticks = 0;
|
|
42
|
+
gx = forceX(0).strength(0.05);
|
|
43
|
+
gy = forceY(0).strength(0.05);
|
|
44
|
+
widths = new Map();
|
|
45
|
+
grid = new Map();
|
|
46
|
+
boxes = [];
|
|
47
|
+
constructor() {
|
|
48
|
+
this.link.distance((l) => 24 + l.source.r + l.target.r);
|
|
49
|
+
this.sim = forceSimulation([])
|
|
50
|
+
.stop()
|
|
51
|
+
.alphaMin(0.008)
|
|
52
|
+
.alphaDecay(0.0228)
|
|
53
|
+
.velocityDecay(0.4)
|
|
54
|
+
.force('charge', this.charge)
|
|
55
|
+
.force('link', this.link)
|
|
56
|
+
// Overlap only matters once the graph has opened out, and collision is
|
|
57
|
+
// a third of a tick's cost: it waits for the layout to cool, then
|
|
58
|
+
// resolves on every other tick, which still converges.
|
|
59
|
+
.force('collide', Object.assign((alpha) => (alpha < 0.45 && ++this.ticks % 2 ? this.collide(alpha) : undefined), {
|
|
60
|
+
initialize: (nodes, random) => this.collide.initialize(nodes, random)
|
|
61
|
+
}))
|
|
62
|
+
.force('x', this.gx)
|
|
63
|
+
.force('y', this.gy);
|
|
64
|
+
}
|
|
65
|
+
/** Gravity follows the frame's shape, so a wide view lays out wide and a phone tall. */
|
|
66
|
+
setAspect(width, height) {
|
|
67
|
+
const ratio = Math.min(Math.max(width / Math.max(height, 1), 0.4), 2.5);
|
|
68
|
+
this.gx.strength(0.05 * ratio ** -0.75);
|
|
69
|
+
this.gy.strength(0.05 * ratio ** 0.75);
|
|
70
|
+
}
|
|
71
|
+
/** Replace the data, keeping every surviving node where it is. */
|
|
72
|
+
setData(nodes, edges) {
|
|
73
|
+
const prev = this.byId;
|
|
74
|
+
const byId = new Map();
|
|
75
|
+
const fresh = [];
|
|
76
|
+
for (const node of nodes) {
|
|
77
|
+
if (byId.has(node.id))
|
|
78
|
+
continue;
|
|
79
|
+
const old = prev.get(node.id);
|
|
80
|
+
const s = old ?? { id: node.id, node, x: NaN, y: NaN, r: 0, degree: 0 };
|
|
81
|
+
s.node = node;
|
|
82
|
+
s.degree = 0;
|
|
83
|
+
if (!old)
|
|
84
|
+
fresh.push(s);
|
|
85
|
+
byId.set(node.id, s);
|
|
86
|
+
}
|
|
87
|
+
const adj = new Map();
|
|
88
|
+
for (const s of byId.values())
|
|
89
|
+
adj.set(s, new Set());
|
|
90
|
+
const pairs = new Map();
|
|
91
|
+
const list = [];
|
|
92
|
+
for (const edge of edges) {
|
|
93
|
+
const a = byId.get(edge.source);
|
|
94
|
+
const b = byId.get(edge.target);
|
|
95
|
+
if (!a || !b || a === b)
|
|
96
|
+
continue;
|
|
97
|
+
const key = a.id < b.id ? `${a.id}\u0000${b.id}` : `${b.id}\u0000${a.id}`;
|
|
98
|
+
const i = pairs.get(key) ?? 0;
|
|
99
|
+
pairs.set(key, i + 1);
|
|
100
|
+
// 1, -1, 2, -2 … relative to the pair's own lower-id → higher-id axis.
|
|
101
|
+
const fan = (i % 2 ? -1 : 1) * (1 + Math.floor(i / 2));
|
|
102
|
+
list.push({ source: a, target: b, edge, bend: a.id < b.id ? fan : -fan });
|
|
103
|
+
a.degree++;
|
|
104
|
+
b.degree++;
|
|
105
|
+
adj.get(a)?.add(b);
|
|
106
|
+
adj.get(b)?.add(a);
|
|
107
|
+
}
|
|
108
|
+
for (const s of byId.values()) {
|
|
109
|
+
s.r = s.node.size ?? Math.min(2.5 + 2.8 * Math.sqrt(s.degree), 26) + (s.node.emphasis ? 3 : 0);
|
|
110
|
+
}
|
|
111
|
+
// A newcomer starts beside the nodes it joins, so a live graph grows in
|
|
112
|
+
// place; with no placed neighbour, d3 seeds it on its phyllotaxis.
|
|
113
|
+
for (const s of fresh) {
|
|
114
|
+
let n = 0;
|
|
115
|
+
let x = 0;
|
|
116
|
+
let y = 0;
|
|
117
|
+
for (const m of adj.get(s) ?? []) {
|
|
118
|
+
if (Number.isNaN(m.x))
|
|
119
|
+
continue;
|
|
120
|
+
x += m.x;
|
|
121
|
+
y += m.y;
|
|
122
|
+
n++;
|
|
123
|
+
}
|
|
124
|
+
if (n) {
|
|
125
|
+
const a = Math.random() * Math.PI * 2;
|
|
126
|
+
s.x = x / n + Math.cos(a) * (s.r + 16);
|
|
127
|
+
s.y = y / n + Math.sin(a) * (s.r + 16);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.byId = byId;
|
|
131
|
+
this.adj = adj;
|
|
132
|
+
this.nodes = [...byId.values()];
|
|
133
|
+
this.edges = list;
|
|
134
|
+
this.ranked = [...this.nodes].sort((p, q) => Number(!!q.node.emphasis) - Number(!!p.node.emphasis) || q.degree - p.degree);
|
|
135
|
+
// A big graph trades a little accuracy for ticks it can afford.
|
|
136
|
+
const big = this.nodes.length > 1200;
|
|
137
|
+
this.charge.theta(big ? 1.1 : 0.9).distanceMax(big ? 480 : 900);
|
|
138
|
+
this.sim.alphaDecay(big ? 0.032 : 0.0228);
|
|
139
|
+
this.sim.nodes(this.nodes);
|
|
140
|
+
this.link.links(list);
|
|
141
|
+
if (this.selected)
|
|
142
|
+
this.selected = byId.get(this.selected.id) ?? null;
|
|
143
|
+
if (this.hovered)
|
|
144
|
+
this.hovered = byId.get(this.hovered.id) ?? null;
|
|
145
|
+
this.matches = new Set([...this.matches].flatMap((m) => byId.get(m.id) ?? []));
|
|
146
|
+
return fresh.length;
|
|
147
|
+
}
|
|
148
|
+
hot() {
|
|
149
|
+
return this.sim.alpha() >= this.sim.alphaMin();
|
|
150
|
+
}
|
|
151
|
+
heat(alpha) {
|
|
152
|
+
this.sim.alpha(Math.max(this.sim.alpha(), alpha));
|
|
153
|
+
}
|
|
154
|
+
/** Advance the layout for at most `budgetMs`, and at least one tick. */
|
|
155
|
+
advance(budgetMs) {
|
|
156
|
+
const t0 = performance.now();
|
|
157
|
+
do
|
|
158
|
+
this.sim.tick();
|
|
159
|
+
while (this.hot() && performance.now() - t0 < budgetMs);
|
|
160
|
+
}
|
|
161
|
+
visible(s) {
|
|
162
|
+
return !this.hidden.has(s.node.kind);
|
|
163
|
+
}
|
|
164
|
+
neighbourhood(s) {
|
|
165
|
+
return [s, ...(this.adj.get(s) ?? [])].filter((n) => this.visible(n));
|
|
166
|
+
}
|
|
167
|
+
/** The camera that frames `subset` (default: every visible node) inside the inset box. */
|
|
168
|
+
fit(subset, inset, maxK = 1.8) {
|
|
169
|
+
let x0 = Infinity;
|
|
170
|
+
let y0 = Infinity;
|
|
171
|
+
let x1 = -Infinity;
|
|
172
|
+
let y1 = -Infinity;
|
|
173
|
+
for (const s of subset ?? this.nodes) {
|
|
174
|
+
if (!this.visible(s) || Number.isNaN(s.x))
|
|
175
|
+
continue;
|
|
176
|
+
x0 = Math.min(x0, s.x - s.r);
|
|
177
|
+
y0 = Math.min(y0, s.y - s.r);
|
|
178
|
+
x1 = Math.max(x1, s.x + s.r);
|
|
179
|
+
y1 = Math.max(y1, s.y + s.r + 16);
|
|
180
|
+
}
|
|
181
|
+
if (x0 === Infinity)
|
|
182
|
+
return null;
|
|
183
|
+
const pad = Math.min(48, Math.min(this.width, this.height) * 0.06);
|
|
184
|
+
// Names hang below and either side of a node: room for half a name.
|
|
185
|
+
const side = pad + Math.min(56, this.width * 0.1);
|
|
186
|
+
const box = {
|
|
187
|
+
left: (inset?.left ?? 0) + side,
|
|
188
|
+
top: (inset?.top ?? 0) + pad,
|
|
189
|
+
right: this.width - (inset?.right ?? 0) - side,
|
|
190
|
+
bottom: this.height - (inset?.bottom ?? 0) - pad
|
|
191
|
+
};
|
|
192
|
+
const bw = Math.max(box.right - box.left, 40);
|
|
193
|
+
const bh = Math.max(box.bottom - box.top, 40);
|
|
194
|
+
const k = Math.min(bw / Math.max(x1 - x0, 1), bh / Math.max(y1 - y0, 1), maxK);
|
|
195
|
+
return {
|
|
196
|
+
k,
|
|
197
|
+
x: box.left + bw / 2 - ((x0 + x1) / 2) * k,
|
|
198
|
+
y: box.top + bh / 2 - ((y0 + y1) / 2) * k
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
toWorld(sx, sy) {
|
|
202
|
+
return [(sx - this.cam.x) / this.cam.k, (sy - this.cam.y) / this.cam.k];
|
|
203
|
+
}
|
|
204
|
+
/** Zoom by `factor` about the screen point (sx, sy). */
|
|
205
|
+
zoomAt(sx, sy, factor, min, max) {
|
|
206
|
+
const k = Math.min(Math.max(this.cam.k * factor, min), max);
|
|
207
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
208
|
+
this.cam = { k, x: sx - wx * k, y: sy - wy * k };
|
|
209
|
+
}
|
|
210
|
+
/** The node under a screen point, with `slop` px of grace for a finger. */
|
|
211
|
+
pick(sx, sy, slop) {
|
|
212
|
+
const [wx, wy] = this.toWorld(sx, sy);
|
|
213
|
+
const k = this.cam.k;
|
|
214
|
+
let best = null;
|
|
215
|
+
let bd = Infinity;
|
|
216
|
+
for (const s of this.nodes) {
|
|
217
|
+
if (!this.visible(s))
|
|
218
|
+
continue;
|
|
219
|
+
const d = Math.hypot(s.x - wx, s.y - wy);
|
|
220
|
+
if (d < Math.max(s.r, 2 / k) + slop / k && d < bd) {
|
|
221
|
+
bd = d;
|
|
222
|
+
best = s;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return best;
|
|
226
|
+
}
|
|
227
|
+
clearTextCache() {
|
|
228
|
+
this.widths.clear();
|
|
229
|
+
}
|
|
230
|
+
draw(ctx) {
|
|
231
|
+
const P = this.palette;
|
|
232
|
+
const { dpr, width: W, height: H } = this;
|
|
233
|
+
const { k, x: tx, y: ty } = this.cam;
|
|
234
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
235
|
+
ctx.clearRect(0, 0, W, H);
|
|
236
|
+
if (!P)
|
|
237
|
+
return;
|
|
238
|
+
const chosen = this.selected && this.visible(this.selected) ? this.selected : null;
|
|
239
|
+
const focus = this.hovered ?? chosen;
|
|
240
|
+
const near = focus ? this.adj.get(focus) : undefined;
|
|
241
|
+
const lit = (s) => !focus || s === focus || !!near?.has(s);
|
|
242
|
+
const margin = 40 / k;
|
|
243
|
+
const vx0 = -tx / k - margin;
|
|
244
|
+
const vy0 = -ty / k - margin;
|
|
245
|
+
const vx1 = (W - tx) / k + margin;
|
|
246
|
+
const vy1 = (H - ty) / k + margin;
|
|
247
|
+
const onScreen = (s) => s.x > vx0 && s.x < vx1 && s.y > vy0 && s.y < vy1;
|
|
248
|
+
const rad = (s) => Math.max(s.r, 1.8 / k);
|
|
249
|
+
ctx.setTransform(dpr * k, 0, 0, dpr * k, dpr * tx, dpr * ty);
|
|
250
|
+
ctx.lineCap = 'butt';
|
|
251
|
+
ctx.lineJoin = 'round';
|
|
252
|
+
const shown = [];
|
|
253
|
+
for (const e of this.edges) {
|
|
254
|
+
const a = e.source;
|
|
255
|
+
const b = e.target;
|
|
256
|
+
if (!this.visible(a) || !this.visible(b))
|
|
257
|
+
continue;
|
|
258
|
+
if (Math.max(a.x, b.x) < vx0 ||
|
|
259
|
+
Math.min(a.x, b.x) > vx1 ||
|
|
260
|
+
Math.max(a.y, b.y) < vy0 ||
|
|
261
|
+
Math.min(a.y, b.y) > vy1)
|
|
262
|
+
continue;
|
|
263
|
+
shown.push(e);
|
|
264
|
+
}
|
|
265
|
+
// Detail follows how many edges are on screen, not the zoom: past a
|
|
266
|
+
// thousand, curves read as straight lines and arrowheads as noise, and
|
|
267
|
+
// both cost more to fill than the rest of the frame.
|
|
268
|
+
const curved = shown.length < 1200;
|
|
269
|
+
// Edges: one batched path for the rest, one per colour for the lit ones.
|
|
270
|
+
const rest = new Path2D();
|
|
271
|
+
const restHeads = new Path2D();
|
|
272
|
+
const litPaths = new Map();
|
|
273
|
+
const edgeLabels = [];
|
|
274
|
+
const heads = this.directed && (shown.length < 500 || !!focus);
|
|
275
|
+
// A hub's relations crowd its own spokes; past a dozen the card lists them instead.
|
|
276
|
+
const relationWords = focus ? (this.adj.get(focus)?.size ?? 0) <= 12 : shown.length < 300;
|
|
277
|
+
for (const e of shown) {
|
|
278
|
+
const a = e.source;
|
|
279
|
+
const b = e.target;
|
|
280
|
+
const hot = !!focus && (a === focus || b === focus);
|
|
281
|
+
let path = rest;
|
|
282
|
+
let head = restHeads;
|
|
283
|
+
if (hot) {
|
|
284
|
+
const colour = this.colour(a === focus ? b : a);
|
|
285
|
+
let p = litPaths.get(colour);
|
|
286
|
+
if (!p)
|
|
287
|
+
litPaths.set(colour, (p = { line: new Path2D(), head: new Path2D() }));
|
|
288
|
+
path = p.line;
|
|
289
|
+
head = p.head;
|
|
290
|
+
}
|
|
291
|
+
const [cx, cy] = curved || hot ? this.control(e) : [(a.x + b.x) / 2, (a.y + b.y) / 2];
|
|
292
|
+
path.moveTo(a.x, a.y);
|
|
293
|
+
if (curved || hot)
|
|
294
|
+
path.quadraticCurveTo(cx, cy, b.x, b.y);
|
|
295
|
+
else
|
|
296
|
+
path.lineTo(b.x, b.y);
|
|
297
|
+
if (heads && (hot || !focus))
|
|
298
|
+
this.arrow(head, cx, cy, b, rad(b), k);
|
|
299
|
+
if (e.edge.label && relationWords && (hot || !focus))
|
|
300
|
+
edgeLabels.push(e);
|
|
301
|
+
}
|
|
302
|
+
ctx.strokeStyle = P.edge;
|
|
303
|
+
ctx.fillStyle = P.edge;
|
|
304
|
+
ctx.globalAlpha = focus ? 0.07 : Math.min(0.65, 0.12 + 0.35 * k);
|
|
305
|
+
ctx.lineWidth = 1 / k;
|
|
306
|
+
ctx.stroke(rest);
|
|
307
|
+
ctx.fill(restHeads);
|
|
308
|
+
ctx.globalAlpha = 0.95;
|
|
309
|
+
ctx.lineWidth = 1.75 / k;
|
|
310
|
+
for (const [colour, p] of litPaths) {
|
|
311
|
+
ctx.strokeStyle = colour;
|
|
312
|
+
ctx.fillStyle = colour;
|
|
313
|
+
ctx.stroke(p.line);
|
|
314
|
+
ctx.fill(p.head);
|
|
315
|
+
}
|
|
316
|
+
// Nodes: dimmed first, then lit, each batched by kind. A dot too small to
|
|
317
|
+
// overlap its neighbour visibly skips the ground-coloured halo.
|
|
318
|
+
const halo = k >= 0.5 ? 1.4 / k : 0;
|
|
319
|
+
for (const pass of [false, true]) {
|
|
320
|
+
if (pass === false && !focus)
|
|
321
|
+
continue;
|
|
322
|
+
const groups = new Map();
|
|
323
|
+
for (const s of this.nodes) {
|
|
324
|
+
if (!this.visible(s) || !onScreen(s) || lit(s) !== pass)
|
|
325
|
+
continue;
|
|
326
|
+
const kind = s.node.kind;
|
|
327
|
+
let p = groups.get(kind);
|
|
328
|
+
if (!p)
|
|
329
|
+
groups.set(kind, (p = new Path2D()));
|
|
330
|
+
this.shape(p, this.shapes.get(kind) ?? 'circle', s.x, s.y, rad(s));
|
|
331
|
+
}
|
|
332
|
+
ctx.globalAlpha = pass ? 1 : 0.12;
|
|
333
|
+
ctx.lineWidth = halo;
|
|
334
|
+
ctx.strokeStyle = P.ground;
|
|
335
|
+
for (const [kind, p] of groups) {
|
|
336
|
+
ctx.fillStyle = P.kinds.get(kind) ?? P.fallback;
|
|
337
|
+
ctx.fill(p);
|
|
338
|
+
if (halo)
|
|
339
|
+
ctx.stroke(p);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
ctx.globalAlpha = 1;
|
|
343
|
+
// Rings: emphasis (double, in the kind's colour), search hits, the selection.
|
|
344
|
+
for (const s of this.nodes) {
|
|
345
|
+
if (!this.visible(s) || !onScreen(s))
|
|
346
|
+
continue;
|
|
347
|
+
const ring = s === this.selected || s === focus;
|
|
348
|
+
if (!s.node.emphasis && !ring && !this.matches.has(s))
|
|
349
|
+
continue;
|
|
350
|
+
const r = rad(s);
|
|
351
|
+
const shape = this.shapes.get(s.node.kind) ?? 'circle';
|
|
352
|
+
ctx.globalAlpha = lit(s) ? 1 : 0.2;
|
|
353
|
+
if (ring) {
|
|
354
|
+
const glow = new Path2D();
|
|
355
|
+
this.shape(glow, shape, s.x, s.y, r + 7 / k);
|
|
356
|
+
ctx.fillStyle = this.colour(s);
|
|
357
|
+
ctx.globalAlpha = 0.22;
|
|
358
|
+
ctx.fill(glow);
|
|
359
|
+
ctx.globalAlpha = 1;
|
|
360
|
+
}
|
|
361
|
+
const p = new Path2D();
|
|
362
|
+
this.shape(p, shape, s.x, s.y, r + 3.5 / k);
|
|
363
|
+
ctx.lineWidth = (ring ? 2 : 1.4) / k;
|
|
364
|
+
ctx.strokeStyle = ring || this.matches.has(s) ? P.ink : this.colour(s);
|
|
365
|
+
ctx.stroke(p);
|
|
366
|
+
}
|
|
367
|
+
ctx.globalAlpha = 1;
|
|
368
|
+
this.labels(ctx, P, focus, near, edgeLabels, onScreen, rad);
|
|
369
|
+
}
|
|
370
|
+
colour(s) {
|
|
371
|
+
return this.palette?.kinds.get(s.node.kind) ?? this.palette?.fallback ?? 'currentColor';
|
|
372
|
+
}
|
|
373
|
+
control(e) {
|
|
374
|
+
const a = e.source;
|
|
375
|
+
const b = e.target;
|
|
376
|
+
const dx = b.x - a.x;
|
|
377
|
+
const dy = b.y - a.y;
|
|
378
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
379
|
+
const curve = Math.min(len * 0.1, 22) * e.bend;
|
|
380
|
+
return [(a.x + b.x) / 2 - (dy / len) * curve, (a.y + b.y) / 2 + (dx / len) * curve];
|
|
381
|
+
}
|
|
382
|
+
arrow(p, cx, cy, b, r, k) {
|
|
383
|
+
const dx = b.x - cx;
|
|
384
|
+
const dy = b.y - cy;
|
|
385
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
386
|
+
const ux = dx / len;
|
|
387
|
+
const uy = dy / len;
|
|
388
|
+
const tipX = b.x - ux * (r + 2 / k);
|
|
389
|
+
const tipY = b.y - uy * (r + 2 / k);
|
|
390
|
+
const size = 7 / k;
|
|
391
|
+
p.moveTo(tipX, tipY);
|
|
392
|
+
p.lineTo(tipX - ux * size - uy * size * 0.5, tipY - uy * size + ux * size * 0.5);
|
|
393
|
+
p.lineTo(tipX - ux * size + uy * size * 0.5, tipY - uy * size - ux * size * 0.5);
|
|
394
|
+
p.closePath();
|
|
395
|
+
}
|
|
396
|
+
shape(p, shape, x, y, r) {
|
|
397
|
+
if (shape === 'square') {
|
|
398
|
+
const h = r * 0.89;
|
|
399
|
+
p.rect(x - h, y - h, h * 2, h * 2);
|
|
400
|
+
}
|
|
401
|
+
else if (shape === 'diamond') {
|
|
402
|
+
const h = r * 1.22;
|
|
403
|
+
p.moveTo(x, y - h);
|
|
404
|
+
p.lineTo(x + h, y);
|
|
405
|
+
p.lineTo(x, y + h);
|
|
406
|
+
p.lineTo(x - h, y);
|
|
407
|
+
p.closePath();
|
|
408
|
+
}
|
|
409
|
+
else if (shape === 'triangle') {
|
|
410
|
+
const h = r * 1.3;
|
|
411
|
+
p.moveTo(x, y - h);
|
|
412
|
+
p.lineTo(x + h * 0.866, y + h * 0.5);
|
|
413
|
+
p.lineTo(x - h * 0.866, y + h * 0.5);
|
|
414
|
+
p.closePath();
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
p.moveTo(x + r, y);
|
|
418
|
+
p.arc(x, y, r, 0, Math.PI * 2);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
measure(ctx, font, text) {
|
|
422
|
+
const key = `${font}|${text}`;
|
|
423
|
+
let w = this.widths.get(key);
|
|
424
|
+
if (w === undefined) {
|
|
425
|
+
ctx.font = font;
|
|
426
|
+
w = ctx.measureText(text).width;
|
|
427
|
+
this.widths.set(key, w);
|
|
428
|
+
}
|
|
429
|
+
return w;
|
|
430
|
+
}
|
|
431
|
+
/** Reserve a screen box unless it overlaps one already reserved. */
|
|
432
|
+
claim(x0, y0, x1, y1) {
|
|
433
|
+
const c0 = Math.floor(x0 / CELL);
|
|
434
|
+
const c1 = Math.floor(x1 / CELL);
|
|
435
|
+
const r0 = Math.floor(y0 / CELL);
|
|
436
|
+
const r1 = Math.floor(y1 / CELL);
|
|
437
|
+
const b = this.boxes;
|
|
438
|
+
for (let c = c0; c <= c1; c++) {
|
|
439
|
+
for (let r = r0; r <= r1; r++) {
|
|
440
|
+
for (const i of this.grid.get(c * 4096 + r) ?? []) {
|
|
441
|
+
if (x0 < b[i + 2] && x1 > b[i] && y0 < b[i + 3] && y1 > b[i + 1])
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const i = b.length;
|
|
447
|
+
b.push(x0, y0, x1, y1);
|
|
448
|
+
for (let c = c0; c <= c1; c++) {
|
|
449
|
+
for (let r = r0; r <= r1; r++) {
|
|
450
|
+
const key = c * 4096 + r;
|
|
451
|
+
const cell = this.grid.get(key);
|
|
452
|
+
if (cell)
|
|
453
|
+
cell.push(i);
|
|
454
|
+
else
|
|
455
|
+
this.grid.set(key, [i]);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return true;
|
|
459
|
+
}
|
|
460
|
+
labels(ctx, P, focus, near, edgeLabels, onScreen, rad) {
|
|
461
|
+
const { k, x: tx, y: ty } = this.cam;
|
|
462
|
+
const { dpr, width: W, height: H } = this;
|
|
463
|
+
this.grid.clear();
|
|
464
|
+
this.boxes = [];
|
|
465
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
466
|
+
ctx.textAlign = 'center';
|
|
467
|
+
ctx.lineJoin = 'round';
|
|
468
|
+
ctx.globalAlpha = 1;
|
|
469
|
+
// The discs a label must not cover: under a focus, the lit ones; at rest,
|
|
470
|
+
// only nodes drawn large. A haloed name reads fine across small dots.
|
|
471
|
+
for (const s of this.nodes) {
|
|
472
|
+
if (!this.visible(s) || !onScreen(s))
|
|
473
|
+
continue;
|
|
474
|
+
const r = rad(s) * k;
|
|
475
|
+
if (focus ? s !== focus && !near?.has(s) : r < 8)
|
|
476
|
+
continue;
|
|
477
|
+
const sx = tx + s.x * k;
|
|
478
|
+
const sy = ty + s.y * k;
|
|
479
|
+
this.claim(sx - r, sy - r, sx + r, sy + r);
|
|
480
|
+
}
|
|
481
|
+
const order = [];
|
|
482
|
+
if (focus) {
|
|
483
|
+
order.push(focus);
|
|
484
|
+
const ring = [...(near ?? [])].sort((p, q) => q.degree - p.degree);
|
|
485
|
+
order.push(...ring);
|
|
486
|
+
}
|
|
487
|
+
else {
|
|
488
|
+
if (this.selected)
|
|
489
|
+
order.push(this.selected);
|
|
490
|
+
order.push(...this.matches);
|
|
491
|
+
order.push(...this.ranked);
|
|
492
|
+
}
|
|
493
|
+
const seen = new Set();
|
|
494
|
+
const quota = Math.ceil((W * H) / LABEL_AREA);
|
|
495
|
+
let placed = 0;
|
|
496
|
+
let tries = 0;
|
|
497
|
+
const body = `500 12px ${P.font}`;
|
|
498
|
+
const strong = `600 13px ${P.font}`;
|
|
499
|
+
for (const s of order) {
|
|
500
|
+
if (placed >= MAX_LABELS || tries >= MAX_LABEL_TRIES)
|
|
501
|
+
break;
|
|
502
|
+
if (seen.has(s) || !this.visible(s) || !onScreen(s))
|
|
503
|
+
continue;
|
|
504
|
+
seen.add(s);
|
|
505
|
+
tries++;
|
|
506
|
+
const text = clip(s.node.label);
|
|
507
|
+
const prominent = s === focus || s === this.selected || !!s.node.emphasis;
|
|
508
|
+
if (!focus && placed >= quota && rad(s) * k < 7 && !prominent && !this.matches.has(s))
|
|
509
|
+
continue;
|
|
510
|
+
const font = prominent ? strong : body;
|
|
511
|
+
const w = this.measure(ctx, font, text);
|
|
512
|
+
const h = prominent ? 15 : 14;
|
|
513
|
+
const sx = tx + s.x * k;
|
|
514
|
+
const r = rad(s) * k;
|
|
515
|
+
if (sx + w / 2 < 0 || sx - w / 2 > W)
|
|
516
|
+
continue;
|
|
517
|
+
// Below the node, else above it; the focused or selected name is drawn
|
|
518
|
+
// whatever it lands on, since it is the one the reader is looking for.
|
|
519
|
+
const below = ty + s.y * k + r + 4;
|
|
520
|
+
const above = ty + s.y * k - r - 4 - h;
|
|
521
|
+
const fits = (y) => this.claim(sx - w / 2 - 3, y - 2, sx + w / 2 + 3, y + h + 2);
|
|
522
|
+
const sy = fits(below) ? below : fits(above) ? above : s === focus || s === this.selected ? below : null;
|
|
523
|
+
if (sy === null || sy > H || sy + h < 0)
|
|
524
|
+
continue;
|
|
525
|
+
this.text(ctx, font, text, sx, sy, 'top', P.ink, P.ground);
|
|
526
|
+
placed++;
|
|
527
|
+
}
|
|
528
|
+
const small = `600 10px ${P.font}`;
|
|
529
|
+
let edgesPlaced = 0;
|
|
530
|
+
for (const e of edgeLabels) {
|
|
531
|
+
if (edgesPlaced >= MAX_EDGE_LABELS)
|
|
532
|
+
break;
|
|
533
|
+
const label = e.edge.label;
|
|
534
|
+
if (!label)
|
|
535
|
+
continue;
|
|
536
|
+
const [cx, cy] = this.control(e);
|
|
537
|
+
const mx = tx + (0.25 * e.source.x + 0.5 * cx + 0.25 * e.target.x) * k;
|
|
538
|
+
const my = ty + (0.25 * e.source.y + 0.5 * cy + 0.25 * e.target.y) * k;
|
|
539
|
+
if (mx < 0 || mx > W || my < 0 || my > H)
|
|
540
|
+
continue;
|
|
541
|
+
const text = clip(label);
|
|
542
|
+
const w = this.measure(ctx, small, text);
|
|
543
|
+
if (!this.claim(mx - w / 2 - 3, my - 7, mx + w / 2 + 3, my + 7))
|
|
544
|
+
continue;
|
|
545
|
+
const hot = !!focus && (e.source === focus || e.target === focus);
|
|
546
|
+
this.text(ctx, small, text, mx, my, 'middle', hot ? P.ink : P.inkSoft, P.ground);
|
|
547
|
+
edgesPlaced++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
text(ctx, font, text, x, y, baseline, fill, halo) {
|
|
551
|
+
ctx.font = font;
|
|
552
|
+
ctx.textBaseline = baseline;
|
|
553
|
+
ctx.lineWidth = 3.5;
|
|
554
|
+
ctx.strokeStyle = halo;
|
|
555
|
+
ctx.strokeText(text, x, y);
|
|
556
|
+
ctx.fillStyle = fill;
|
|
557
|
+
ctx.fillText(text, x, y);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function clip(text) {
|
|
561
|
+
return text.length > 32 ? `${text.slice(0, 31)}…` : text;
|
|
562
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** One entity. `kind` keys it to a `GraphKind`; extra fields ride along to the callbacks. */
|
|
2
|
+
export interface GraphNode {
|
|
3
|
+
id: string;
|
|
4
|
+
label: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
/** One line under the title in the detail card. */
|
|
7
|
+
description?: string;
|
|
8
|
+
/** A second ring: the subject of the page, a watched entity. */
|
|
9
|
+
emphasis?: boolean;
|
|
10
|
+
/** Radius in px at zoom 1. Defaults from the node's degree. */
|
|
11
|
+
size?: number;
|
|
12
|
+
}
|
|
13
|
+
/** One relation. Edges naming an id not in `nodes` are dropped. */
|
|
14
|
+
export interface GraphEdge {
|
|
15
|
+
source: string;
|
|
16
|
+
target: string;
|
|
17
|
+
label?: string;
|
|
18
|
+
}
|
|
19
|
+
export type GraphShape = 'circle' | 'square' | 'diamond' | 'triangle';
|
|
20
|
+
/** A legend row. The colour is any CSS colour; a `var(--…)` resolves against the graph's own element. */
|
|
21
|
+
export interface GraphKind {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
color?: string;
|
|
25
|
+
shape?: GraphShape;
|
|
26
|
+
}
|
|
27
|
+
/** A node beside the selected one, with the relation that joins them. */
|
|
28
|
+
export interface GraphNeighbour<N extends GraphNode = GraphNode> {
|
|
29
|
+
node: N;
|
|
30
|
+
label?: string;
|
|
31
|
+
/** `out` when the selected node is the edge's source. */
|
|
32
|
+
direction: 'in' | 'out';
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A route's page: its header, sections and panels stacked at the one page
|
|
4
|
+
* rhythm. Pages had written that wrapper per file (`space-y-6`, `space-y-10`,
|
|
5
|
+
* `gap-4`, `gap-5`, `gap-10` in one app; a local PageContainer behind 56
|
|
6
|
+
* call sites in another).
|
|
7
|
+
*
|
|
8
|
+
* Width is not this component's: AppShell caps the content box from the
|
|
9
|
+
* route's `measure`, which a route declares in its load data. The container
|
|
10
|
+
* fills that box and grows to its height, so a page holding a board or a
|
|
11
|
+
* table can scroll inside itself.
|
|
12
|
+
*
|
|
13
|
+
* PageHeader carries its own bottom margin for pages written without this;
|
|
14
|
+
* inside it the gap alone spaces the header, so the header sits at the same
|
|
15
|
+
* distance either way.
|
|
16
|
+
*/
|
|
17
|
+
import type { Snippet } from 'svelte';
|
|
18
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
19
|
+
import { cn, type WithElementRef } from '../../../utils.js';
|
|
20
|
+
|
|
21
|
+
let {
|
|
22
|
+
children,
|
|
23
|
+
ref = $bindable(null),
|
|
24
|
+
class: className,
|
|
25
|
+
...restProps
|
|
26
|
+
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & { children: Snippet } = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div
|
|
30
|
+
bind:this={ref}
|
|
31
|
+
data-slot="page-container"
|
|
32
|
+
class={cn(
|
|
33
|
+
'flex min-h-0 w-full min-w-0 flex-1 flex-col gap-6 *:data-[slot=page-header]:mb-0',
|
|
34
|
+
className
|
|
35
|
+
)}
|
|
36
|
+
{...restProps}
|
|
37
|
+
>
|
|
38
|
+
{@render children()}
|
|
39
|
+
</div>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A route's page: its header, sections and panels stacked at the one page
|
|
3
|
+
* rhythm. Pages had written that wrapper per file (`space-y-6`, `space-y-10`,
|
|
4
|
+
* `gap-4`, `gap-5`, `gap-10` in one app; a local PageContainer behind 56
|
|
5
|
+
* call sites in another).
|
|
6
|
+
*
|
|
7
|
+
* Width is not this component's: AppShell caps the content box from the
|
|
8
|
+
* route's `measure`, which a route declares in its load data. The container
|
|
9
|
+
* fills that box and grows to its height, so a page holding a board or a
|
|
10
|
+
* table can scroll inside itself.
|
|
11
|
+
*
|
|
12
|
+
* PageHeader carries its own bottom margin for pages written without this;
|
|
13
|
+
* inside it the gap alone spaces the header, so the header sits at the same
|
|
14
|
+
* distance either way.
|
|
15
|
+
*/
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
import type { HTMLAttributes } from 'svelte/elements';
|
|
18
|
+
import { type WithElementRef } from '../../../utils.js';
|
|
19
|
+
type $$ComponentProps = WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
|
20
|
+
children: Snippet;
|
|
21
|
+
};
|
|
22
|
+
declare const PageContainer: import("svelte").Component<$$ComponentProps, {}, "ref">;
|
|
23
|
+
type PageContainer = ReturnType<typeof PageContainer>;
|
|
24
|
+
export default PageContainer;
|