@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.
- package/package.json +1 -1
- package/src/components/HkInput.password.test.tsx +114 -88
- package/src/components/HkInput.tsx +5 -7
- package/src/components/HkNodeCanvas.base.test.tsx +246 -0
- package/src/components/HkNodeCanvas.gestures.test.tsx +158 -0
- package/src/components/HkNodeCanvas.tsx +468 -13
- package/src/components/HkPasswordSurface.tsx +20 -41
- package/src/components/nodeCanvasTypes.ts +170 -0
- package/src/components/revealKinematogram.test.ts +55 -11
- package/src/components/revealKinematogram.ts +100 -75
|
@@ -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,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=
|
|
608
|
-
|
|
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
|
|
59
|
-
* drifting the other way on
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
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
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
* pedestal and halo.
|
|
129
|
-
* carries no glyph structure
|
|
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
|
|
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
|
}
|