@celestia-island/hikari 0.55.93 → 0.55.94
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
CHANGED
|
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import { createApp, defineComponent, h, isRef, nextTick, onUpdated, ref, type App } from "vue";
|
|
3
3
|
|
|
4
4
|
import HkNodeCanvas, { NODE_CANVAS_DEFAULTS } from "./HkNodeCanvas";
|
|
5
|
+
import { CANVAS_BOUNDS_MARGIN, clampCameraToBounds } from "./nodeCanvasTypes";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* HkNodeCanvas tests — the camera protocol the three node surfaces share:
|
|
@@ -1050,4 +1051,142 @@ describe("HkNodeCanvas", () => {
|
|
|
1050
1051
|
// Controlled: the internal camera did not move on its own.
|
|
1051
1052
|
expect(instance.value!.camera).toEqual({ k: 1, x: 0, y: 0 });
|
|
1052
1053
|
});
|
|
1054
|
+
|
|
1055
|
+
// ── Bounds clamp ─────────────────────────────────────────────────
|
|
1056
|
+
// The camera may never push the content hull farther than half a
|
|
1057
|
+
// viewport past the visible edge, per axis: at the limit the hull sits
|
|
1058
|
+
// exactly CANVAS_BOUNDS_MARGIN of a viewport outside, and past it the
|
|
1059
|
+
// outward drag is refused. Every camera write funnels through
|
|
1060
|
+
// `setCamera`, so a drag, a wheel/pinch zoom, a tween and an imperative
|
|
1061
|
+
// call all stop at the same edge.
|
|
1062
|
+
|
|
1063
|
+
describe("bounds clamp", () => {
|
|
1064
|
+
// BOUNDS = 400×200 at (0,0), viewport 800×600, k = 1 ⇒
|
|
1065
|
+
// x ∈ [-k·(x+w) - f·W, W - k·x + f·W] = [-800, 1200],
|
|
1066
|
+
// y ∈ [-200 - 300, 600 + 300] = [-500, 900].
|
|
1067
|
+
const X_LIMIT_LOW = -800;
|
|
1068
|
+
const Y_LIMIT_LOW = -500;
|
|
1069
|
+
|
|
1070
|
+
it("clamps the pure camera to the expanded hull", () => {
|
|
1071
|
+
const bounds = { x: 0, y: 0, width: 400, height: 200 };
|
|
1072
|
+
const viewport = { width: 800, height: 600 };
|
|
1073
|
+
// A camera already framing the content passes through untouched.
|
|
1074
|
+
const inside = { k: 1, x: 100, y: 100 };
|
|
1075
|
+
expect(clampCameraToBounds(inside, bounds, viewport)).toEqual(inside);
|
|
1076
|
+
// Dragged far off-screen: stopped at the derived limit.
|
|
1077
|
+
expect(clampCameraToBounds({ k: 1, x: -5000, y: -9000 }, bounds, viewport)).toEqual({
|
|
1078
|
+
k: 1,
|
|
1079
|
+
x: X_LIMIT_LOW,
|
|
1080
|
+
y: Y_LIMIT_LOW,
|
|
1081
|
+
});
|
|
1082
|
+
// …and symmetrically for the far corners.
|
|
1083
|
+
expect(clampCameraToBounds({ k: 1, x: 5000, y: 9000 }, bounds, viewport)).toEqual({
|
|
1084
|
+
k: 1,
|
|
1085
|
+
x: 1200,
|
|
1086
|
+
y: 900,
|
|
1087
|
+
});
|
|
1088
|
+
});
|
|
1089
|
+
|
|
1090
|
+
it("holds the margin as a SCREEN distance at any zoom", () => {
|
|
1091
|
+
// At k = 2 the world window halves, so the world-space margin halves
|
|
1092
|
+
// with it — but the on-screen slack stays half a viewport.
|
|
1093
|
+
const bounds = { x: 0, y: 0, width: 400, height: 200 };
|
|
1094
|
+
const viewport = { width: 800, height: 600 };
|
|
1095
|
+
const clamped = clampCameraToBounds({ k: 2, x: -99999, y: 0 }, bounds, viewport);
|
|
1096
|
+
// xLow = -2·400 - 0.5·800 = -1200.
|
|
1097
|
+
expect(clamped.x).toBe(-1200);
|
|
1098
|
+
// At the limit the hull's right edge sits exactly half a viewport
|
|
1099
|
+
// left of the screen's left edge.
|
|
1100
|
+
const hullRightOnScreen = (bounds.width) * clamped.k + clamped.x;
|
|
1101
|
+
expect(hullRightOnScreen).toBe(-viewport.width / 2);
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
it("passes degenerate inputs through unchanged", () => {
|
|
1105
|
+
const cam = { k: 1, x: 123, y: 456 };
|
|
1106
|
+
expect(clampCameraToBounds(cam, null, { width: 800, height: 600 })).toBe(cam);
|
|
1107
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 0, height: 100 }, { width: 800, height: 600 })).toBe(cam);
|
|
1108
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 100, height: 0 }, { width: 800, height: 600 })).toBe(cam);
|
|
1109
|
+
expect(clampCameraToBounds(cam, { x: 0, y: 0, width: 100, height: 100 }, { width: 0, height: 0 })).toBe(cam);
|
|
1110
|
+
expect(clampCameraToBounds({ k: 0, x: 1, y: 1 }, { x: 0, y: 0, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({ k: 0, x: 1, y: 1 });
|
|
1111
|
+
// An astronomically placed hull overflows the clamp range (k·coord
|
|
1112
|
+
// passes the double ceiling): refuse to clamp rather than write a
|
|
1113
|
+
// non-finite translation (the browser would drop the transform).
|
|
1114
|
+
const far = { x: 1e308, y: 1e308, width: 100, height: 100 };
|
|
1115
|
+
expect(clampCameraToBounds({ k: 2, x: 123, y: 456 }, far, { width: 800, height: 600 })).toEqual({ k: 2, x: 123, y: 456 });
|
|
1116
|
+
expect(clampCameraToBounds({ k: 1, x: NaN, y: 0 }, { x: 0, y: 0, width: 100, height: 100 }, { width: 800, height: 600 })).toEqual({ k: 1, x: NaN, y: 0 });
|
|
1117
|
+
});
|
|
1118
|
+
|
|
1119
|
+
it("stops a pan that would drag the hull off-screen", async () => {
|
|
1120
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1121
|
+
await nextTick();
|
|
1122
|
+
instance.value?.panBy(-5000, -5000);
|
|
1123
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1124
|
+
// The far direction clamps at its own wall.
|
|
1125
|
+
instance.value?.panBy(99999, 99999);
|
|
1126
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: 1200, y: 900 });
|
|
1127
|
+
// Small moves apply on the free axis; the wall axis stays pinned.
|
|
1128
|
+
instance.value?.panBy(-10, 5);
|
|
1129
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: 1190, y: 900 });
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
it("stops a pointer drag at the same edge", async () => {
|
|
1133
|
+
const { instance, root } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1134
|
+
await nextTick();
|
|
1135
|
+
root.setPointerCapture = () => {};
|
|
1136
|
+
root.dispatchEvent(pointerEvent("pointerdown", { pointerId: 21, clientX: 400, clientY: 300 }));
|
|
1137
|
+
// 400 px of drag left/up, in two moves, is nowhere near the limit —
|
|
1138
|
+
// then one enormous move rides into the wall.
|
|
1139
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 300, clientY: 250 }));
|
|
1140
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: 200, clientY: 200 }));
|
|
1141
|
+
root.dispatchEvent(pointerEvent("pointermove", { pointerId: 21, clientX: -100000, clientY: -100000 }));
|
|
1142
|
+
document.body.dispatchEvent(pointerEvent("pointerup", { pointerId: 21, clientX: -100000, clientY: -100000, buttons: 0 }));
|
|
1143
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
it("keeps a wheel zoom anchored without escaping the walls", async () => {
|
|
1147
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1148
|
+
await nextTick();
|
|
1149
|
+
// Ride to the low-x wall first, then zoom out hard: the anchor keeps
|
|
1150
|
+
// the world point under the cursor, but the translation must stay
|
|
1151
|
+
// inside the clamp interval for the new k.
|
|
1152
|
+
instance.value?.panBy(-5000, 0);
|
|
1153
|
+
for (let step = 0; step < 12; step++) instance.value?.zoomBy(-1);
|
|
1154
|
+
const cam = instance.value!.camera;
|
|
1155
|
+
const xLow = -cam.k * (BOUNDS.x + BOUNDS.width) - CANVAS_BOUNDS_MARGIN * VIEWPORT.width;
|
|
1156
|
+
const xHigh = VIEWPORT.width - cam.k * BOUNDS.x + CANVAS_BOUNDS_MARGIN * VIEWPORT.width;
|
|
1157
|
+
expect(cam.x).toBeGreaterThanOrEqual(xLow - 1e-6);
|
|
1158
|
+
expect(cam.x).toBeLessThanOrEqual(xHigh + 1e-6);
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it("lets a controlled host's gestures clamp before the emit", async () => {
|
|
1162
|
+
const onUpdate = vi.fn();
|
|
1163
|
+
const { instance } = mount({
|
|
1164
|
+
contentBounds: BOUNDS,
|
|
1165
|
+
camera: { k: 1, x: 0, y: 0 },
|
|
1166
|
+
"onUpdate:camera": onUpdate,
|
|
1167
|
+
fitOnLoad: false,
|
|
1168
|
+
});
|
|
1169
|
+
await nextTick();
|
|
1170
|
+
instance.value?.panBy(-5000, -5000);
|
|
1171
|
+
const last = onUpdate.mock.calls.at(-1)?.[0] as { k: number; x: number; y: number };
|
|
1172
|
+
expect(last).toEqual({ k: 1, x: X_LIMIT_LOW, y: Y_LIMIT_LOW });
|
|
1173
|
+
});
|
|
1174
|
+
|
|
1175
|
+
it("does nothing the host did not ask for when the clamp is off", async () => {
|
|
1176
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false, clampToBounds: false });
|
|
1177
|
+
await nextTick();
|
|
1178
|
+
instance.value?.panBy(-5000, -5000);
|
|
1179
|
+
expect(instance.value!.camera).toEqual({ k: 1, x: -5000, y: -5000 });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
it("never fights a fit: framing still centres the hull", async () => {
|
|
1183
|
+
const { instance } = mount({ contentBounds: BOUNDS, fitOnLoad: false });
|
|
1184
|
+
await nextTick();
|
|
1185
|
+
instance.value?.panBy(-5000, -5000);
|
|
1186
|
+
expect(instance.value!.fit()).toBe(true);
|
|
1187
|
+
// 400×200 in 800×600 with 40 px padding → k = min(1.8, 2.6) capped at
|
|
1188
|
+
// fitCap 1.25, centred: x = (800-500)/2, y = (600-250)/2.
|
|
1189
|
+
expect(instance.value!.camera).toEqual({ k: 1.25, x: 150, y: 175 });
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1053
1192
|
});
|
|
@@ -13,6 +13,7 @@ import "./HkNodeCanvas.scss";
|
|
|
13
13
|
|
|
14
14
|
import {
|
|
15
15
|
computeLod,
|
|
16
|
+
clampCameraToBounds,
|
|
16
17
|
edgeHitWidth,
|
|
17
18
|
edgeMidpoint,
|
|
18
19
|
edgePath,
|
|
@@ -39,7 +40,7 @@ export type {
|
|
|
39
40
|
NodeCanvasPainter,
|
|
40
41
|
PrintPaper,
|
|
41
42
|
} from "./nodeCanvasTypes";
|
|
42
|
-
export { computeLod, edgePath, edgeMidpoint, edgeHitWidth, sideOffset, oppositeSide, LOD_DEFAULTS, PAPER_SIZES } from "./nodeCanvasTypes";
|
|
43
|
+
export { computeLod, edgePath, edgeMidpoint, edgeHitWidth, sideOffset, oppositeSide, LOD_DEFAULTS, PAPER_SIZES, clampCameraToBounds, CANVAS_BOUNDS_MARGIN } from "./nodeCanvasTypes";
|
|
43
44
|
|
|
44
45
|
/** The pointer payload the edge events carry: a real MouseEvent for
|
|
45
46
|
* hover/click/contextmenu, or a synthesized stand-in when a touch
|
|
@@ -143,6 +144,13 @@ interface PendingRequest {
|
|
|
143
144
|
* read to simplify their nodes at low zoom.
|
|
144
145
|
* - a **print mode**: disables gestures, fixes the camera, and exposes
|
|
145
146
|
* `exportSVG()` for vector output.
|
|
147
|
+
* - a **bounds clamp** (default on): every camera write stops where the
|
|
148
|
+
* content hull would leave half a viewport of itself past the visible
|
|
149
|
+
* edge — a drag, a wheel or pinch zoom, a tween, an imperative call.
|
|
150
|
+
* A graph can still be stranded only through the host's own channels:
|
|
151
|
+
* opting out (`clampToBounds: false`), never supplying
|
|
152
|
+
* `contentBounds`, or a controlled `camera` prop the host writes
|
|
153
|
+
* verbatim (emitted gesture results are always pre-clamped).
|
|
146
154
|
*
|
|
147
155
|
* What it still does NOT own: what nodes look like, what data drives
|
|
148
156
|
* them, or how they are laid out. The host draws node content through
|
|
@@ -165,7 +173,10 @@ export default defineComponent({
|
|
|
165
173
|
/** Controlled camera (`v-model:camera`). Without it the component keeps
|
|
166
174
|
* its own, seeded by `fitOnLoad`. */
|
|
167
175
|
camera: { type: Object as PropType<NodeCanvasCamera | undefined>, default: undefined },
|
|
168
|
-
/** Content rectangle, in world units
|
|
176
|
+
/** Content rectangle, in world units — the hull every node on the
|
|
177
|
+
* canvas forms. `fit` frames it, and the bounds clamp (`clampToBounds`,
|
|
178
|
+
* on by default) refuses camera moves that would push it out of
|
|
179
|
+
* reach. */
|
|
169
180
|
contentBounds: { type: Object as PropType<NodeCanvasBounds | null>, default: null },
|
|
170
181
|
/** Smallest zoom the camera will settle on. */
|
|
171
182
|
minZoom: { type: Number, default: NODE_CANVAS_DEFAULTS.minZoom },
|
|
@@ -197,6 +208,15 @@ export default defineComponent({
|
|
|
197
208
|
pannable: { type: Boolean, default: true },
|
|
198
209
|
/** Whether the wheel zooms. */
|
|
199
210
|
zoomable: { type: Boolean, default: true },
|
|
211
|
+
/** Keep the content within reach: the camera may never push the
|
|
212
|
+
* content hull (the `contentBounds` envelope every node on the canvas
|
|
213
|
+
* forms) farther than half a viewport past the visible edge, per
|
|
214
|
+
* axis — the outward drag stops there instead of stranding the graph
|
|
215
|
+
* off-screen. On by default; a canvas with a genuinely unbounded
|
|
216
|
+
* surface (or one whose content the host moves independently) opts
|
|
217
|
+
* out with `false`. Needs `contentBounds` to know the hull — without
|
|
218
|
+
* it there is nothing to clamp against and this is a no-op. */
|
|
219
|
+
clampToBounds: { type: Boolean, default: true },
|
|
200
220
|
/** Mount the minimap in the corner. */
|
|
201
221
|
minimap: { type: Boolean, default: true },
|
|
202
222
|
/** Which corner the minimap takes. */
|
|
@@ -558,12 +578,21 @@ export default defineComponent({
|
|
|
558
578
|
// the surface freezes with no way back. Refuse to propagate it.
|
|
559
579
|
if (!Number.isFinite(k) || k <= 0) return;
|
|
560
580
|
if (!Number.isFinite(next.x) || !Number.isFinite(next.y)) return;
|
|
581
|
+
// Bounds clamp (default on): every write funnels through here, so a
|
|
582
|
+
// drag, a wheel/pinch zoom, a tween and an imperative `setCamera` all
|
|
583
|
+
// stop at the same edge — the content hull never leaves half a
|
|
584
|
+
// viewport of reach. `fit()` frames the hull inside the viewport and
|
|
585
|
+
// therefore always satisfies the clamp; it writes through
|
|
586
|
+
// `writeCamera` directly, so a host framing is never fought.
|
|
587
|
+
const bounded = props.clampToBounds
|
|
588
|
+
? clampCameraToBounds({ k, x: next.x, y: next.y }, props.contentBounds, viewport.value)
|
|
589
|
+
: { k, x: next.x, y: next.y };
|
|
561
590
|
// Any non-automatic camera move is the user's: a late `contentBounds`
|
|
562
591
|
// must not throw that work away. It also means the host is somewhere
|
|
563
592
|
// else, so a later `fit()` may ask for the same camera again.
|
|
564
593
|
movedByHand = true;
|
|
565
594
|
lastRequested = null;
|
|
566
|
-
writeCamera(
|
|
595
|
+
writeCamera(bounded);
|
|
567
596
|
}
|
|
568
597
|
|
|
569
598
|
/** The camera that frames `contentBounds` inside the viewport. */
|
|
@@ -278,6 +278,74 @@ export function edgeMidpoint(
|
|
|
278
278
|
return { x: (from.x + to.x) / 2, y: (from.y + to.y) / 2 };
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ── Camera bounds clamp ──────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
/** How far past the content hull the camera may push it, as a fraction of
|
|
284
|
+
* the viewport size per axis (0.5 = the hull may leave at most half a
|
|
285
|
+
* viewport of itself past the visible edge — no farther). */
|
|
286
|
+
export const CANVAS_BOUNDS_MARGIN = 0.5;
|
|
287
|
+
|
|
288
|
+
/** Clamp a camera so the content cannot be dragged out of reach.
|
|
289
|
+
*
|
|
290
|
+
* The binding geometry is the content hull — the axis-aligned envelope of
|
|
291
|
+
* every component on the canvas (the hull's x/y extremes are exactly what
|
|
292
|
+
* a pure translation can bind against). The camera is clamped so the
|
|
293
|
+
* viewport's world window keeps intersecting that hull expanded by
|
|
294
|
+
* `marginFactor` viewport sizes per axis: at the limit the hull itself
|
|
295
|
+
* sits exactly `marginFactor` of a viewport outside the visible edge, and
|
|
296
|
+
* past that the outward drag is refused.
|
|
297
|
+
*
|
|
298
|
+
* Degenerate inputs pass the camera through unchanged: no bounds (the
|
|
299
|
+
* host gave none), a non-finite or empty hull, an unmeasured viewport, a
|
|
300
|
+
* non-finite zoom, or a coordinate combination whose clamp range is not
|
|
301
|
+
* finite (an astronomically placed hull) — a clamped camera is only ever
|
|
302
|
+
* returned finite, because the browser drops the whole transform
|
|
303
|
+
* otherwise. */
|
|
304
|
+
export function clampCameraToBounds(
|
|
305
|
+
camera: NodeCanvasCamera,
|
|
306
|
+
bounds: NodeCanvasBounds | null,
|
|
307
|
+
viewport: { width: number; height: number },
|
|
308
|
+
marginFactor: number = CANVAS_BOUNDS_MARGIN,
|
|
309
|
+
): NodeCanvasCamera {
|
|
310
|
+
if (
|
|
311
|
+
!bounds ||
|
|
312
|
+
!(bounds.width > 0) ||
|
|
313
|
+
!(bounds.height > 0) ||
|
|
314
|
+
!Number.isFinite(bounds.x) ||
|
|
315
|
+
!Number.isFinite(bounds.y) ||
|
|
316
|
+
!(viewport.width > 0) ||
|
|
317
|
+
!(viewport.height > 0)
|
|
318
|
+
) {
|
|
319
|
+
return camera;
|
|
320
|
+
}
|
|
321
|
+
const { k } = camera;
|
|
322
|
+
if (!Number.isFinite(k) || k <= 0) return camera;
|
|
323
|
+
if (!Number.isFinite(camera.x) || !Number.isFinite(camera.y)) return camera;
|
|
324
|
+
// The viewport's world window is [-x/k, (W-x)/k]; "keep intersecting the
|
|
325
|
+
// hull expanded by (f·W/k, f·H/k) world units" solves to these
|
|
326
|
+
// translation ranges. The viewport fraction cancels the zoom — the
|
|
327
|
+
// margin is a SCREEN distance (half a viewport) at any k.
|
|
328
|
+
const xLow = -k * (bounds.x + bounds.width) - marginFactor * viewport.width;
|
|
329
|
+
const xHigh = viewport.width - k * bounds.x + marginFactor * viewport.width;
|
|
330
|
+
const yLow = -k * (bounds.y + bounds.height) - marginFactor * viewport.height;
|
|
331
|
+
const yHigh = viewport.height - k * bounds.y + marginFactor * viewport.height;
|
|
332
|
+
// bounds.width/height > 0 keeps both intervals non-empty (dust aside);
|
|
333
|
+
// the finiteness guard refuses a hull so far out the range overflows —
|
|
334
|
+
// a non-finite translation would drop the whole transform.
|
|
335
|
+
if (!(xLow <= xHigh) || !(yLow <= yHigh)) return camera;
|
|
336
|
+
if (
|
|
337
|
+
!Number.isFinite(xLow) || !Number.isFinite(xHigh)
|
|
338
|
+
|| !Number.isFinite(yLow) || !Number.isFinite(yHigh)
|
|
339
|
+
) {
|
|
340
|
+
return camera;
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
k,
|
|
344
|
+
x: Math.min(xHigh, Math.max(xLow, camera.x)),
|
|
345
|
+
y: Math.min(yHigh, Math.max(yLow, camera.y)),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
281
349
|
// ── Print ────────────────────────────────────────────────────────────────
|
|
282
350
|
|
|
283
351
|
export type PrintPaper = "a4-landscape" | "a3-landscape" | "auto";
|