@celestia-island/hikari 0.55.93 → 0.55.95

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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.93",
3
+ "version": "0.55.95",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari Vue 3 component library — production-grade UI components based on shittim-chest design system",
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ /**
7
+ * Source contract: the JSON tree's spacing rhythm is token-based, not
8
+ * ad-hoc. The 2026-09-30 normalization replaced the off-scale gaps
9
+ * (2px toggle margin, 0.35em colon, 0.75em type hint) with design
10
+ * units and added the toggle's first-line centering formula — this
11
+ * contract keeps the file from drifting back to magic values, which is
12
+ * exactly the "crooked collapsed/expanded alignment" regression class
13
+ * the normalization fixed.
14
+ */
15
+ const here = dirname(fileURLToPath(import.meta.url));
16
+ const scss = readFileSync(join(here, "HkJsonTree.scss"), "utf-8");
17
+
18
+ describe("HkJsonTree spacing contract", () => {
19
+ it("keeps the toggle exactly one spacing unit from the row text", () => {
20
+ const block = scss.slice(scss.indexOf(".s-jt-toggle {"), scss.indexOf(".s-jt-toggle[data-parent]"));
21
+ expect(block).toContain("margin-inline-end: var(--space-4");
22
+ });
23
+
24
+ it("centers the toggle box on the first text line by formula", () => {
25
+ const block = scss.slice(scss.indexOf(".s-jt-toggle {"), scss.indexOf(".s-jt-toggle[data-parent]"));
26
+ expect(block).toContain("align-self: flex-start");
27
+ expect(block).toContain("margin-block-start: max(0px, calc((1.55em - var(--jt-toggle, 14px)) / 2))");
28
+ });
29
+
30
+ it("keeps every --jt-toggle fallback at the 14px the formula assumes", () => {
31
+ // Regex sweep, not a blacklist: ANY fallback other than 14px (today
32
+ // or added later) would de-center the box by the delta.
33
+ const fallbacks = [...scss.matchAll(/var\(--jt-toggle,\s*([^)]+)\)/g)].map((m) => m[1].trim());
34
+ expect(fallbacks.length, "fallback sites were scanned").toBeGreaterThan(2);
35
+ expect(fallbacks.every((f) => f === "14px"), `fallbacks = ${fallbacks}`).toBe(true);
36
+ });
37
+
38
+ it("pins the depth step to exactly one toggle width", () => {
39
+ const tsx = readFileSync(join(here, "HkJsonTree.tsx"), "utf-8");
40
+ expect(tsx).toContain("const JT_INDENT = JT_TOGGLE;");
41
+ });
42
+
43
+ it("keeps the key colon gap on one spacing unit", () => {
44
+ const block = scss.slice(scss.indexOf(".s-jt-colon {"), scss.indexOf(".s-jv-null"));
45
+ expect(block).toContain("margin-inline-end: var(--space-4");
46
+ expect(block).not.toContain("0.35em");
47
+ });
48
+
49
+ it("keeps the type hint gap on two spacing units", () => {
50
+ const block = scss.slice(scss.indexOf(".s-jt-type {"), scss.indexOf(".s-jt-badge {"));
51
+ expect(block).toContain("margin-inline-start: var(--space-8");
52
+ expect(block).not.toContain("0.75em");
53
+ });
54
+
55
+ it("leaves no off-scale magic gaps anywhere in the tree stylesheet", () => {
56
+ expect(scss).not.toMatch(/margin-inline-end: 0\.\d+em/);
57
+ expect(scss).not.toMatch(/margin-inline-start: 0\.\d+em/);
58
+ });
59
+ });
@@ -69,14 +69,20 @@
69
69
  display: inline-flex;
70
70
  align-items: center;
71
71
  justify-content: center;
72
- width: var(--jt-toggle, 1rem);
73
- height: var(--jt-toggle, 1rem);
72
+ width: var(--jt-toggle, 14px);
73
+ height: var(--jt-toggle, 14px);
74
74
  flex-shrink: 0;
75
- margin-inline-end: var(--space-2, 0.125rem);
75
+ /* Toggle → text is ONE spacing unit everywhere (leaf, collapsed and
76
+ expanded parents alike) — the collapsed row and its children read
77
+ on the same rhythm instead of ad-hoc gaps. */
78
+ margin-inline-end: var(--space-4, 0.25rem);
76
79
  /* The toggle box has no text baseline of its own — pin it to the line
77
- top instead of letting it join the row's baseline group with a
78
- synthesized border-box baseline. */
80
+ top and center it on the FIRST text line by formula (line-height
81
+ 1.55 × font-size vs the fixed box), so the chevron/leaf sits on the
82
+ text's optical center in collapsed and expanded rows alike instead
83
+ of floating above the baseline group. */
79
84
  align-self: flex-start;
85
+ margin-block-start: max(0px, calc((1.55em - var(--jt-toggle, 14px)) / 2));
80
86
  }
81
87
 
82
88
  .s-jt-toggle[data-parent] {
@@ -117,7 +123,7 @@
117
123
 
118
124
  .s-jt-colon {
119
125
  color: rgb(var(--color-muted));
120
- margin-inline-end: 0.35em;
126
+ margin-inline-end: var(--space-4, 0.25rem);
121
127
  }
122
128
 
123
129
  .s-jv-null {
@@ -152,7 +158,7 @@
152
158
  font-size: var(--text-3xs, 0.5rem);
153
159
  font-style: italic;
154
160
  font-weight: 400;
155
- margin-inline-start: 0.75em;
161
+ margin-inline-start: var(--space-8, 0.5rem);
156
162
  }
157
163
 
158
164
  .s-jt-badge {
@@ -130,14 +130,24 @@ function initialExpandedSet(root: HJsonNode): Set<number> {
130
130
  /**
131
131
  * HkJsonTree — the interactive expandable JSON tree.
132
132
  *
133
- * This is the exact renderer the chat card flow uses for tool results
134
- * (extracted verbatim from HkToolBlock, same DOM and `s-jt-*` stylesheet, so
135
- * the two can never drift). Feed it either pre-parsed `value` or a raw JSON
136
- * `text`; non-container input renders nothing.
133
+ * A first-class family component (same tier as the rich-text surfaces):
134
+ * the chat tool blocks, the chest error landing's raw-details pane and
135
+ * any host pane render through THIS component — HkToolBlock consumes it
136
+ * directly, and the `s-jt-*` stylesheet single-sources the visual
137
+ * language, so the two can never drift. Feed it either pre-parsed
138
+ * `value` or a raw JSON `text`; non-container input renders nothing.
137
139
  *
138
140
  * The root keeps the legacy `s-tool-json-tree` class: hosts (including
139
141
  * HkToolBlock's deferred scrollbar pass) target it, and the pane chrome
140
142
  * (mono font, capped height, hidden native scrollbars) lives there.
143
+ *
144
+ * Spacing contract (pinned by HkJsonTree.contract.test.ts): the toggle
145
+ * box is 14px and sits ONE `--space-4` from the row text — identical for
146
+ * leaf dots, collapsed previews and expanded parents — and is centered
147
+ * on the first text line by formula, so collapsed and expanded states
148
+ * align with the same left rhythm; `key:` gaps are one unit, type hints
149
+ * two; the depth step is one toggle width (JT_INDENT = JT_TOGGLE,
150
+ * pinned alongside the stylesheet contract).
141
151
  */
142
152
  export const HkJsonTree = defineComponent({
143
153
  name: "HkJsonTree",
@@ -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, used for fit and clamping. */
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({ k, x: next.x, y: next.y });
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";