@reddb-io/ui 0.1.1 → 0.3.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.
Files changed (60) hide show
  1. package/build/_app/immutable/assets/0.DQDLkSdR.css +1 -0
  2. package/build/_app/immutable/assets/Workspace.CvP5De7i.css +1 -0
  3. package/build/_app/immutable/chunks/65XjEwaF.js +367 -0
  4. package/build/_app/immutable/chunks/BjFADNkA.js +992 -0
  5. package/build/_app/immutable/chunks/{ALqaDrNQ.js → C278awGv.js} +1 -1
  6. package/build/_app/immutable/chunks/{Ov8N-vxt.js → C9rXzYLu.js} +1 -1
  7. package/build/_app/immutable/chunks/CDKIdLnm.js +187 -0
  8. package/build/_app/immutable/chunks/{DXY_Rxa7.js → CXNPJgv1.js} +7 -7
  9. package/build/_app/immutable/chunks/{Bgso8cDH.js → DqvWKbSc.js} +1 -1
  10. package/build/_app/immutable/chunks/pDJnDxXM.js +1 -0
  11. package/build/_app/immutable/entry/app.DdaMXHAv.js +2 -0
  12. package/build/_app/immutable/entry/start.-MLBtQXB.js +1 -0
  13. package/build/_app/immutable/nodes/{0.CHPiFxDo.js → 0.Ysoz3Y3h.js} +1 -1
  14. package/build/_app/immutable/nodes/{1.BC_4YZ0h.js → 1.C2jbGTOJ.js} +1 -1
  15. package/build/_app/immutable/nodes/11.CAG5_cGP.js +1 -0
  16. package/build/_app/immutable/nodes/{10.B7DMKSNc.js → 12.DQG5zpEx.js} +5 -5
  17. package/build/_app/immutable/nodes/7.CAG5_cGP.js +1 -0
  18. package/build/_app/immutable/nodes/{7.BZX9Iird.js → 8.CrHI4F9p.js} +1 -1
  19. package/build/_app/version.json +1 -1
  20. package/build/index.html +12 -12
  21. package/dist/embed/{RedUiEmbedRoot-BGoLxJf1.js → RedUiEmbedRoot-BwFUA5X-.js} +12715 -10653
  22. package/dist/embed/RedUiEmbedRoot-BwFUA5X-.js.map +1 -0
  23. package/dist/embed/index-DGhgtnaK.js +577 -0
  24. package/dist/embed/{index-B6ZQhCsu.js.map → index-DGhgtnaK.js.map} +1 -1
  25. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js → index-fad77a13.esm-BMULHdcE.js} +22 -20
  26. package/dist/embed/{index-fad77a13.esm-BqPj0saH.js.map → index-fad77a13.esm-BMULHdcE.js.map} +1 -1
  27. package/dist/embed/index.js +1 -1
  28. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js +536 -0
  29. package/dist/embed/sigma-edge-curve.esm-CAT5Xrom.js.map +1 -0
  30. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js → sigma-node-border.esm-Bnk4BRbw.js} +32 -32
  31. package/dist/embed/{sigma-node-border.esm-CGtlgqmo.js.map → sigma-node-border.esm-Bnk4BRbw.js.map} +1 -1
  32. package/dist/embed/{sigma.esm-y0qgY-OC.js → sigma.esm-D5yoExsH.js} +3 -3
  33. package/dist/embed/{sigma.esm-y0qgY-OC.js.map → sigma.esm-D5yoExsH.js.map} +1 -1
  34. package/package.json +5 -3
  35. package/src/lib/Appearance.svelte +90 -0
  36. package/src/lib/CommandPalette.svelte +2 -0
  37. package/src/lib/SettingsView.svelte +376 -0
  38. package/src/lib/Topbar.svelte +15 -1
  39. package/src/lib/Workspace.svelte +14 -1
  40. package/src/lib/index.ts +2 -0
  41. package/src/lib/renderers/GraphRenderer.svelte +448 -44
  42. package/src/lib/renderers/graph-bundle.ts +401 -0
  43. package/src/lib/renderers/graph-communities.ts +193 -0
  44. package/src/lib/renderers/graph-render.ts +48 -5
  45. package/src/lib/renderers/graph-sigma.ts +274 -8
  46. package/src/lib/router.svelte.ts +84 -57
  47. package/src/lib/settings-sections.ts +267 -0
  48. package/src/lib/skins/skins.svelte.ts +64 -0
  49. package/src/lib/skins/skins.ts +178 -0
  50. package/build/_app/immutable/assets/0.C63jY5jX.css +0 -1
  51. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +0 -1
  52. package/build/_app/immutable/chunks/BMHKbvs8.js +0 -1
  53. package/build/_app/immutable/chunks/BQHbwf54.js +0 -916
  54. package/build/_app/immutable/chunks/Bmgx1mfv.js +0 -310
  55. package/build/_app/immutable/entry/app.D_IVtpdv.js +0 -2
  56. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +0 -1
  57. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +0 -1
  58. package/dist/embed/index-B6ZQhCsu.js +0 -577
  59. /package/build/_app/immutable/assets/{10.Dblyk0Kr.css → 12.Dblyk0Kr.css} +0 -0
  60. /package/build/_app/immutable/nodes/{8.CAG5_cGP.js → 10.CAG5_cGP.js} +0 -0
