@celestia-island/hikari 0.55.21 → 0.55.23

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,55 @@ 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
+ },
222
+
223
+ // ── Gesture & animation upgrades ─────────────────────────────
224
+
225
+ /** Camera tween duration in ms (0 disables tweening). Only
226
+ * `tweenCamera()` animates over this duration with ease-out cubic.
227
+ * Snaps instantly when prefers-reduced-motion is active and
228
+ * `respectReducedMotion` is on (default). */
229
+ tweenMs: { type: Number, default: 0 },
230
+ /** Two-finger pinch-to-zoom on touch devices. */
231
+ pinchable: { type: Boolean, default: true },
232
+ /** Snap tweens when prefers-reduced-motion is active (default). */
233
+ respectReducedMotion: { type: Boolean, default: true },
234
+ /** DPI scale provider: maps pointer coordinates to drawn coordinates.
235
+ * Needed when the canvas is inside a CSS `zoom` or `transform: scale()`
236
+ * container — pointer events report unscaled page coordinates but the
237
+ * camera measures the canvas's own pixels. */
238
+ pointerScale: {
239
+ type: Function as PropType<(() => { x: number; y: number }) | undefined>,
240
+ default: undefined,
241
+ },
152
242
  },
153
243
  emits: ["update:camera"],
154
244
  setup(props, { slots, emit, expose }) {
@@ -167,6 +257,50 @@ export default defineComponent({
167
257
  /** Set by setCamera (gestures and imperative calls) — never by `fit`. */
168
258
  let movedByHand = false;
169
259
 
260
+ // ── Pinch-to-zoom state ──────────────────────────────────────
261
+ /** Active pointers for pinch tracking (pointerId → screen position). */
262
+ const activePointers = new Map<number, { x: number; y: number }>();
263
+ /** Pinch gesture snapshot: start distance/zoom/midpoint for the ratio. */
264
+ let pinchSnapshot: {
265
+ startDist: number;
266
+ startZoom: number;
267
+ midX: number;
268
+ midY: number;
269
+ } | null = null;
270
+
271
+ // ── Camera tween state ───────────────────────────────────────
272
+ let tweenRaf: number | null = null;
273
+
274
+ // ── Motion constraints ───────────────────────────────────────
275
+ const reducedMotion = computed(() => {
276
+ if (!props.respectReducedMotion) return false;
277
+ return typeof window !== "undefined"
278
+ && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
279
+ });
280
+
281
+ // ── Canvas2D painter layer ───────────────────────────────────
282
+ const canvasEl = shallowRef<HTMLCanvasElement | null>(null);
283
+ /** Registered painters, sorted by z on each frame. */
284
+ const painters = shallowRef<NodeCanvasPainter[]>([]);
285
+ /** The animation frame loop handle (null when idle). */
286
+ let rafHandle: number | null = null;
287
+ /** Timestamp of the last frame (for dt). */
288
+ let lastFrameTime = 0;
289
+
290
+ // ── LOD ──────────────────────────────────────────────────────
291
+ const levelOfDetail = computed<LodLevel>(() =>
292
+ computeLod(camera.value.k, props.lodThresholds),
293
+ );
294
+
295
+ // ── Edge rendering (computed SVG paths) ──────────────────────
296
+ const edgePaths = computed(() =>
297
+ props.edges.map((edge) => ({
298
+ edge,
299
+ path: edgePath(edge, edge.routing ?? props.edgeRouting),
300
+ mid: edgeMidpoint(edge, edge.routing ?? props.edgeRouting),
301
+ })),
302
+ );
303
+
170
304
  const camera = computed<NodeCanvasCamera>(() => props.camera ?? inner.value);
171
305
 
172
306
  /** Snap a zoom factor onto the grid, then into range. `quantizeScaleStep`
@@ -402,6 +536,11 @@ export default defineComponent({
402
536
  }
403
537
 
404
538
  function onPointerDown(event: PointerEvent) {
539
+ // When a pinch is live, cancel any single-pointer pan.
540
+ if (pinchSnapshot !== null) {
541
+ panning = null;
542
+ return;
543
+ }
405
544
  if (!props.pannable || event.button !== 0) return;
406
545
  // A host that drags its own nodes suppresses the pan by preventing the
407
546
  // default on its card's pointerdown: the root listens in the bubble
@@ -424,6 +563,7 @@ export default defineComponent({
424
563
  ) {
425
564
  return;
426
565
  }
566
+ trackPointer(event);
427
567
  panning = {
428
568
  pointerId: event.pointerId,
429
569
  x: event.clientX,
@@ -442,6 +582,14 @@ export default defineComponent({
442
582
  }
443
583
 
444
584
  function onPointerMove(event: PointerEvent) {
585
+ // Update pinch tracking when a tracked pointer moves.
586
+ if (activePointers.has(event.pointerId)) {
587
+ activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
588
+ if (pinchSnapshot !== null) {
589
+ updatePinch();
590
+ return;
591
+ }
592
+ }
445
593
  if (!panning || event.pointerId !== panning.pointerId) return;
446
594
  // The gesture is only captured once it passes the slop, so a release that
447
595
  // happens outside the canvas never reaches this element. Without this the
@@ -474,6 +622,8 @@ export default defineComponent({
474
622
  }
475
623
 
476
624
  function endPan(event: PointerEvent) {
625
+ endPointerTracking(event);
626
+ if (pinchSnapshot !== null) return;
477
627
  if (!panning || event.pointerId !== panning.pointerId) return;
478
628
  const pointerId = panning.pointerId;
479
629
  panning = null;
@@ -487,6 +637,11 @@ export default defineComponent({
487
637
  // canvas clears focus), and ending the gesture there would kill the first
488
638
  // pan after any click. Only the window's own blur ends it.
489
639
  if (event.target && event.target !== window) return;
640
+ // Clear pinch state too: a blur during a two-finger gesture would
641
+ // otherwise leave a stale snapshot that permanently suppresses pan
642
+ // (every pointerdown would hit the pinch-active early return).
643
+ activePointers.clear();
644
+ pinchSnapshot = null;
490
645
  if (!panning) return;
491
646
  const pointerId = panning.pointerId;
492
647
  panning = null;
@@ -505,6 +660,7 @@ export default defineComponent({
505
660
  viewport.value = next;
506
661
  }
507
662
 
663
+ onMounted(() => { ensureFrameLoop(); });
508
664
  onMounted(() => {
509
665
  // Measure BEFORE the first paint the watcher can react to: with
510
666
  // `fitOnLoad` this is what makes the first painted frame already be the
@@ -574,6 +730,8 @@ export default defineComponent({
574
730
  );
575
731
 
576
732
  onBeforeUnmount(() => {
733
+ cancelTween();
734
+ stopFrameLoop();
577
735
  ro?.disconnect();
578
736
  ro = null;
579
737
  panning = null;
@@ -582,6 +740,240 @@ export default defineComponent({
582
740
  window.removeEventListener("blur", loseFocus, true);
583
741
  });
584
742
 
743
+ // ── Canvas painter management ────────────────────────────────
744
+
745
+ function registerPainter(painter: NodeCanvasPainter): void {
746
+ const next = [...painters.value, painter].sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
747
+ painters.value = next;
748
+ ensureFrameLoop();
749
+ }
750
+
751
+ function unregisterPainter(painterId: string): void {
752
+ painters.value = painters.value.filter((p) => p.id !== painterId);
753
+ if (painters.value.length === 0 && !props.onFrame) {
754
+ stopFrameLoop();
755
+ }
756
+ }
757
+
758
+ // ── Frame loop ───────────────────────────────────────────────
759
+ // A single rAF loop drives the Canvas2D layer. It only runs when
760
+ // there are painters or an onFrame callback — zero cost otherwise.
761
+
762
+ function ensureFrameLoop(): void {
763
+ if (rafHandle !== null) return;
764
+ if (painters.value.length === 0 && !props.onFrame) return;
765
+ lastFrameTime = performance.now();
766
+ rafHandle = requestAnimationFrame(frameTick);
767
+ }
768
+
769
+ function stopFrameLoop(): void {
770
+ if (rafHandle !== null) {
771
+ cancelAnimationFrame(rafHandle);
772
+ rafHandle = null;
773
+ }
774
+ }
775
+
776
+ function frameTick(now: number): void {
777
+ const dt = now - lastFrameTime;
778
+ lastFrameTime = now;
779
+ const cam = camera.value;
780
+ const vp = viewport.value;
781
+ const lod = levelOfDetail.value;
782
+
783
+ // Paint the canvas layer.
784
+ const canvas = canvasEl.value;
785
+ if (canvas) {
786
+ const dpr = window.devicePixelRatio || 1;
787
+ const w = vp.width;
788
+ const h = vp.height;
789
+ // Only resize when dimensions actually changed (avoid clearing).
790
+ if (canvas.width !== Math.round(w * dpr) || canvas.height !== Math.round(h * dpr)) {
791
+ canvas.width = Math.round(w * dpr);
792
+ canvas.height = Math.round(h * dpr);
793
+ }
794
+ const ctx = canvas.getContext("2d");
795
+ if (ctx) {
796
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
797
+ ctx.clearRect(0, 0, w, h);
798
+ // Apply camera transform so painters draw in world coordinates.
799
+ ctx.translate(cam.x, cam.y);
800
+ ctx.scale(cam.k, cam.k);
801
+
802
+ const frame: NodeCanvasFrameContext = {
803
+ camera: cam,
804
+ viewport: vp,
805
+ dt,
806
+ lod,
807
+ ctx,
808
+ };
809
+
810
+ for (const painter of painters.value) {
811
+ painter.draw(frame);
812
+ }
813
+
814
+ // View-level frame callback (after painters, so it can overlay).
815
+ props.onFrame?.(frame);
816
+ }
817
+ }
818
+
819
+ rafHandle = requestAnimationFrame(frameTick);
820
+ }
821
+
822
+ // Start/stop the loop when props change.
823
+ watch(
824
+ () => props.onFrame,
825
+ (fn) => {
826
+ if (fn) ensureFrameLoop();
827
+ else if (painters.value.length === 0) stopFrameLoop();
828
+ },
829
+ );
830
+
831
+ // ── Print / Export ───────────────────────────────────────────
832
+
833
+ /** Export the edge layer as an SVG element (nodes are NOT included —
834
+ * hosts that need node content in the export should compose it
835
+ * separately). Useful for print-mode output and documentation. */
836
+ function exportSVG(): SVGElement {
837
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
838
+ const paper = PAPER_SIZES[props.printPaper];
839
+ const vp = viewport.value;
840
+ svg.setAttribute("width", String(paper?.width ?? vp.width));
841
+ svg.setAttribute("height", String(paper?.height ?? vp.height));
842
+ svg.setAttribute("viewBox", `0 0 ${paper?.width ?? vp.width} ${paper?.height ?? vp.height}`);
843
+
844
+ // Copy edge paths into the export SVG.
845
+ const edgesGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
846
+ for (const { edge, path } of edgePaths.value) {
847
+ const p = document.createElementNS("http://www.w3.org/2000/svg", "path");
848
+ p.setAttribute("d", path);
849
+ p.setAttribute("fill", "none");
850
+ p.setAttribute("stroke", edge.color ?? "currentColor");
851
+ p.setAttribute("stroke-width", String(edge.width ?? 1.5));
852
+ if (edge.dashed) p.setAttribute("stroke-dasharray", "6 4");
853
+ edgesGroup.appendChild(p);
854
+ if (edge.label) {
855
+ const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
856
+ const mid = edgeMidpoint(edge, edge.routing ?? props.edgeRouting);
857
+ text.setAttribute("x", String(mid.x));
858
+ text.setAttribute("y", String(mid.y - 6));
859
+ text.setAttribute("text-anchor", "middle");
860
+ text.setAttribute("font-size", "12");
861
+ text.textContent = edge.label;
862
+ edgesGroup.appendChild(text);
863
+ }
864
+ }
865
+ svg.appendChild(edgesGroup);
866
+ return svg;
867
+ }
868
+
869
+ /** Export the canvas as a PNG image element. */
870
+ function exportPNG(): HTMLCanvasElement {
871
+ const source = canvasEl.value;
872
+ if (!source) {
873
+ // Return an empty canvas if no canvas layer exists.
874
+ const empty = document.createElement("canvas");
875
+ empty.width = 1;
876
+ empty.height = 1;
877
+ return empty;
878
+ }
879
+ const copy = document.createElement("canvas");
880
+ copy.width = source.width;
881
+ copy.height = source.height;
882
+ copy.getContext("2d")?.drawImage(source, 0, 0);
883
+ return copy;
884
+ }
885
+
886
+ // ── Pinch-to-zoom ────────────────────────────────────────────
887
+
888
+ /** Track active pointers for pinch detection. */
889
+ function trackPointer(event: PointerEvent): void {
890
+ activePointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
891
+ if (
892
+ props.pinchable
893
+ && !props.printMode
894
+ && activePointers.size === 2
895
+ && pinchSnapshot === null
896
+ ) {
897
+ const [a, b] = [...activePointers.values()];
898
+ const dx = b.x - a.x;
899
+ const dy = b.y - a.y;
900
+ pinchSnapshot = {
901
+ startDist: Math.hypot(dx, dy),
902
+ startZoom: camera.value.k,
903
+ midX: (a.x + b.x) / 2,
904
+ midY: (a.y + b.y) / 2,
905
+ };
906
+ }
907
+ }
908
+
909
+ /** Update pinch zoom from current pointer positions. */
910
+ function updatePinch(): void {
911
+ if (!pinchSnapshot || activePointers.size < 2) return;
912
+ const [a, b] = [...activePointers.values()];
913
+ const dist = Math.hypot(b.x - a.x, b.y - a.y);
914
+ if (pinchSnapshot.startDist < 1) return;
915
+ const ratio = dist / pinchSnapshot.startDist;
916
+ const targetK = normalizeZoom(pinchSnapshot.startZoom * ratio);
917
+ // Zoom anchored at the pinch midpoint.
918
+ const rect = rootEl.value?.getBoundingClientRect();
919
+ if (!rect) return;
920
+ const scale = props.pointerScale?.() ?? { x: 1, y: 1 };
921
+ const midX = ((pinchSnapshot.midX - rect.left) / scale.x);
922
+ const midY = ((pinchSnapshot.midY - rect.top) / scale.y);
923
+ const cam = camera.value;
924
+ const k = targetK;
925
+ const x = midX - ((midX - cam.x) * k) / cam.k;
926
+ const y = midY - ((midY - cam.y) * k) / cam.k;
927
+ setCamera({ k, x, y });
928
+ }
929
+
930
+ /** End pinch tracking when a pointer lifts. */
931
+ function endPointerTracking(event: PointerEvent): void {
932
+ activePointers.delete(event.pointerId);
933
+ if (activePointers.size < 2) {
934
+ pinchSnapshot = null;
935
+ }
936
+ }
937
+
938
+ // ── Camera tween ─────────────────────────────────────────────
939
+
940
+ /** Cancel any running tween. */
941
+ function cancelTween(): void {
942
+ if (tweenRaf !== null) {
943
+ cancelAnimationFrame(tweenRaf);
944
+ tweenRaf = null;
945
+ }
946
+ }
947
+
948
+ /** Animate the camera to a target over `tweenMs`, or snap if
949
+ * reduced-motion is active or tweenMs is 0. */
950
+ function tweenCamera(target: NodeCanvasCamera, durationMs?: number): void {
951
+ cancelTween();
952
+ const ms = durationMs ?? props.tweenMs;
953
+ if (ms <= 0 || reducedMotion.value) {
954
+ setCamera(target);
955
+ return;
956
+ }
957
+ const start = { ...camera.value };
958
+ const startTime = performance.now();
959
+
960
+ const step = (now: number) => {
961
+ const t = Math.min((now - startTime) / ms, 1);
962
+ const eased = 1 - Math.pow(1 - t, 3); // ease-out cubic
963
+ setCamera({
964
+ k: start.k + (target.k - start.k) * eased,
965
+ x: start.x + (target.x - start.x) * eased,
966
+ y: start.y + (target.y - start.y) * eased,
967
+ });
968
+ if (t < 1) {
969
+ tweenRaf = requestAnimationFrame(step);
970
+ } else {
971
+ tweenRaf = null;
972
+ }
973
+ };
974
+ tweenRaf = requestAnimationFrame(step);
975
+ }
976
+
585
977
  expose({
586
978
  camera,
587
979
  viewport,
@@ -591,6 +983,13 @@ export default defineComponent({
591
983
  screenToWorld,
592
984
  worldToScreen,
593
985
  setCamera,
986
+ tweenCamera,
987
+ // ── New: rendering base capabilities ──
988
+ levelOfDetail,
989
+ registerPainter,
990
+ unregisterPainter,
991
+ exportSVG,
992
+ exportPNG,
594
993
  });
595
994
 
596
995
  const slotProps = computed<NodeCanvasSlotProps>(() => ({
@@ -601,23 +1000,79 @@ export default defineComponent({
601
1000
 
602
1001
  return () => {
603
1002
  const { k, x, y } = camera.value;
1003
+ const isPrint = props.printMode;
1004
+ const paper = isPrint ? PAPER_SIZES[props.printPaper] : null;
604
1005
  return (
605
1006
  <div
606
1007
  ref={rootEl}
607
- class="hk-node-canvas"
608
- data-pannable={props.pannable ? "" : undefined}
1008
+ class={{
1009
+ "hk-node-canvas": true,
1010
+ "hk-node-canvas--print": isPrint,
1011
+ }}
1012
+ data-pannable={!isPrint && props.pannable ? "" : undefined}
609
1013
  aria-label={props.ariaLabel}
610
1014
  style={{
611
1015
  // Only a surface that consumes the gesture may take touch scrolling
612
- // away from the page.
613
- touchAction: props.pannable || props.zoomable ? "none" : undefined,
1016
+ // away from the page. Print mode never takes scrolling.
1017
+ touchAction: !isPrint && (props.pannable || props.zoomable) ? "none" : undefined,
1018
+ ...(paper && { width: `${paper.width}px`, height: `${paper.height}px` }),
614
1019
  }}
615
- onWheel={onWheel}
616
- onPointerdown={onPointerDown}
617
- onPointermove={onPointerMove}
618
- onPointerup={endPan}
619
- onPointercancel={endPan}
1020
+ onWheel={isPrint ? undefined : onWheel}
1021
+ onPointerdown={isPrint ? undefined : onPointerDown}
1022
+ onPointermove={isPrint ? undefined : onPointerMove}
1023
+ onPointerup={isPrint ? undefined : endPan}
1024
+ onPointercancel={isPrint ? undefined : endPan}
620
1025
  >
1026
+ {/* Canvas2D layer: painters draw strokes/grids/pipes in world
1027
+ coordinates, driven by the same camera as the DOM layer. */}
1028
+ <canvas
1029
+ ref={canvasEl}
1030
+ class="hk-node-canvas-canvas"
1031
+ style={{
1032
+ position: "absolute",
1033
+ inset: 0,
1034
+ pointerEvents: "none",
1035
+ }}
1036
+ />
1037
+ {/* SVG edge layer: routed edges between nodes, rendered as SVG
1038
+ for CSS styling and accessibility. Lives inside the camera
1039
+ transform so edges stay in sync with DOM nodes. */}
1040
+ {edgePaths.value.length > 0 && (
1041
+ <svg
1042
+ class="hk-node-canvas-edges"
1043
+ style={{
1044
+ position: "absolute",
1045
+ inset: 0,
1046
+ pointerEvents: "none",
1047
+ overflow: "visible",
1048
+ }}
1049
+ >
1050
+ <g transform={`translate(${x} ${y}) scale(${k})`}>
1051
+ {edgePaths.value.map(({ edge, path, mid }) => (
1052
+ <g key={edge.id} class="hk-node-canvas-edge" data-edge-type={edge.type}>
1053
+ <path
1054
+ d={path}
1055
+ fill="none"
1056
+ stroke={edge.color ?? "var(--hk-border-3, #555)"}
1057
+ stroke-width={edge.width ?? 1.5}
1058
+ stroke-dasharray={edge.dashed ? "6 4" : undefined}
1059
+ />
1060
+ {edge.label && (
1061
+ <text
1062
+ x={mid.x}
1063
+ y={mid.y - 6}
1064
+ text-anchor="middle"
1065
+ font-size="11"
1066
+ fill="var(--hk-text-2, #aaa)"
1067
+ >
1068
+ {edge.label}
1069
+ </text>
1070
+ )}
1071
+ </g>
1072
+ ))}
1073
+ </g>
1074
+ </svg>
1075
+ )}
621
1076
  {/* The transformed layer: the host draws in CONTENT coordinates and
622
1077
  this transform puts it on screen. */}
623
1078
  <div
@@ -627,7 +1082,7 @@ export default defineComponent({
627
1082
  {slots.default?.(slotProps.value)}
628
1083
  </div>
629
1084
  <div class="hk-node-canvas-overlay">{slots.overlay?.(slotProps.value)}</div>
630
- {props.minimap && (
1085
+ {!isPrint && props.minimap && (
631
1086
  <div
632
1087
  class="hk-node-canvas-minimap"
633
1088
  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,
@@ -54,16 +55,15 @@ interface Ripple {
54
55
  * by `revealStrategy`, how it is TRIGGERED by `revealTrigger`:
55
56
  * - strategy "filter" (default): dual counter-drifting spatter
56
57
  * layers in strict BLACK/WHITE/GRAY — the glyph row is a set of
57
- * 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
65
- * dissolved into the halo ramp. Reduced motion or a pattern-less
66
- * engine degrades to the fully readable static plain text.
58
+ * STATIC bold apertures filled with a bright gray spatter texture
59
+ * drifting one way, over a statistically matched DIM spatter
60
+ * field drifting the other way, both on a UNIFORM near-black
61
+ * ground (white-on-black reading in every theme; legibility lives
62
+ * in the brightness channel — dense dots, dim surround, bright
63
+ * text layer). A single frame — a screenshot — carries no glyph
64
+ * structure, only the mean-luminance signal dissolved into the
65
+ * halo ramp. Reduced motion or a pattern-less engine degrades to
66
+ * the fully readable static plain text.
67
67
  * - strategy "sweep": a readable window — the password is drawn as
68
68
  * ordinary high-contrast text inside a narrow band that sweeps
69
69
  * across the row over the boiling-noise field. Reading is
@@ -121,12 +121,12 @@ export default defineComponent({
121
121
  /**
122
122
  * What the eye reveal SHOWS:
123
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.
124
+ * strict black/white/gray — static BOLD glyph apertures filled
125
+ * with a BRIGHT dense gray spatter over an oppositely drifting,
126
+ * statistically matched DIM spatter field on a UNIFORM
127
+ * near-black ground (white-on-black in every theme), plus a
128
+ * large brightening pedestal and white halo. A single
129
+ * 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
  }