@celestia-island/hikari 0.55.21 → 0.55.22

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.
@@ -11,6 +11,33 @@ import {
11
11
 
12
12
  import "./HkNodeCanvas.scss";
13
13
 
14
+ import {
15
+ computeLod,
16
+ edgeMidpoint,
17
+ edgePath,
18
+ LOD_DEFAULTS,
19
+ PAPER_SIZES,
20
+ type EdgeRouting,
21
+ type LodLevel,
22
+ type LodThresholds,
23
+ type NodeCanvasEdge,
24
+ type NodeCanvasFrameContext,
25
+ type NodeCanvasPainter,
26
+ type PrintPaper,
27
+ } from "./nodeCanvasTypes";
28
+
29
+ // Re-export shared types so consumers can import from the component.
30
+ export type {
31
+ EdgeRouting,
32
+ LodLevel,
33
+ LodThresholds,
34
+ NodeCanvasEdge,
35
+ NodeCanvasFrameContext,
36
+ NodeCanvasPainter,
37
+ PrintPaper,
38
+ } from "./nodeCanvasTypes";
39
+ export { computeLod, edgePath, edgeMidpoint, LOD_DEFAULTS, PAPER_SIZES } from "./nodeCanvasTypes";
40
+
14
41
  /** A camera: zoom factor plus the translation of the content origin. */
15
42
  export interface NodeCanvasCamera {
16
43
  /** Zoom factor (1 = 1:1). */
@@ -88,9 +115,23 @@ interface PendingRequest {
88
115
  * maps back into content coordinates. That is what this component owns, and
89
116
  * it owns it once instead of three times.
90
117
  *
91
- * What it does NOT own: edges, nodes, routing, layout. The host draws those
92
- * through the default slot, which lives inside the transformed layer, so the
93
- * host only has to think in content coordinates.
118
+ * In addition to the camera, the upgraded base owns:
119
+ *
120
+ * - a **Canvas2D painter layer** beneath the DOM: hosts register
121
+ * painters (`registerPainter`) that draw strokes, grids, pipes —
122
+ * anything Canvas2D does better than SVG — in world coordinates,
123
+ * driven by the same camera in a single frame loop.
124
+ * - an **edge layer** (`edges` prop): routing (bezier / orthogonal /
125
+ * direct), labels, styles, and optional flow animation, rendered as
126
+ * SVG so CSS and accessibility work for free.
127
+ * - **LOD** (level of detail): a zoom-derived detail level hosts can
128
+ * read to simplify their nodes at low zoom.
129
+ * - a **print mode**: disables gestures, fixes the camera, and exposes
130
+ * `exportSVG()` for vector output.
131
+ *
132
+ * What it still does NOT own: what nodes look like, what data drives
133
+ * them, or how they are laid out. The host draws node content through
134
+ * the default slot in world coordinates.
94
135
  *
95
136
  * Two behaviours are deliberate:
96
137
  *
@@ -149,6 +190,35 @@ export default defineComponent({
149
190
  default: "bottom-right",
150
191
  },
151
192
  ariaLabel: { type: String, default: undefined },
193
+
194
+ // ── Rendering base upgrades ──────────────────────────────────
195
+
196
+ /** Edges to render between nodes. Routed and styled by the canvas. */
197
+ edges: { type: Array as PropType<NodeCanvasEdge[]>, default: () => [] },
198
+ /** Default routing strategy for edges. Individual edges can override. */
199
+ edgeRouting: {
200
+ type: String as PropType<EdgeRouting>,
201
+ default: "bezier" as EdgeRouting,
202
+ },
203
+ /** Zoom thresholds for level-of-detail. At or above `medium`:
204
+ * full detail. At or above `low`: outlines + labels. Below: outlines. */
205
+ lodThresholds: {
206
+ type: Object as PropType<LodThresholds>,
207
+ default: () => ({ ...LOD_DEFAULTS }),
208
+ },
209
+ /** Frame callback: called every animation frame with camera, dt, LOD,
210
+ * and the Canvas2D context. Use for animations and real-time overlays. */
211
+ onFrame: {
212
+ type: Function as PropType<((frame: NodeCanvasFrameContext) => void) | undefined>,
213
+ default: undefined,
214
+ },
215
+ /** Print mode: disables gestures, fixes proportions for export. */
216
+ printMode: { type: Boolean, default: false },
217
+ /** Paper size for print mode (ignored when printMode is false). */
218
+ printPaper: {
219
+ type: String as PropType<PrintPaper>,
220
+ default: "auto" as PrintPaper,
221
+ },
152
222
  },
153
223
  emits: ["update:camera"],
154
224
  setup(props, { slots, emit, expose }) {
@@ -167,6 +237,29 @@ export default defineComponent({
167
237
  /** Set by setCamera (gestures and imperative calls) — never by `fit`. */
168
238
  let movedByHand = false;
169
239
 
240
+ // ── Canvas2D painter layer ───────────────────────────────────
241
+ const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
242
+ /** Registered painters, sorted by z on each frame. */
243
+ const painters = shallowRef<NodeCanvasPainter[]>([]);
244
+ /** The animation frame loop handle (null when idle). */
245
+ let rafHandle: number | null = null;
246
+ /** Timestamp of the last frame (for dt). */
247
+ let lastFrameTime = 0;
248
+
249
+ // ── LOD ──────────────────────────────────────────────────────
250
+ const levelOfDetail = computed<LodLevel>(() =>
251
+ computeLod(camera.value.k, props.lodThresholds),
252
+ );
253
+
254
+ // ── Edge rendering (computed SVG paths) ──────────────────────
255
+ const edgePaths = computed(() =>
256
+ props.edges.map((edge) => ({
257
+ edge,
258
+ path: edgePath(edge, edge.routing ?? props.edgeRouting),
259
+ mid: edgeMidpoint(edge, edge.routing ?? props.edgeRouting),
260
+ })),
261
+ );
262
+
170
263
  const camera = computed<NodeCanvasCamera>(() => props.camera ?? inner.value);
171
264
 
172
265
  /** Snap a zoom factor onto the grid, then into range. `quantizeScaleStep`
@@ -505,6 +598,7 @@ export default defineComponent({
505
598
  viewport.value = next;
506
599
  }
507
600
 
601
+ onMounted(() => { ensureFrameLoop(); });
508
602
  onMounted(() => {
509
603
  // Measure BEFORE the first paint the watcher can react to: with
510
604
  // `fitOnLoad` this is what makes the first painted frame already be the
@@ -574,6 +668,7 @@ export default defineComponent({
574
668
  );
575
669
 
576
670
  onBeforeUnmount(() => {
671
+ stopFrameLoop();
577
672
  ro?.disconnect();
578
673
  ro = null;
579
674
  panning = null;
@@ -582,6 +677,149 @@ export default defineComponent({
582
677
  window.removeEventListener("blur", loseFocus, true);
583
678
  });
584
679
 
680
+ // ── Canvas painter management ────────────────────────────────
681
+
682
+ function registerPainter(painter: NodeCanvasPainter): void {
683
+ const next = [...painters.value, painter].sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
684
+ painters.value = next;
685
+ ensureFrameLoop();
686
+ }
687
+
688
+ function unregisterPainter(painterId: string): void {
689
+ painters.value = painters.value.filter((p) => p.id !== painterId);
690
+ if (painters.value.length === 0 && !props.onFrame) {
691
+ stopFrameLoop();
692
+ }
693
+ }
694
+
695
+ // ── Frame loop ───────────────────────────────────────────────
696
+ // A single rAF loop drives the Canvas2D layer. It only runs when
697
+ // there are painters or an onFrame callback — zero cost otherwise.
698
+
699
+ function ensureFrameLoop(): void {
700
+ if (rafHandle !== null) return;
701
+ if (painters.value.length === 0 && !props.onFrame) return;
702
+ lastFrameTime = performance.now();
703
+ rafHandle = requestAnimationFrame(frameTick);
704
+ }
705
+
706
+ function stopFrameLoop(): void {
707
+ if (rafHandle !== null) {
708
+ cancelAnimationFrame(rafHandle);
709
+ rafHandle = null;
710
+ }
711
+ }
712
+
713
+ function frameTick(now: number): void {
714
+ const dt = now - lastFrameTime;
715
+ lastFrameTime = now;
716
+ const cam = camera.value;
717
+ const vp = viewport.value;
718
+ const lod = levelOfDetail.value;
719
+
720
+ // Paint the canvas layer.
721
+ const canvas = canvasEl.value;
722
+ if (canvas) {
723
+ const dpr = window.devicePixelRatio || 1;
724
+ const w = vp.width;
725
+ const h = vp.height;
726
+ // Only resize when dimensions actually changed (avoid clearing).
727
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
728
+ canvas.width = Math.round(w * dpr);
729
+ canvas.height = Math.round(h * dpr);
730
+ }
731
+ const ctx = canvas.getContext("2d");
732
+ if (ctx) {
733
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
734
+ ctx.clearRect(0, 0, w, h);
735
+ // Apply camera transform so painters draw in world coordinates.
736
+ ctx.translate(cam.x, cam.y);
737
+ ctx.scale(cam.k, cam.k);
738
+
739
+ const frame: NodeCanvasFrameContext = {
740
+ camera: cam,
741
+ viewport: vp,
742
+ dt,
743
+ lod,
744
+ ctx,
745
+ };
746
+
747
+ for (const painter of painters.value) {
748
+ painter.draw(frame);
749
+ }
750
+
751
+ // View-level frame callback (after painters, so it can overlay).
752
+ props.onFrame?.(frame);
753
+ }
754
+ }
755
+
756
+ rafHandle = requestAnimationFrame(frameTick);
757
+ }
758
+
759
+ // Start/stop the loop when props change.
760
+ watch(
761
+ () => props.onFrame,
762
+ (fn) => {
763
+ if (fn) ensureFrameLoop();
764
+ else if (painters.value.length === 0) stopFrameLoop();
765
+ },
766
+ );
767
+
768
+ // ── Print / Export ───────────────────────────────────────────
769
+
770
+ /** Export the edge layer as an SVG element (nodes are NOT included —
771
+ * hosts that need node content in the export should compose it
772
+ * separately). Useful for print-mode output and documentation. */
773
+ function exportSVG(): SVGElement {
774
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
775
+ const paper = PAPER_SIZES[props.printPaper];
776
+ const vp = viewport.value;
777
+ svg.setAttribute("width", String(paper?.width ?? vp.width));
778
+ svg.setAttribute("height", String(paper?.height ?? vp.height));
779
+ svg.setAttribute("viewBox", `0 0 ${paper?.width ?? vp.width} ${paper?.height ?? vp.height}`);
780
+
781
+ // Copy edge paths into the export SVG.
782
+ const edgesGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
783
+ for (const { edge, path } of edgePaths.value) {
784
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
785
+ p.setAttribute("d", path);
786
+ p.setAttribute("fill", "none");
787
+ p.setAttribute("stroke", edge.color ?? "currentColor");
788
+ p.setAttribute("stroke-width", String(edge.width ?? 1.5));
789
+ if (edge.dashed) p.setAttribute("stroke-dasharray", "6 4");
790
+ edgesGroup.appendChild(p);
791
+ if (edge.label) {
792
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
793
+ const mid = edgeMidpoint(edge, edge.routing ?? props.edgeRouting);
794
+ text.setAttribute("x", String(mid.x));
795
+ text.setAttribute("y", String(mid.y - 6));
796
+ text.setAttribute("text-anchor", "middle");
797
+ text.setAttribute("font-size", "12");
798
+ text.textContent = edge.label;
799
+ edgesGroup.appendChild(text);
800
+ }
801
+ }
802
+ svg.appendChild(edgesGroup);
803
+ return svg;
804
+ }
805
+
806
+ /** Export the canvas as a PNG image element. */
807
+ function exportPNG(): HTMLCanvasElement {
808
+ const source = canvasEl.value;
809
+ if (!source) {
810
+ // Return an empty canvas if no canvas layer exists.
811
+ const empty = document.createElement("canvas");
812
+ empty.width = 1;
813
+ empty.height = 1;
814
+ return empty;
815
+ }
816
+ const copy = document.createElement("canvas");
817
+ copy.width = source.width;
818
+ copy.height = source.height;
819
+ copy.getContext("2d")?.drawImage(source, 0, 0);
820
+ return copy;
821
+ }
822
+
585
823
  expose({
586
824
  camera,
587
825
  viewport,
@@ -591,6 +829,12 @@ export default defineComponent({
591
829
  screenToWorld,
592
830
  worldToScreen,
593
831
  setCamera,
832
+ // ── New: rendering base capabilities ──
833
+ levelOfDetail,
834
+ registerPainter,
835
+ unregisterPainter,
836
+ exportSVG,
837
+ exportPNG,
594
838
  });
595
839
 
596
840
  const slotProps = computed<NodeCanvasSlotProps>(() => ({
@@ -601,23 +845,79 @@ export default defineComponent({
601
845
 
602
846
  return () => {
603
847
  const { k, x, y } = camera.value;
848
+ const isPrint = props.printMode;
849
+ const paper = isPrint ? PAPER_SIZES[props.printPaper] : null;
604
850
  return (
605
851
  <div
606
852
  ref={rootEl}
607
- class="hk-node-canvas"
608
- data-pannable={props.pannable ? "" : undefined}
853
+ class={{
854
+ "hk-node-canvas": true,
855
+ "hk-node-canvas--print": isPrint,
856
+ }}
857
+ data-pannable={!isPrint && props.pannable ? "" : undefined}
609
858
  aria-label={props.ariaLabel}
610
859
  style={{
611
860
  // Only a surface that consumes the gesture may take touch scrolling
612
- // away from the page.
613
- touchAction: props.pannable || props.zoomable ? "none" : undefined,
861
+ // away from the page. Print mode never takes scrolling.
862
+ touchAction: !isPrint && (props.pannable || props.zoomable) ? "none" : undefined,
863
+ ...(paper && { width: `${paper.width}px`, height: `${paper.height}px` }),
614
864
  }}
615
- onWheel={onWheel}
616
- onPointerdown={onPointerDown}
617
- onPointermove={onPointerMove}
618
- onPointerup={endPan}
619
- onPointercancel={endPan}
865
+ onWheel={isPrint ? undefined : onWheel}
866
+ onPointerdown={isPrint ? undefined : onPointerDown}
867
+ onPointermove={isPrint ? undefined : onPointerMove}
868
+ onPointerup={isPrint ? undefined : endPan}
869
+ onPointercancel={isPrint ? undefined : endPan}
620
870
  >
871
+ {/* Canvas2D layer: painters draw strokes/grids/pipes in world
872
+ coordinates, driven by the same camera as the DOM layer. */}
873
+ <canvas
874
+ ref={canvasEl}
875
+ class="hk-node-canvas-canvas"
876
+ style={{
877
+ position: "absolute",
878
+ inset: 0,
879
+ pointerEvents: "none",
880
+ }}
881
+ />
882
+ {/* SVG edge layer: routed edges between nodes, rendered as SVG
883
+ for CSS styling and accessibility. Lives inside the camera
884
+ transform so edges stay in sync with DOM nodes. */}
885
+ {edgePaths.value.length > 0 && (
886
+ <svg
887
+ class="hk-node-canvas-edges"
888
+ style={{
889
+ position: "absolute",
890
+ inset: 0,
891
+ pointerEvents: "none",
892
+ overflow: "visible",
893
+ }}
894
+ >
895
+ <g transform={`translate(${x} ${y}) scale(${k})`}>
896
+ {edgePaths.value.map(({ edge, path, mid }) => (
897
+ <g key={edge.id} class="hk-node-canvas-edge" data-edge-type={edge.type}>
898
+ <path
899
+ d={path}
900
+ fill="none"
901
+ stroke={edge.color ?? "var(--hk-border-3, #555)"}
902
+ stroke-width={edge.width ?? 1.5}
903
+ stroke-dasharray={edge.dashed ? "6 4" : undefined}
904
+ />
905
+ {edge.label && (
906
+ <text
907
+ x={mid.x}
908
+ y={mid.y - 6}
909
+ text-anchor="middle"
910
+ font-size="11"
911
+ fill="var(--hk-text-2, #aaa)"
912
+ >
913
+ {edge.label}
914
+ </text>
915
+ )}
916
+ </g>
917
+ ))}
918
+ </g>
919
+ </svg>
920
+ )}
621
921
  {/* The transformed layer: the host draws in CONTENT coordinates and
622
922
  this transform puts it on screen. */}
623
923
  <div
@@ -627,7 +927,7 @@ export default defineComponent({
627
927
  {slots.default?.(slotProps.value)}
628
928
  </div>
629
929
  <div class="hk-node-canvas-overlay">{slots.overlay?.(slotProps.value)}</div>
630
- {props.minimap && (
930
+ {!isPrint && props.minimap && (
631
931
  <div
632
932
  class="hk-node-canvas-minimap"
633
933
  data-placement={props.minimapPlacement}
@@ -24,6 +24,7 @@ import HkTooltip from "./HkTooltip";
24
24
  import { HkPlaceholderMarquee, type PlaceholderVariant } from "./HkPlaceholderMarquee";
25
25
  import {
26
26
  layoutRevealGlyphs,
27
+ parseColorTriple,
27
28
  RevealFilterPainter,
28
29
  RevealNoisePainter,
29
30
  sweepWindow,
@@ -52,16 +53,15 @@ interface Ripple {
52
53
  * Right-edge affordance (`passwordTrailing`):
53
54
  * - "eye" (default): the reveal button. What the reveal SHOWS is chosen
54
55
  * by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
55
- * - strategy "filter" (default): dual counter-drifting spatter
56
+ * - strategy "filter" (default): dual FAST counter-drifting spatter
56
57
  * layers in strict BLACK/WHITE/GRAY — the glyph row is a set of
57
58
  * STATIC bold apertures filled with a gray spatter texture
58
- * drifting one way, over a statistically identical spatter field
59
- * drifting the other way on the theme's ground (near-black in a
60
- * dark theme, near-white in a light one), the glyphs shifted by a
61
- * small lightness pedestal toward visibility with a soft halo
62
- * band around the row (see revealKinematogram.ts). The whole row
63
- * stays readable in motion while any single frame — a screenshot
64
- * — carries no glyph structure, only a weak mean-luminance signal
59
+ * streaming one way, over a statistically identical spatter field
60
+ * streaming the other way, both on a UNIFORM near-black ground
61
+ * (white-on-black reading in every theme). The drift is very fast
62
+ * so persistence of vision averages the aperture interiors into
63
+ * readable near-solid glyphs. A single frame — a screenshot —
64
+ * carries no glyph structure, only a weak mean-luminance signal
65
65
  * dissolved into the halo ramp. Reduced motion or a pattern-less
66
66
  * engine degrades to the fully readable static plain text.
67
67
  * - strategy "sweep": a readable window — the password is drawn as
@@ -120,13 +120,13 @@ export default defineComponent({
120
120
  },
121
121
  /**
122
122
  * What the eye reveal SHOWS:
123
- * - "filter" (default): dual counter-drifting spatter layers in
124
- * strict black/white/gray — static BOLD glyph apertures over an
125
- * oppositely drifting, statistically matched gray spatter field
126
- * on the theme's ground (near-black dark theme / near-white
127
- * light theme), plus a small visibility-direction lightness
128
- * pedestal and halo. Readable in motion; a single screenshot
129
- * carries no glyph structure, only a weak luminance signal.
123
+ * - "filter" (default): dual FAST counter-drifting spatter layers
124
+ * in strict black/white/gray — static BOLD glyph apertures over
125
+ * an oppositely streaming, statistically matched gray spatter
126
+ * field on a UNIFORM near-black ground (white-on-black in every
127
+ * theme), plus a small brightening pedestal and white halo; the
128
+ * speed lets persistence of vision average the glyphs readable.
129
+ * A single screenshot carries no glyph structure.
130
130
  * - "sweep": a readable window — ordinary high-contrast text inside
131
131
  * a narrow band sweeping across the row. Easy to read; a single
132
132
  * screenshot leaks the band's characters in the clear.
@@ -311,25 +311,6 @@ export default defineComponent({
311
311
  const ripples: Ripple[] = [];
312
312
  let ro: ResizeObserver | null = null;
313
313
 
314
- function parseColorTriple(raw: string): [number, number, number] | null {
315
- // Modern color functions (oklch/lab/color()) would split into
316
- // garbage numeric triples — reject them and keep the caller's
317
- // previous base instead (latent for consumer themes authored in
318
- // those functions; hikari's own themes use rgb triplets/hex).
319
- if (/^(oklch|oklab|lab|lch|color)\(/i.test(raw.trim())) return null;
320
- // Leading separators are REAL here: a computed `rgb(r, g, b)`
321
- // string starts with "rgb(" and the split would yield a leading
322
- // EMPTY token — Number("") === 0, which silently shifts the
323
- // triple to [0, r, g] and skews every derived HSL channel (the
324
- // filter theme proxy read a light ink as 45.7% lightness).
325
- const ns = raw
326
- .split(/[\s,()rgba]+/)
327
- .filter((t) => t !== "")
328
- .map(Number)
329
- .filter((n) => !isNaN(n));
330
- return ns.length >= 3 ? [ns[0], ns[1], ns[2]] : null;
331
- }
332
-
333
314
  function syncColor() {
334
315
  try {
335
316
  const cs = getComputedStyle(document.documentElement);
@@ -802,12 +783,10 @@ export default defineComponent({
802
783
  sweepT = 0;
803
784
  if (!parked) {
804
785
  if (props.revealStrategy === "filter") {
805
- // Grayscale spatter anchored to the effective theme: dark
806
- // themes carry light field ink, so the ink lightness is
807
- // the theme proxy — light ink (>= 50) means a dark theme
808
- // (near-black ground), dark ink a light theme (near-white
809
- // ground). No theme hue survives into the spatter.
810
- revealFilter.beginHold(textHsl[2] >= 50 ? "dark" : "light", dpr);
786
+ // Grayscale spatter on the uniform near-black ground — the
787
+ // reveal reads white-on-black in EVERY theme (the theme-
788
+ // following light variant read far worse and was dropped).
789
+ revealFilter.beginHold(dpr);
811
790
  } else {
812
791
  revealNoise.beginHold(textHsl);
813
792
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * nodeCanvasTypes.ts — Shared types for the HkNodeCanvas rendering base.
3
+ *
4
+ * These types are consumed by views (network topology, ladder diagram,
5
+ * mind map) that build on HkNodeCanvas. They are framework-agnostic
6
+ * (no Vue imports) so they can be used in pure computation modules too.
7
+ */
8
+
9
+ // ── Camera & Bounds (re-exported from the main component) ────────────────
10
+
11
+ export interface NodeCanvasCamera {
12
+ /** Zoom factor (1 = 1:1). */
13
+ k: number;
14
+ /** Translation of the content origin, in screen pixels. */
15
+ x: number;
16
+ /** Translation of the content origin, in screen pixels. */
17
+ y: number;
18
+ }
19
+
20
+ export interface NodeCanvasBounds {
21
+ x: number;
22
+ y: number;
23
+ width: number;
24
+ height: number;
25
+ }
26
+
27
+ export interface NodeCanvasSlotProps {
28
+ camera: NodeCanvasCamera;
29
+ viewport: { width: number; height: number };
30
+ contentBounds: NodeCanvasBounds | null;
31
+ }
32
+
33
+ // ── Level of Detail ──────────────────────────────────────────────────────
34
+
35
+ /** Level of detail, derived from the camera zoom factor. */
36
+ export type LodLevel = "high" | "medium" | "low";
37
+
38
+ /** Zoom thresholds: at or above `medium` shows full detail, at or above
39
+ * `low` shows outlines + labels, below `low` shows outlines only. */
40
+ export interface LodThresholds {
41
+ medium: number;
42
+ low: number;
43
+ }
44
+
45
+ export const LOD_DEFAULTS: LodThresholds = {
46
+ medium: 0.45,
47
+ low: 0.18,
48
+ };
49
+
50
+ /** Compute the LOD level from a zoom factor and thresholds. */
51
+ export function computeLod(k: number, t: LodThresholds): LodLevel {
52
+ if (k >= t.medium) return "high";
53
+ if (k >= t.low) return "medium";
54
+ return "low";
55
+ }
56
+
57
+ // ── Canvas Painter ───────────────────────────────────────────────────────
58
+
59
+ /** Context passed to canvas painters and the onFrame callback each frame. */
60
+ export interface NodeCanvasFrameContext {
61
+ camera: NodeCanvasCamera;
62
+ viewport: { width: number; height: number };
63
+ /** Milliseconds since the last frame (for animations). */
64
+ dt: number;
65
+ /** Current LOD level. */
66
+ lod: LodLevel;
67
+ /** The Canvas2D context, already transformed to world coordinates. */
68
+ ctx: CanvasRenderingContext2D;
69
+ }
70
+
71
+ /** A canvas painter: called every frame while mounted. Painters draw
72
+ * strokes, grids, pipes, edges — anything that benefits from Canvas2D
73
+ * performance. They draw in WORLD coordinates (the camera transform
74
+ * is already applied to the context). */
75
+ export interface NodeCanvasPainter {
76
+ /** Unique identifier for registration/removal. */
77
+ id: string;
78
+ /** Called every frame. Draw in world coordinates. */
79
+ draw: (frame: NodeCanvasFrameContext) => void;
80
+ /** Painters with smaller z are drawn first (default 0). */
81
+ z?: number;
82
+ }
83
+
84
+ // ── Edges ────────────────────────────────────────────────────────────────
85
+
86
+ /** How an edge is routed from source to target. */
87
+ export type EdgeRouting = "bezier" | "orthogonal" | "direct";
88
+
89
+ /** An edge between two points (or two nodes, resolved by the host). */
90
+ export interface NodeCanvasEdge {
91
+ id: string;
92
+ /** Source point in world coordinates. */
93
+ from: { x: number; y: number };
94
+ /** Target point in world coordinates. */
95
+ to: { x: number; y: number };
96
+ /** Semantic type — views can use this for styling. */
97
+ type?: string;
98
+ /** Label rendered at the edge midpoint. */
99
+ label?: string;
100
+ /** Override the global routing strategy for this edge. */
101
+ routing?: EdgeRouting;
102
+ /** Stroke color (CSS color value or CSS variable name). */
103
+ color?: string;
104
+ /** Stroke width in world units. */
105
+ width?: number;
106
+ /** Dashed line. */
107
+ dashed?: boolean;
108
+ }
109
+
110
+ // ── Edge Geometry (pure functions) ───────────────────────────────────────
111
+
112
+ /** Compute an SVG path string for an edge using the given routing. */
113
+ export function edgePath(
114
+ edge: Pick<NodeCanvasEdge, "from" | "to">,
115
+ routing: EdgeRouting,
116
+ ): string {
117
+ const { from, to } = edge;
118
+ switch (routing) {
119
+ case "direct":
120
+ return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
121
+ case "orthogonal":
122
+ return orthogonalPath(from, to);
123
+ case "bezier":
124
+ default:
125
+ return bezierPath(from, to);
126
+ }
127
+ }
128
+
129
+ /** Cubic bezier: control points at 40% horizontal offset. */
130
+ function bezierPath(
131
+ from: { x: number; y: number },
132
+ to: { x: number; y: number },
133
+ ): string {
134
+ const dx = Math.abs(to.x - from.x);
135
+ const cp = Math.max(dx * 0.4, 40);
136
+ return `M ${from.x} ${from.y} C ${from.x + cp} ${from.y}, ${to.x - cp} ${to.y}, ${to.x} ${to.y}`;
137
+ }
138
+
139
+ /** Manhattan routing: horizontal → vertical → horizontal. */
140
+ function orthogonalPath(
141
+ from: { x: number; y: number },
142
+ to: { x: number; y: number },
143
+ ): string {
144
+ const midX = (from.x + to.x) / 2;
145
+ return `M ${from.x} ${from.y} L ${midX} ${from.y} L ${midX} ${to.y} L ${to.x} ${to.y}`;
146
+ }
147
+
148
+ /** Compute the midpoint of an edge (for label placement). */
149
+ export function edgeMidpoint(
150
+ edge: Pick<NodeCanvasEdge, "from" | "to">,
151
+ routing: EdgeRouting,
152
+ ): { x: number; y: number } {
153
+ const { from, to } = edge;
154
+ if (routing === "orthogonal") {
155
+ const midX = (from.x + to.x) / 2;
156
+ return { x: midX, y: (from.y + to.y) / 2 };
157
+ }
158
+ return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
159
+ }
160
+
161
+ // ── Print ────────────────────────────────────────────────────────────────
162
+
163
+ export type PrintPaper = "a4-landscape" | "a3-landscape" | "auto";
164
+
165
+ /** Paper dimensions in CSS pixels at 96 DPI. */
166
+ export const PAPER_SIZES: Record<PrintPaper, { width: number; height: number } | null> = {
167
+ "a4-landscape": { width: 1122.5, height: 793.7 },
168
+ "a3-landscape": { width: 1587.4, height: 1122.5 },
169
+ auto: null,
170
+ };