@@ -54,6 +54,24 @@ export function sigmaNodeSize(type: string, incomingScale: number): number {
54
54
  return nodeVisualRadius(type, incomingScale) * SIGMA_SIZE_FACTOR;
55
55
  }
56
56
 
57
+ // ─── community supernode sizing (collapsed-cluster rendering) ───────────────
58
+
59
+ /** Light stroke that marks a supernode as an expandable cluster. */
60
+ export const SUPERNODE_STROKE_COLOR = "#e2e8f0";
61
+
62
+ /**
63
+ * Supernode radius in the SVG fallback's [0, 100] space — grows with the
64
+ * member count (sub-linear so a 500-node cluster doesn't swallow the canvas).
65
+ */
66
+ export function supernodeSvgRadius(memberCount: number): number {
67
+ return Math.min(7, 2.2 + Math.sqrt(Math.max(1, memberCount)) * 0.7);
68
+ }
69
+
70
+ /** Same radius mapped onto sigma's pixel sizing. */
71
+ export function supernodeSigmaSize(memberCount: number): number {
72
+ return supernodeSvgRadius(memberCount) * SIGMA_SIZE_FACTOR;
73
+ }
74
+
57
75
  // ─── colour utilities (sigma's WebGL colour parser handles hex/rgb(a) but not
58
76
  // hsl(), so community tints are converted up front) ─────────────────────
59
77
 
@@ -159,6 +177,10 @@ export interface SigmaNodeAttributes {
159
177
  nodeType: string;
160
178
  community: number;
161
179
  orphan: boolean;
180
+ /** True for a collapsed-community supernode; always labels, never dims away. */
181
+ isSupernode: boolean;
182
+ /** Member count behind a supernode (0 for ordinary nodes). */
183
+ memberCount: number;
162
184
  /** Drives `defaultNodeType` → NodeBorderProgram. */
163
185
  type: "border";
164
186
  }
@@ -169,7 +191,19 @@ export interface SigmaEdgeAttributes {
169
191
  /** Original edge label, kept so the reducer can surface it on focus only. */
170
192
  edgeLabel: string;
171
193
  label: string;
172
- type: "line";
194
+ /**
195
+ * True when the edge's endpoints sit in different communities — the
196
+ * group-in-a-box overlay routes these at the box borders, so the native
197
+ * straight edge is hidden while that mode is on.
198
+ */
199
+ crossCommunity: boolean;
200
+ /**
201
+ * Bundling curvature consumed by @sigma/edge-curve when bundling is on. 0 for
202
+ * a straight chord; the reducer flips the edge to the "curved" program only
203
+ * when bundling is active.
204
+ */
205
+ curvature: number;
206
+ type: "line" | "curved";
173
207
  }
174
208
 
