@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.
- package/package.json +1 -1
- package/src/components/HkInput.password.test.tsx +51 -81
- package/src/components/HkInput.tsx +8 -9
- package/src/components/HkNodeCanvas.base.test.tsx +246 -0
- package/src/components/HkNodeCanvas.tsx +313 -13
- package/src/components/HkPasswordSurface.tsx +19 -40
- package/src/components/nodeCanvasTypes.ts +170 -0
- package/src/components/revealKinematogram.test.ts +42 -4
- package/src/components/revealKinematogram.ts +72 -60
|
@@ -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
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
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=
|
|
608
|
-
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
124
|
-
* strict black/white/gray — static BOLD glyph apertures over
|
|
125
|
-
* oppositely
|
|
126
|
-
* on
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* carries no glyph structure
|
|
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
|
|
806
|
-
//
|
|
807
|
-
//
|
|
808
|
-
|
|
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
|
+
};
|