175
209
  export interface BuildSigmaGraphParams {
@@ -180,6 +214,12 @@ export interface BuildSigmaGraphParams {
180
214
  orphanIds: Set<string>;
181
215
  dark?: boolean;
182
216
  edgeColor?: string;
217
+ /**
218
+ * Per-edge bundling curvature (sigma scene space), keyed by edge id. When
219
+ * present and non-zero, the edge carries the value so the curve program can
220
+ * bow it onto its bundle. Absent / 0 → straight edge.
221
+ */
222
+ bundledCurvatures?: Map<string, number>;
183
223
  }
184
224
 
185
225
  /**
@@ -199,6 +239,7 @@ export function buildSigmaGraph(
199
239
  orphanIds,
200
240
  dark = true,
201
241
  edgeColor = "#3a424d",
242
+ bundledCurvatures,
202
243
  } = params;
203
244
 
204
245
  const graph = new Graph<SigmaNodeAttributes, SigmaEdgeAttributes>({
@@ -212,7 +253,12 @@ export function buildSigmaGraph(
212
253
  const type = String(node.data.node_type ?? "node");
213
254
  const incomingScale = sizeScales.get(node.id) ?? 1;
214
255
  const pos = layout.get(node.id);
215
- const size = sigmaNodeSize(type, incomingScale);
256
+ const isSupernode = node.data.__supernode === true;
257
+ const memberCount =
258
+ typeof node.data.__memberCount === "number" ? node.data.__memberCount : 0;
259
+ const size = isSupernode
260
+ ? supernodeSigmaSize(memberCount)
261
+ : sigmaNodeSize(type, incomingScale);
216
262
  const fill = pos
217
263
  ? sigmaColorForCommunity(pos.community, dark)
218
264
  : colorForType(type);
@@ -224,11 +270,13 @@ export function buildSigmaGraph(
224
270
  baseSize: size,
225
271
  color: fill,
226
272
  mutedColor: withAlpha(fill, 0.14),
227
- borderColor: colorForType(type),
273
+ borderColor: isSupernode ? SUPERNODE_STROKE_COLOR : colorForType(type),
228
274
  label: node.label,
229
275
  nodeType: type,
230
276
  community: pos?.community ?? -1,
231
277
  orphan: orphanIds.has(node.id),
278
+ isSupernode,
279
+ memberCount,
232
280
  type: "border",
233
281
  });
234
282
  }
@@ -236,11 +284,18 @@ export function buildSigmaGraph(
236
284
  for (const edge of edges) {
237
285
  if (!graph.hasNode(edge.source) || !graph.hasNode(edge.target)) continue;
238
286
  if (graph.hasEdge(edge.id)) continue;
287
+ const sc = layout.get(edge.source)?.community ?? -1;
288
+ const tc = layout.get(edge.target)?.community ?? -1;
289
+ const aggregated = edge.data?.__aggregated === true;
290
+ const weight = typeof edge.data?.weight === "number" ? edge.data.weight : 1;
239
291
  graph.addEdgeWithKey(edge.id, edge.source, edge.target, {
240
- size: 1,
292
+ // Aggregated cross-cluster edges thicken with the number they fold in.
293
+ size: aggregated ? Math.min(6, 1 + Math.log2(weight + 1)) : 1,
241
294
  color: withAlpha(edgeColor, 0.34),
242
295
  edgeLabel: edge.label ?? "",
243
296
  label: "",
297
+ crossCommunity: sc !== tc,
298
+ curvature: bundledCurvatures?.get(edge.id) ?? 0,
244
299
  type: "line",
245
300
  });
246
301
  }
@@ -248,6 +303,175 @@ export function buildSigmaGraph(
248
303
  return graph;
249
304
  }
250
305
 
306
+ // ─── group-in-a-box geometry (pure; consumed by the SVG fallback directly and
307
+ // by the canvas overlay after a graph→viewport transform) ────────────────
308
+
309
+ export interface CommunityBox {
310
+ community: number;
311
+ /** Member count — drives nothing visual, surfaced for labels/tooltips. */
312
+ count: number;
313
+ /** Tint hue for this community (hsl string). */
314
+ tint: string;
315
+ /** Faint translucent fill so clusters read without drowning the nodes. */
316
+ fill: string;
317
+ /** Stronger translucent stroke for the box border. */
318
+ stroke: string;
319
+ minX: number;
320
+ minY: number;
321
+ maxX: number;
322
+ maxY: number;
323
+ /** Box centre, handy for community labels. */
324
+ cx: number;
325
+ cy: number;
326
+ }
327
+
328
+ export interface CommunityBoxOptions {
329
+ /** Padding (in layout units) added around the member bounding box. */
330
+ padding?: number;
331
+ dark?: boolean;
332
+ }
333
+
334
+ /**
335
+ * One bounding box per community, sized to its members with a small padding.
336
+ * Positions are in the same [0, 100] layout space the SVG fallback draws in;
337
+ * the canvas overlay maps them through sigma's graph→viewport transform.
338
+ * Communities are returned in ascending id order so the output is stable.
339
+ */
340
+ export function computeCommunityBoxes(
341
+ nodes: GraphNode[],
342
+ layout: GraphLayout,
343
+ options: CommunityBoxOptions = {}
344
+ ): CommunityBox[] {
345
+ const { padding = 2.5, dark = true } = options;
346
+ const bounds = new Map<
347
+ number,
348
+ { minX: number; minY: number; maxX: number; maxY: number; count: number }
349
+ >();
350
+
351
+ for (const node of nodes) {
352
+ const pos = layout.get(node.id);
353
+ if (!pos) continue;
354
+ const b = bounds.get(pos.community);
355
+ if (!b) {
356
+ bounds.set(pos.community, {
357
+ minX: pos.x,
358
+ minY: pos.y,
359
+ maxX: pos.x,
360
+ maxY: pos.y,
361
+ count: 1,
362
+ });
363
+ } else {
364
+ b.minX = Math.min(b.minX, pos.x);
365
+ b.minY = Math.min(b.minY, pos.y);
366
+ b.maxX = Math.max(b.maxX, pos.x);
367
+ b.maxY = Math.max(b.maxY, pos.y);
368
+ b.count += 1;
369
+ }
370
+ }
371
+
372
+ return [...bounds.entries()]
373
+ .sort((a, b) => a[0] - b[0])
374
+ .map(([community, b]) => {
375
+ const tint = colorForCommunity(community, dark);
376
+ return {
377
+ community,
378
+ count: b.count,
379
+ tint,
380
+ fill: withAlpha(tint, 0.08),
381
+ stroke: withAlpha(tint, 0.5),
382
+ minX: b.minX - padding,
383
+ minY: b.minY - padding,
384
+ maxX: b.maxX + padding,
385
+ maxY: b.maxY + padding,
386
+ cx: (b.minX + b.maxX) / 2,
387
+ cy: (b.minY + b.maxY) / 2,
388
+ };
389
+ });
390
+ }
391
+
392
+ export interface RoutedEdge {
393
+ id: string;
394
+ source: string;
395
+ target: string;
396
+ label?: string;
397
+ sourceCommunity: number;
398
+ targetCommunity: number;
399
+ x1: number;
400
+ y1: number;
401
+ x2: number;
402
+ y2: number;
403
+ }
404
+
405
+ interface Box {
406
+ minX: number;
407
+ minY: number;
408
+ maxX: number;
409
+ maxY: number;
410
+ }
411
+
412
+ /**
413
+ * Walk the ray from `p` toward `q` and return the point where it leaves the
414
+ * box that contains `p`. Used to clip an inter-community edge to its source /
415
+ * target box borders so the segment connects the boxes rather than cutting
416
+ * through their interiors.
417
+ */
418
+ function exitPoint(
419
+ px: number,
420
+ py: number,
421
+ qx: number,
422
+ qy: number,
423
+ box: Box
424
+ ): { x: number; y: number } {
425
+ const dx = qx - px;
426
+ const dy = qy - py;
427
+ let t = 1;
428
+ if (dx > 0) t = Math.min(t, (box.maxX - px) / dx);
429
+ else if (dx < 0) t = Math.min(t, (box.minX - px) / dx);
430
+ if (dy > 0) t = Math.min(t, (box.maxY - py) / dy);
431
+ else if (dy < 0) t = Math.min(t, (box.minY - py) / dy);
432
+ t = Math.max(0, Math.min(1, t));
433
+ return { x: px + dx * t, y: py + dy * t };
434
+ }
435
+
436
+ /**
437
+ * Route every inter-community edge so it runs between the two community box
438
+ * borders instead of node-centre to node-centre — the group-in-a-box
439
+ * convention that keeps cross-cluster edges out of unrelated clusters.
440
+ * Intra-community edges are left to the native renderer and skipped here.
441
+ */
442
+ export function routeInterCommunityEdges(
443
+ edges: GraphEdge[],
444
+ layout: GraphLayout,
445
+ boxes: CommunityBox[]
446
+ ): RoutedEdge[] {
447
+ const boxByCommunity = new Map(boxes.map((b) => [b.community, b]));
448
+ const out: RoutedEdge[] = [];
449
+ for (const edge of edges) {
450
+ const s = layout.get(edge.source);
451
+ const t = layout.get(edge.target);
452
+ if (!s || !t) continue;
453
+ if (s.community === t.community) continue;
454
+ const sourceBox = boxByCommunity.get(s.community);
455
+ const targetBox = boxByCommunity.get(t.community);
456
+ if (!sourceBox || !targetBox) continue;
457
+ const start = exitPoint(s.x, s.y, t.x, t.y, sourceBox);
458
+ const end = exitPoint(t.x, t.y, s.x, s.y, targetBox);
459
+ out.push({
460
+ id: edge.id,
461
+ source: edge.source,
462
+ target: edge.target,
463
+ label: edge.label,
464
+ sourceCommunity: s.community,
465
+ targetCommunity: t.community,
466
+ x1: start.x,
467
+ y1: start.y,
468
+ x2: end.x,
469
+ y2: end.y,
470
+ });
471
+ }
472
+ return out;
473
+ }
474
+
251
475
  // ─── focus neighbourhood (parity with the canvas focus highlight) ───────────
252
476
 
253
477
  export interface GraphFocus {
@@ -284,6 +508,13 @@ export interface SigmaReducerState {
284
508
  /** Focus-pulse progress, 0 (rest) → 1 (peak). Held at 0 for reduced motion. */
285
509
  pulse: number;
286
510
  colors: SigmaThemeColors;
511
+ /**
512
+ * Group-in-a-box mode: cross-community edges are drawn by the overlay at the
513
+ * box borders, so the native straight edge is hidden to avoid double lines.
514
+ */
515
+ groupInBox?: boolean;
516
+ /** Edge bundling on → edges render through the curve program. */
517
+ bundle: boolean;
287
518
  }
288
519
 
289
520
  export interface SigmaNodeDisplay {
@@ -303,6 +534,10 @@ export interface SigmaEdgeDisplay {
303
534
  forceLabel: boolean;
304
535
  zIndex: number;
305
536
  hidden: boolean;
537
+ /** Which edge program draws this edge: straight "line" or bundled "curved". */
538
+ type: "line" | "curved";
539
+ /** Bow magnitude/sign for the curve program (ignored by the line program). */
540
+ curvature: number;
306
541
  }
307
542
 
308
543
  const NODE_LABEL_MAX = 36;
@@ -337,9 +572,12 @@ export function reduceSigmaNode(
337
572
  ? colors.selectedNodeStroke
338
573
  : attrs.borderColor;
339
574
 
575
+ // Supernodes always carry their cluster/count label so the collapsed state
576
+ // is self-explanatory and obviously clickable.
340
577
  const showLabel =
341
- hasFocus &&
342
- (isPrimary || (focus.nodes.size <= NEIGHBOUR_LABEL_LIMIT && isNeighbor));
578
+ attrs.isSupernode ||
579
+ (hasFocus &&
580
+ (isPrimary || (focus.nodes.size <= NEIGHBOUR_LABEL_LIMIT && isNeighbor)));
343
581
 
344
582
  return {
345
583
  size: attrs.baseSize * scale,
@@ -347,7 +585,7 @@ export function reduceSigmaNode(
347
585
  borderColor,
348
586
  label: showLabel ? attrs.label.slice(0, NODE_LABEL_MAX) : "",
349
587
  forceLabel: showLabel,
350
- zIndex: isPrimary ? 3 : isNeighbor ? 2 : 1,
588
+ zIndex: isPrimary ? 3 : attrs.isSupernode ? 2 : isNeighbor ? 2 : 1,
351
589
  hidden: false,
352
590
  };
353
591
  }
@@ -358,10 +596,32 @@ export function reduceSigmaEdge(
358
596
  attrs: SigmaEdgeAttributes,
359
597
  state: SigmaReducerState
360
598
  ): SigmaEdgeDisplay {
361
- const { focus, focusId, colors } = state;
599
+ const { focus, focusId, colors, groupInBox, bundle } = state;
362
600
  const hasFocus = focusId !== null;
363
601
  const focused = focus.edges.has(id);
364
602
 
603
+ // The sigma edge reducer's return *replaces* the edge data, so type and
604
+ // curvature must be carried through on every branch or the curve program
605
+ // can't see them. Bundle off → always the straight line program.
606
+ const curvature = attrs.curvature;
607
+ const type: "line" | "curved" =
608
+ bundle && Math.abs(curvature) > 1e-4 ? "curved" : "line";
609
+
610
+ // In group-in-a-box mode the overlay owns cross-community edges; hide the
611
+ // native straight line so it doesn't cut through unrelated clusters.
612
+ if (groupInBox && attrs.crossCommunity && !focused) {
613
+ return {
614
+ size: attrs.size,
615
+ color: attrs.color,
616
+ label: "",
617
+ forceLabel: false,
618
+ zIndex: 0,
619
+ hidden: true,
620
+ type,
621
+ curvature,
622
+ };
623
+ }
624
+
365
625
  if (!hasFocus) {
366
626
  return {
367
627
  size: attrs.size,
@@ -370,6 +630,8 @@ export function reduceSigmaEdge(
370
630
  forceLabel: false,
371
631
  zIndex: 0,
372
632
  hidden: false,
633
+ type,
634
+ curvature,
373
635
  };
374
636
  }
375
637
 
@@ -381,6 +643,8 @@ export function reduceSigmaEdge(
381
643
  forceLabel: Boolean(attrs.edgeLabel),
382
644
  zIndex: 2,
383
645
  hidden: false,
646
+ type,
647
+ curvature,
384
648
  };
385
649
  }
386
650
 
@@ -391,5 +655,7 @@ export function reduceSigmaEdge(
391
655
  forceLabel: false,
392
656
  zIndex: 0,
393
657
  hidden: false,
658
+ type,
659
+ curvature,
394
660
  };
395
661
  }
@@ -14,74 +14,90 @@
14
14
  // Both implementations satisfy the same `Router` contract, so every view
15
15
  // component navigates the same way regardless of which Surface it runs in.
16
16
 
17
- import { getContext, setContext } from 'svelte'
18
- import { collectionPageHref } from './collection-pages'
17
+ import { getContext, setContext } from "svelte";
18
+ import { collectionPageHref } from "./collection-pages";
19
19
 
20
- /** The four top-level destinations of the Mountable Root. */
21
- export type View = 'query' | 'collections' | 'cluster' | 'security'
20
+ /** The top-level destinations of the Mountable Root. */
21
+ export type View =
22
+ | "query"
23
+ | "collections"
24
+ | "cluster"
25
+ | "security"
26
+ | "settings"
27
+ | "appearance";
22
28
 
23
29
  /**
24
30
  * A navigation intent. `collection` carries a selected collection + subpage
25
31
  * within the collections workspace; the other targets are the bare views.
26
32
  */
27
33
  export type RouteTarget =
28
- | { view: 'query' }
29
- | { view: 'collections' }
30
- | { view: 'cluster' }
31
- | { view: 'security' }
32
- | { view: 'collection'; collection: string; subpage: string }
34
+ | { view: "query" }
35
+ | { view: "collections" }
36
+ | { view: "cluster" }
37
+ | { view: "security" }
38
+ | { view: "settings" }
39
+ | { view: "appearance" }
40
+ | { view: "collection"; collection: string; subpage: string };
33
41
 
34
42
  /** Resolved current location, the reactive surface views read from. */
35
43
  export interface RouteLocation {
36
- view: View
44
+ view: View;
37
45
  /** Selected collection when inside the collections workspace, else null. */
38
- collection: string | null
46
+ collection: string | null;
39
47
  /** Selected collection subpage (`table`, `graph`, …), else null. */
40
- subpage: string | null
48
+ subpage: string | null;
41
49
  }
42
50
 
43
51
  export interface Router {
44
52
  /** Current top-level view. */
45
- readonly view: View
53
+ readonly view: View;
46
54
  /** Selected collection, or null. */
47
- readonly collection: string | null
55
+ readonly collection: string | null;
48
56
  /** Selected collection subpage, or null. */
49
- readonly subpage: string | null
57
+ readonly subpage: string | null;
50
58
  /** Canonical path for the current location (mirrors the SvelteKit URL). */
51
- readonly path: string
59
+ readonly path: string;
52
60
  /** The canonical href a `RouteTarget` maps to (for `<a href>` display). */
53
- href(target: RouteTarget): string
61
+ href(target: RouteTarget): string;
54
62
  /**
55
63
  * Navigate to a target. When called from an anchor `onclick`, pass the
56
64
  * event so the router can `preventDefault()` (state router) or honour
57
65
  * modifier-clicks for open-in-new-tab (Kit router). `replace` swaps the
58
66
  * current history entry instead of pushing (Kit only; ignored by state).
59
67
  */
60
- go(target: RouteTarget, event?: Event, replace?: boolean): void
68
+ go(target: RouteTarget, event?: Event, replace?: boolean): void;
61
69
  }
62
70
 
63
71
  /** Map a `RouteTarget` to its canonical path. */
64
72
  export function targetToHref(target: RouteTarget): string {
65
73
  switch (target.view) {
66
- case 'query':
67
- return '/query'
68
- case 'collections':
69
- return '/collections'
70
- case 'cluster':
71
- return '/cluster'
72
- case 'security':
73
- return '/security'
74
- case 'collection':
75
- return collectionPageHref(target.collection, target.subpage)
74
+ case "query":
75
+ return "/query";
76
+ case "collections":
77
+ return "/collections";
78
+ case "cluster":
79
+ return "/cluster";
80
+ case "security":
81
+ return "/security";
82
+ case "settings":
83
+ return "/settings";
84
+ case "appearance":
85
+ return "/appearance";
86
+ case "collection":
87
+ return collectionPageHref(target.collection, target.subpage);
76
88
  }
77
89
  }
78
90
 
79
91
  /** Map a `RouteTarget` to a resolved location. */
80
92
  export function targetToLocation(target: RouteTarget): RouteLocation {
81
- if (target.view === 'collection') {
82
- return { view: 'collections', collection: target.collection, subpage: target.subpage }
93
+ if (target.view === "collection") {
94
+ return {
95
+ view: "collections",
96
+ collection: target.collection,
97
+ subpage: target.subpage,
98
+ };
83
99
  }
84
- return { view: target.view, collection: null, subpage: null }
100
+ return { view: target.view, collection: null, subpage: null };
85
101
  }
86
102
 
87
103
  /**
@@ -90,18 +106,25 @@ export function targetToLocation(target: RouteTarget): RouteLocation {
90
106
  * `/collections` for the bare collections workspace.
91
107
  */
92
108
  export function pathToLocation(pathname: string): RouteLocation {
93
- if (pathname.startsWith('/query')) return { view: 'query', collection: null, subpage: null }
94
- if (pathname.startsWith('/cluster')) return { view: 'cluster', collection: null, subpage: null }
95
- if (pathname.startsWith('/security')) return { view: 'security', collection: null, subpage: null }
96
- const m = pathname.match(/^\/c\/([^/]+)(?:\/p\/([^/]+))?/)
109
+ if (pathname.startsWith("/query"))
110
+ return { view: "query", collection: null, subpage: null };
111
+ if (pathname.startsWith("/cluster"))
112
+ return { view: "cluster", collection: null, subpage: null };
113
+ if (pathname.startsWith("/security"))
114
+ return { view: "security", collection: null, subpage: null };
115
+ if (pathname.startsWith("/settings"))
116
+ return { view: "settings", collection: null, subpage: null };
117
+ if (pathname.startsWith("/appearance"))
118
+ return { view: "appearance", collection: null, subpage: null };
119
+ const m = pathname.match(/^\/c\/([^/]+)(?:\/p\/([^/]+))?/);
97
120
  if (m) {
98
121
  return {
99
- view: 'collections',
122
+ view: "collections",
100
123
  collection: decodeURIComponent(m[1]),
101
124
  subpage: m[2] ? decodeURIComponent(m[2]) : null,
102
- }
125
+ };
103
126
  }
104
- return { view: 'collections', collection: null, subpage: null }
127
+ return { view: "collections", collection: null, subpage: null };
105
128
  }
106
129
 
107
130
  /**
@@ -111,43 +134,47 @@ export function pathToLocation(pathname: string): RouteLocation {
111
134
  */
112
135
  export function createStateRouter(initial?: RouteLocation): Router {
113
136
  let loc = $state<RouteLocation>(
114
- initial ?? { view: 'collections', collection: null, subpage: null },
115
- )
137
+ initial ?? { view: "collections", collection: null, subpage: null }
138
+ );
116
139
  return {
117
140
  get view() {
118
- return loc.view
141
+ return loc.view;
119
142
  },
120
143
  get collection() {
121
- return loc.collection
144
+ return loc.collection;
122
145
  },
123
146
  get subpage() {
124
- return loc.subpage
147
+ return loc.subpage;
125
148
  },
126
149
  get path() {
127
150
  return targetToHref(
128
151
  loc.collection
129
- ? { view: 'collection', collection: loc.collection, subpage: loc.subpage ?? 'table' }
130
- : { view: loc.view },
131
- )
152
+ ? {
153
+ view: "collection",
154
+ collection: loc.collection,
155
+ subpage: loc.subpage ?? "table",
156
+ }
157
+ : { view: loc.view }
158
+ );
132
159
  },
133
160
  href: (target) => targetToHref(target),
134
161
  go: (target, event) => {
135
162
  // The href is synthetic in state mode, so always suppress the anchor's
136
163
  // default navigation and switch the in-memory location instead.
137
- event?.preventDefault()
138
- loc = targetToLocation(target)
164
+ event?.preventDefault();
165
+ loc = targetToLocation(target);
139
166
  },
140
- }
167
+ };
141
168
  }
142
169
 
143
- const ROUTER_KEY = Symbol('red-ui-router')
170
+ const ROUTER_KEY = Symbol("red-ui-router");
144
171
 
145
- let fallbackRouter: Router | null = null
172
+ let fallbackRouter: Router | null = null;
146
173
 
147
174
  /** Provide a router to descendant view components (called by the Mountable Root). */
148
175
  export function setRouter(router: Router): Router {
149
- setContext(ROUTER_KEY, router)
150
- return router
176
+ setContext(ROUTER_KEY, router);
177
+ return router;
151
178
  }
152
179
 
153
180
  /**
@@ -156,8 +183,8 @@ export function setRouter(router: Router): Router {
156
183
  * lack of a provider.
157
184
  */
158
185
  export function useRouter(): Router {
159
- const router = getContext<Router | undefined>(ROUTER_KEY)
160
- if (router) return router
161
- if (!fallbackRouter) fallbackRouter = createStateRouter()
162
- return fallbackRouter
186
+ const router = getContext<Router | undefined>(ROUTER_KEY);
187
+ if (router) return router;
188
+ if (!fallbackRouter) fallbackRouter = createStateRouter();
189
+ return fallbackRouter;
163
190
  }