@celestia-island/hikari 0.34.0 → 0.35.0

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.34.0",
3
+ "version": "0.35.0",
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,83 @@
1
+ // HkBoard — the shared node-and-edge board surface.
2
+ // Geometry semantics live in utils/boardCamera.ts + utils/boardEdges.ts;
3
+ // this file only styles the layers (grid, world, edges, node shells,
4
+ // minimap dock).
5
+
6
+ .hk-board {
7
+ position: relative;
8
+ width: 100%;
9
+ height: 100%;
10
+ min-height: 0;
11
+ overflow: hidden;
12
+ background: rgb(var(--color-surface));
13
+ // gestures own the surface: no native scroll/selection interference
14
+ touch-action: none;
15
+ user-select: none;
16
+ }
17
+
18
+ .hk-board-grid {
19
+ position: absolute;
20
+ inset: 0;
21
+ pointer-events: none;
22
+ }
23
+
24
+ .hk-board-world {
25
+ position: absolute;
26
+ top: 0;
27
+ left: 0;
28
+ transform-origin: 0 0;
29
+ will-change: transform;
30
+ }
31
+
32
+ .hk-board-edges {
33
+ position: absolute;
34
+ top: 0;
35
+ left: 0;
36
+ overflow: visible;
37
+ pointer-events: none;
38
+ }
39
+
40
+ .hk-board-edge {
41
+ fill: none;
42
+ stroke-linecap: round;
43
+ }
44
+
45
+ .hk-board-node {
46
+ position: absolute;
47
+ box-sizing: border-box;
48
+
49
+ &--draggable {
50
+ cursor: grab;
51
+
52
+ &:active {
53
+ cursor: grabbing;
54
+ }
55
+ }
56
+ }
57
+
58
+ .hk-board-node-shell {
59
+ width: 100%;
60
+ height: 100%;
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 0 var(--space-6);
65
+ box-sizing: border-box;
66
+ overflow: hidden;
67
+ text-overflow: ellipsis;
68
+ white-space: nowrap;
69
+ font-size: var(--text-2xs);
70
+ color: rgb(var(--color-text));
71
+ background: rgb(var(--color-surface));
72
+ border: 1px solid rgb(var(--color-primary) / 45%);
73
+ border-radius: var(--radius-sm);
74
+ box-shadow: var(--shadow-elevated);
75
+ }
76
+
77
+ .hk-board-minimap {
78
+ position: absolute;
79
+ right: var(--space-10);
80
+ bottom: var(--space-10);
81
+ z-index: 6;
82
+ pointer-events: auto;
83
+ }
@@ -0,0 +1,396 @@
1
+ /**
2
+ * HkBoard — the shared node-and-edge board surface.
3
+ *
4
+ * ONE world-camera canvas that every graph-like page builds on (history
5
+ * topic maps, SCADA scenes, PLC panels, and whatever comes next). The
6
+ * board owns the motion feel and the chrome; the page owns the layout:
7
+ *
8
+ * - CAMERA: 5% geometric zoom ladder with an ANIMATED settle (no snapping),
9
+ * wheel zoom anchored at the pointer (desktop), two-finger pinch
10
+ * (touch, continuous while pinching → animated rung on release),
11
+ * clamped background pan, fit/reset;
12
+ * - GRID: the engineering dot grid is always on and world-anchored —
13
+ * it scales and translates with the camera so it doubles as an
14
+ * alignment aid;
15
+ * - NODES: DOM shells positioned in world coords, rendered through the
16
+ * `node` scoped slot (a labeled shell is provided when the slot is
17
+ * omitted). `draggable` nodes move in world coords and emit
18
+ * `node-move`; `hidden` nodes paint NOTHING but still route edges —
19
+ * that is how a page bakes a right-angle corner into its graph;
20
+ * - EDGES: SVG paths with independently configurable anchor modes
21
+ * (center / nearest / top / top-left / fan-天女散花) and route styles
22
+ * (straight / bezier / orthogonal / spine), optionally routed through
23
+ * `via` junction nodes (see utils/boardEdges.ts);
24
+ * - MINIMAP: HkMinimap pinned bottom-right, wired to the same camera.
25
+ *
26
+ * The page supplies `nodes` (world rects, reactive) and `edges`; node
27
+ * LAYOUT stays the page's business — the board never forces an
28
+ * arrangement, it only standardizes how things connect and move.
29
+ */
30
+
31
+ import {
32
+ computed,
33
+ defineComponent,
34
+ onBeforeUnmount,
35
+ onMounted,
36
+ ref,
37
+ toRef,
38
+ watch,
39
+ type PropType,
40
+ type Ref,
41
+ } from "vue";
42
+
43
+ import { useBoardCamera } from "../composables/useBoardCamera";
44
+ import HkMinimap, { type MinimapBox } from "./HkMinimap";
45
+ import {
46
+ boardBBox,
47
+ type BoardCamera,
48
+ type BoardPoint,
49
+ type BoardRect,
50
+ type BoardViewport,
51
+ } from "../utils/boardCamera";
52
+ import {
53
+ boardAnchor,
54
+ boardEdgePath,
55
+ boardViaPath,
56
+ type BoardAnchorMode,
57
+ type BoardEdgeStyle,
58
+ } from "../utils/boardEdges";
59
+ import "./HkBoard.scss";
60
+
61
+ /** A node on the board. `hidden` nodes are junction-only (invisible). */
62
+ export interface BoardNodeInput {
63
+ id: string;
64
+ x: number;
65
+ y: number;
66
+ w: number;
67
+ h: number;
68
+ label?: string;
69
+ kind?: string;
70
+ /** Invisible junction node — routes edges, paints nothing. */
71
+ hidden?: boolean;
72
+ /** Opt in to pointer dragging (world-coord moves + `node-move` events). */
73
+ draggable?: boolean;
74
+ /** Page payload, handed back through the scoped slot. */
75
+ data?: Record<string, unknown>;
76
+ }
77
+
78
+ export interface BoardEdgeInput {
79
+ id: string;
80
+ from: string;
81
+ to: string;
82
+ /** Junction node ids the edge routes through (their centers). */
83
+ via?: string[];
84
+ style?: BoardEdgeStyle;
85
+ anchorFrom?: BoardAnchorMode;
86
+ anchorTo?: BoardAnchorMode;
87
+ ink?: string;
88
+ dashed?: boolean;
89
+ width?: number;
90
+ }
91
+
92
+ const DEFAULT_INK = "rgb(var(--color-primary) / 38%)";
93
+
94
+ export default defineComponent({
95
+ name: "HkBoard",
96
+ props: {
97
+ nodes: { type: Array as PropType<BoardNodeInput[]>, default: () => [] },
98
+ edges: { type: Array as PropType<BoardEdgeInput[]>, default: () => [] },
99
+ /** Wire wheel / pinch / pan gestures (node clicks always work). */
100
+ interactive: { type: Boolean, default: true },
101
+ grid: { type: Boolean, default: true },
102
+ gridSize: { type: Number, default: 24 },
103
+ minimap: { type: Boolean, default: true },
104
+ minK: { type: Number, default: 0.2 },
105
+ maxK: { type: Number, default: 4 },
106
+ animated: { type: Boolean, default: true },
107
+ fitOnMount: { type: Boolean, default: true },
108
+ },
109
+ emits: {
110
+ nodeClick: (_node: BoardNodeInput) => true,
111
+ nodeMove: (_node: BoardNodeInput, _x: number, _y: number) => true,
112
+ viewportChange: (_cam: BoardCamera) => true,
113
+ },
114
+ setup(props, { emit, expose, slots }) {
115
+ const viewportRef = ref<HTMLElement | null>(null);
116
+ const viewportSize = ref<BoardViewport>({ w: 800, h: 600 });
117
+
118
+ /** Content bbox (world) — nodes plus a small margin. */
119
+ const content = computed<BoardRect>(() => {
120
+ const box = boardBBox(props.nodes.map((n) => ({ x: n.x, y: n.y, w: n.w, h: n.h })));
121
+ return { x: box.x - 20, y: box.y - 20, w: box.w + 40, h: box.h + 40 };
122
+ });
123
+
124
+ const cam: ReturnType<typeof useBoardCamera> = useBoardCamera({
125
+ viewportSize,
126
+ content,
127
+ minK: props.minK,
128
+ maxK: props.maxK,
129
+ animated: props.animated,
130
+ });
131
+ const camera = cam.camera as Ref<BoardCamera>;
132
+
133
+ // ── geometry ────────────────────────────────────────────────────────
134
+ const rectOf = computed(() => {
135
+ const map = new Map<string, BoardRect>();
136
+ for (const n of props.nodes) map.set(n.id, { x: n.x, y: n.y, w: n.w, h: n.h });
137
+ return map;
138
+ });
139
+
140
+ /** Edges sharing a `from` node fan across its border (天女散花). */
141
+ const fanOrdinal = computed(() => {
142
+ const counters = new Map<string, number>();
143
+ const ordinals = new Map<string, { index: number; count: number }>();
144
+ for (const e of props.edges) {
145
+ const next = (counters.get(e.from) ?? 0) + 1;
146
+ counters.set(e.from, next);
147
+ ordinals.set(e.id, { index: next - 1, count: counters.get(e.from)! });
148
+ }
149
+ return ordinals;
150
+ });
151
+
152
+ const edgePaths = computed(() => {
153
+ const rects = rectOf.value;
154
+ const out: { id: string; d: string; ink: string; dashed: boolean; width: number }[] = [];
155
+ for (const e of props.edges) {
156
+ const from = rects.get(e.from);
157
+ const to = rects.get(e.to);
158
+ if (!from || !to) continue;
159
+ const toCenter = { x: to.x + to.w / 2, y: to.y + to.h / 2 };
160
+ const fromCenter = { x: from.x + from.w / 2, y: from.y + from.h / 2 };
161
+ const fan = fanOrdinal.value.get(e.id) ?? { index: 0, count: 1 };
162
+ const a = boardAnchor(from, e.anchorFrom ?? "center", toCenter, fan.index, fan.count);
163
+ const b = boardAnchor(to, e.anchorTo ?? "center", fromCenter, fan.index, fan.count);
164
+ const viaCenters = (e.via ?? [])
165
+ .map((id) => rects.get(id))
166
+ .filter((r): r is BoardRect => Boolean(r))
167
+ .map((r) => ({ x: r.x + r.w / 2, y: r.y + r.h / 2 }));
168
+ out.push({
169
+ id: e.id,
170
+ d: boardViaPath(a, viaCenters, b, e.style ?? "orthogonal"),
171
+ ink: e.ink ?? DEFAULT_INK,
172
+ dashed: Boolean(e.dashed),
173
+ width: e.width ?? 1.5,
174
+ });
175
+ }
176
+ return out;
177
+ });
178
+
179
+ const contentBounds = computed(() => content.value);
180
+
181
+ const worldStyle = computed(() => ({
182
+ transform: `translate(${camera.value.x}px, ${camera.value.y}px) scale(${camera.value.k})`,
183
+ width: `${contentBounds.value.w}px`,
184
+ height: `${contentBounds.value.h}px`,
185
+ }));
186
+
187
+ const gridStyle = computed(() => {
188
+ if (!props.grid) return { display: "none" };
189
+ const s = props.gridSize * camera.value.k;
190
+ return {
191
+ backgroundImage: "radial-gradient(rgb(var(--color-text) / 12%) 1px, transparent 1px)",
192
+ backgroundSize: `${s}px ${s}px`,
193
+ backgroundPosition: `${camera.value.x}px ${camera.value.y}px`,
194
+ };
195
+ });
196
+
197
+ const minimapBoxes = computed<MinimapBox[]>(() =>
198
+ props.nodes
199
+ .filter((n) => !n.hidden)
200
+ .map((n) => ({
201
+ id: n.id,
202
+ bounds: { x: n.x, y: n.y, w: n.w, h: n.h },
203
+ color: "rgb(var(--color-primary) / 40%)",
204
+ })),
205
+ );
206
+
207
+ // ── gestures ────────────────────────────────────────────────────────
208
+ const pointers = new Map<number, { x: number; y: number }>();
209
+ let panState: { px: number; py: number } | null = null;
210
+ let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
211
+ let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
212
+
213
+ const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
214
+ const rect = viewportRef.value?.getBoundingClientRect();
215
+ return { x: e.clientX - (rect?.left ?? 0), y: e.clientY - (rect?.top ?? 0) };
216
+ };
217
+
218
+ function onWheel(e: WheelEvent): void {
219
+ if (!props.interactive) return;
220
+ e.preventDefault();
221
+ const factor = e.deltaY < 0 ? 1.05 : 1 / 1.05;
222
+ cam.zoomByFactor(factor, localPoint(e));
223
+ }
224
+
225
+ function refreshSize(): void {
226
+ const el = viewportRef.value;
227
+ if (!el) return;
228
+ viewportSize.value = { w: el.clientWidth, h: el.clientHeight };
229
+ }
230
+
231
+ let resizeObs: ResizeObserver | null = null;
232
+
233
+ function onPointerDown(e: PointerEvent): void {
234
+ if (!props.interactive) return;
235
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
236
+ if (pointers.size === 2) {
237
+ // pinch begins: snapshot camera + finger geometry
238
+ const [a, b] = [...pointers.values()];
239
+ pinchBase = {
240
+ cam: { ...camera.value },
241
+ dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
242
+ mid: localPoint(e),
243
+ };
244
+ panState = null;
245
+ return;
246
+ }
247
+ if (pointers.size === 1) {
248
+ panState = { px: e.clientX, py: e.clientY };
249
+ viewportRef.value?.setPointerCapture(e.pointerId);
250
+ }
251
+ }
252
+
253
+ function onPointerMove(e: PointerEvent): void {
254
+ if (!props.interactive) return;
255
+ if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
256
+ if (pinchBase && pointers.size >= 2) {
257
+ const [a, b] = [...pointers.values()];
258
+ const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
259
+ const mid = {
260
+ x: (a.x + b.x) / 2 - (viewportRef.value?.getBoundingClientRect().left ?? 0),
261
+ y: (a.y + b.y) / 2 - (viewportRef.value?.getBoundingClientRect().top ?? 0),
262
+ };
263
+ cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
264
+ return;
265
+ }
266
+ if (dragState) {
267
+ const k = camera.value.k || 1;
268
+ const dx = (e.clientX - dragState.sx) / k;
269
+ const dy = (e.clientY - dragState.sy) / k;
270
+ if (Math.abs(e.clientX - dragState.sx) + Math.abs(e.clientY - dragState.sy) > 3) dragState.moved = true;
271
+ dragState.node.x = Math.round(dragState.ox + dx);
272
+ dragState.node.y = Math.round(dragState.oy + dy);
273
+ emit("nodeMove", dragState.node, dragState.node.x, dragState.node.y);
274
+ return;
275
+ }
276
+ if (panState) {
277
+ cam.panBy(e.clientX - panState.px, e.clientY - panState.py);
278
+ panState = { px: e.clientX, py: e.clientY };
279
+ }
280
+ }
281
+
282
+ function onPointerUp(e: PointerEvent): void {
283
+ pointers.delete(e.pointerId);
284
+ if (pointers.size < 2 && pinchBase) {
285
+ pinchBase = null;
286
+ cam.settlePinch();
287
+ }
288
+ if (pointers.size === 0) panState = null;
289
+ if (dragState && !dragState.moved) emit("nodeClick", dragState.node);
290
+ dragState = null;
291
+ }
292
+
293
+ function onNodePointerDown(e: PointerEvent, node: BoardNodeInput): void {
294
+ if (!props.interactive || !node.draggable) return;
295
+ e.stopPropagation();
296
+ dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
297
+ viewportRef.value?.setPointerCapture(e.pointerId);
298
+ }
299
+
300
+ watch(() => camera.value, (c) => emit("viewportChange", { ...c }), { deep: true });
301
+ watch(toRef(props, "nodes"), () => {
302
+ // content growth invalidates clamping — re-clamp against the new bbox
303
+ cam.setCamera({ ...camera.value });
304
+ });
305
+
306
+ onMounted(() => {
307
+ refreshSize();
308
+ resizeObs = new ResizeObserver(refreshSize);
309
+ if (viewportRef.value) resizeObs.observe(viewportRef.value);
310
+ viewportRef.value?.addEventListener("wheel", onWheel, { passive: false });
311
+ if (props.fitOnMount) cam.fit();
312
+ });
313
+
314
+ onBeforeUnmount(() => {
315
+ resizeObs?.disconnect();
316
+ viewportRef.value?.removeEventListener("wheel", onWheel);
317
+ });
318
+
319
+ expose({
320
+ fit: () => cam.fit(),
321
+ zoomIn: () => cam.zoomIn(),
322
+ zoomOut: () => cam.zoomOut(),
323
+ zoomToPercent: (p: number, anchor?: BoardPoint) => cam.zoomToPercent(p, anchor),
324
+ reset: () => cam.fit(),
325
+ camera,
326
+ });
327
+
328
+ return () => (
329
+ <div
330
+ class="hk-board"
331
+ ref={viewportRef}
332
+ onPointerdown={onPointerDown}
333
+ onPointermove={onPointerMove}
334
+ onPointerup={onPointerUp}
335
+ onPointercancel={onPointerUp}
336
+ >
337
+ <div class="hk-board-grid" style={gridStyle.value} />
338
+ <div class="hk-board-world" style={worldStyle.value}>
339
+ <svg class="hk-board-edges" width={contentBounds.value.w} height={contentBounds.value.h}>
340
+ {edgePaths.value.map((e) => (
341
+ <path
342
+ key={e.id}
343
+ class="hk-board-edge"
344
+ d={e.d}
345
+ stroke={e.ink}
346
+ stroke-width={e.width}
347
+ stroke-dasharray={e.dashed ? "6 5" : undefined}
348
+ fill="none"
349
+ stroke-linecap="round"
350
+ />
351
+ ))}
352
+ </svg>
353
+ {props.nodes.filter((n) => !n.hidden).map((n) => (
354
+ <div
355
+ key={n.id}
356
+ class={[
357
+ "hk-board-node",
358
+ { "hk-board-node--draggable": Boolean(n.draggable) },
359
+ n.kind ? `hk-board-node--${n.kind}` : undefined,
360
+ ]}
361
+ style={{ left: `${n.x}px`, top: `${n.y}px`, width: `${n.w}px`, height: `${n.h}px` }}
362
+ onPointerdown={(e: PointerEvent) => onNodePointerDown(e, n)}
363
+ >
364
+ {slots.node
365
+ ? slots.node({ node: n })
366
+ : <div class="hk-board-node-shell">{n.label ?? ""}</div>}
367
+ </div>
368
+ ))}
369
+ </div>
370
+ {props.minimap && (
371
+ <div class="hk-board-minimap">
372
+ <HkMinimap
373
+ boxes={minimapBoxes.value}
374
+ zoom={camera.value.k}
375
+ panX={camera.value.x}
376
+ panY={camera.value.y}
377
+ viewportWidth={viewportSize.value.w}
378
+ viewportHeight={viewportSize.value.h}
379
+ contentBounds={contentBounds.value}
380
+ zoomPercent={Math.round(camera.value.k * 100)}
381
+ canZoomIn={camera.value.k < props.maxK}
382
+ canZoomOut={camera.value.k > props.minK}
383
+ zoomStepPercent={5}
384
+ minZoomPercent={Math.round(props.minK * 100)}
385
+ maxZoomPercent={Math.round(props.maxK * 100)}
386
+ showReset
387
+ onZoomTo={(percent: number) => cam.zoomToPercent(percent)}
388
+ onReset={() => cam.fit()}
389
+ onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
390
+ />
391
+ </div>
392
+ )}
393
+ </div>
394
+ );
395
+ },
396
+ });
@@ -0,0 +1,218 @@
1
+ /**
2
+ * useBoardCamera — reactive world camera + gesture primitives for HkBoard.
3
+ *
4
+ * Owns the camera state (`{x, y, k}`) and the motion rules:
5
+ * - every zoom lands on the 5% geometric ladder (quantizeBoardK / quantizeBoardStep);
6
+ * - zoom changes ANIMATE (~170ms ease-out, geometric k tween) instead of
7
+ * snapping — an active pinch stays raw/continuous while the fingers
8
+ * move and only animates to the nearest rung on release;
9
+ * - wheel steps one rung per notch, anchored at the pointer;
10
+ * - pan is clamped against the content bbox (half-viewport slack);
11
+ * - reduced-motion (or `animated: false`) skips the tween and snaps.
12
+ *
13
+ * The component wires DOM events; this composable stays input-agnostic.
14
+ */
15
+ import { computed, ref, toValue, type MaybeRefOrGetter, type Ref } from "vue";
16
+
17
+ import {
18
+ BOARD_K_MAX,
19
+ BOARD_K_MIN,
20
+ BOARD_ZOOM_FACTOR,
21
+ type BoardCamera,
22
+ type BoardPoint,
23
+ type BoardRect,
24
+ type BoardViewport,
25
+ boardClampPan,
26
+ boardFit,
27
+ boardTweenCam,
28
+ boardZoomAt,
29
+ quantizeBoardK,
30
+ } from "../utils/boardCamera";
31
+
32
+ /** Animated transition duration, ms (wheel notch / minimap bar / pinch release). */
33
+ const BOARD_ANIM_MS = 170;
34
+
35
+ /** System reduced-motion preference — animations snap instead of tweening. */
36
+ function prefersReducedMotion(): boolean {
37
+ return typeof window !== "undefined"
38
+ && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
39
+ }
40
+
41
+ export interface UseBoardCameraOptions {
42
+ /** The viewport element (screen space) — used for anchor math. */
43
+ viewportSize: MaybeRefOrGetter<BoardViewport>;
44
+ /** World bbox of the board content (nodes + padding). */
45
+ content: MaybeRefOrGetter<BoardRect>;
46
+ minK?: number;
47
+ maxK?: number;
48
+ /** Master animation switch; reduced-motion forces snapping regardless. */
49
+ animated?: boolean;
50
+ }
51
+
52
+ export interface UseBoardCameraReturn {
53
+ camera: Ref<BoardCamera>;
54
+ isAnimating: Ref<boolean>;
55
+ /** Screen↔world conversion at the CURRENT camera. */
56
+ screenToWorld: (p: BoardPoint) => BoardPoint;
57
+ worldToScreen: (p: BoardPoint) => BoardPoint;
58
+ /** Animated, ladder-quantized zoom keeping `anchor` (screen px) fixed. */
59
+ zoomByFactor: (factor: number, anchor?: BoardPoint) => void;
60
+ zoomToK: (targetK: number, anchor?: BoardPoint) => void;
61
+ zoomToPercent: (percent: number, anchor?: BoardPoint) => void;
62
+ zoomIn: () => void;
63
+ zoomOut: () => void;
64
+ /** Raw (unquantized, unanimated) pinch zoom — call per pointermove. */
65
+ pinchZoom: (base: BoardCamera, ratio: number, anchor: BoardPoint) => void;
66
+ /** Snap the post-pinch camera onto the nearest rung (animated). */
67
+ settlePinch: () => void;
68
+ panBy: (dx: number, dy: number) => void;
69
+ setCamera: (cam: BoardCamera, opts?: { animate?: boolean }) => void;
70
+ fit: () => void;
71
+ reset: () => void;
72
+ }
73
+
74
+ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraReturn {
75
+ const {
76
+ viewportSize,
77
+ content,
78
+ minK = BOARD_K_MIN,
79
+ maxK = BOARD_K_MAX,
80
+ animated = true,
81
+ } = options;
82
+
83
+ const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
84
+ const isAnimating = ref(false);
85
+
86
+ let raf = 0;
87
+ let tweenFrom: BoardCamera = { x: 0, y: 0, k: 1 };
88
+ let tweenTo: BoardCamera = { x: 0, y: 0, k: 1 };
89
+ let tweenStart = 0;
90
+
91
+ const stopTween = (): void => {
92
+ if (raf) {
93
+ window.cancelAnimationFrame(raf);
94
+ raf = 0;
95
+ }
96
+ isAnimating.value = false;
97
+ };
98
+
99
+ const viewport = (): BoardViewport => toValue(viewportSize);
100
+ const contentRect = (): BoardRect => toValue(content);
101
+
102
+ const clampCam = (cam: BoardCamera): BoardCamera => {
103
+ const k = Math.min(maxK, Math.max(minK, cam.k));
104
+ return boardClampPan({ ...cam, k }, contentRect(), viewport());
105
+ };
106
+
107
+ function animateTo(target: BoardCamera): void {
108
+ const doSnap = !animated || prefersReducedMotion();
109
+ if (doSnap) {
110
+ stopTween();
111
+ camera.value = clampCam(target);
112
+ return;
113
+ }
114
+ tweenFrom = { ...camera.value };
115
+ tweenTo = target;
116
+ tweenStart = performance.now();
117
+ isAnimating.value = true;
118
+ if (raf) window.cancelAnimationFrame(raf);
119
+ const step = (now: number): void => {
120
+ const t = Math.min(1, (now - tweenStart) / BOARD_ANIM_MS);
121
+ camera.value = clampCam(boardTweenCam(tweenFrom, tweenTo, t));
122
+ if (t < 1) {
123
+ raf = window.requestAnimationFrame(step);
124
+ } else {
125
+ raf = 0;
126
+ isAnimating.value = false;
127
+ }
128
+ };
129
+ raf = window.requestAnimationFrame(step);
130
+ }
131
+
132
+ const anchorPoint = (anchor?: BoardPoint): BoardPoint => {
133
+ const vp = viewport();
134
+ return anchor ?? { x: vp.w / 2, y: vp.h / 2 };
135
+ };
136
+
137
+ function zoomByFactor(factor: number, anchor?: BoardPoint): void {
138
+ const next = boardZoomAt(camera.value, camera.value.k * factor, anchorPoint(anchor));
139
+ animateTo({ ...next, k: quantizeBoardK(next.k) });
140
+ }
141
+
142
+ function zoomToK(targetK: number, anchor?: BoardPoint): void {
143
+ const next = boardZoomAt(camera.value, targetK, anchorPoint(anchor));
144
+ animateTo({ ...next, k: quantizeBoardK(next.k) });
145
+ }
146
+
147
+ function zoomToPercent(percent: number, anchor?: BoardPoint): void {
148
+ zoomToK(Math.min(maxK, Math.max(minK, percent / 100)), anchor);
149
+ }
150
+
151
+ function zoomIn(): void {
152
+ zoomByFactor(BOARD_ZOOM_FACTOR);
153
+ }
154
+
155
+ function zoomOut(): void {
156
+ zoomByFactor(1 / BOARD_ZOOM_FACTOR);
157
+ }
158
+
159
+ function pinchZoom(base: BoardCamera, ratio: number, anchor: BoardPoint): void {
160
+ // Raw and continuous while the fingers move; quantized on release.
161
+ stopTween();
162
+ camera.value = clampCam(boardZoomAt(base, base.k * ratio, anchor));
163
+ }
164
+
165
+ function settlePinch(): void {
166
+ const settled = { ...camera.value, k: quantizeBoardK(camera.value.k) };
167
+ animateTo(settled);
168
+ }
169
+
170
+ function panBy(dx: number, dy: number): void {
171
+ stopTween();
172
+ camera.value = clampCam({ ...camera.value, x: camera.value.x + dx, y: camera.value.y + dy });
173
+ }
174
+
175
+ function setCamera(cam: BoardCamera, opts: { animate?: boolean } = {}): void {
176
+ if (opts.animate) animateTo(cam);
177
+ else {
178
+ stopTween();
179
+ camera.value = clampCam(cam);
180
+ }
181
+ }
182
+
183
+ function fit(): void {
184
+ animateTo(boardFit(contentRect(), viewport()));
185
+ }
186
+
187
+ function reset(): void {
188
+ fit();
189
+ }
190
+
191
+ function screenToWorld(p: BoardPoint): BoardPoint {
192
+ const c = camera.value;
193
+ return { x: (p.x - c.x) / c.k, y: (p.y - c.y) / c.k };
194
+ }
195
+
196
+ function worldToScreen(p: BoardPoint): BoardPoint {
197
+ const c = camera.value;
198
+ return { x: p.x * c.k + c.x, y: p.y * c.k + c.y };
199
+ }
200
+
201
+ return {
202
+ camera,
203
+ isAnimating,
204
+ screenToWorld,
205
+ worldToScreen,
206
+ zoomByFactor,
207
+ zoomToK,
208
+ zoomToPercent,
209
+ zoomIn,
210
+ zoomOut,
211
+ pinchZoom,
212
+ settlePinch,
213
+ panBy,
214
+ setCamera,
215
+ fit,
216
+ reset,
217
+ };
218
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export { default as HAlert } from "./components/HkAlert";
4
4
  export { default as HAltSignIn } from "./components/HkAltSignIn";
5
5
  export { default as HAvatar } from "./components/HkAvatar";
6
6
  export { default as HBadge } from "./components/HkBadge";
7
+ export { default as HBoard } from "./components/HkBoard";
7
8
  export { default as HBlockingToast } from "./components/HkBlockingToast";
8
9
  export { default as HBreadcrumb } from "./components/HkBreadcrumb";
9
10
  export { default as HButton } from "./components/HkButton";
@@ -105,6 +106,9 @@ export { default as HLogo } from "./components/HkLogo";
105
106
 
106
107
  // Component types
107
108
  export { type BadgeVariant } from "./components/HkBadge";
109
+ export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
110
+ export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
111
+ export { type BoardCamera, type BoardRect, type BoardViewport } from "./utils/boardCamera";
108
112
  export { type ModalAction } from "./components/HkModal";
109
113
  export { type TreeNode, type TreeSize, type TreeRowScope } from "./components/HkTree";
110
114
  export { type DragListItem } from "./components/HkDraggableList";
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ BOARD_FIT_CAP,
5
+ BOARD_ZOOM_FACTOR,
6
+ boardBBox,
7
+ boardClampPan,
8
+ boardEaseOutCubic,
9
+ boardFit,
10
+ boardLevelOf,
11
+ boardToScreen,
12
+ boardTweenCam,
13
+ boardToWorld,
14
+ boardZoomAt,
15
+ quantizeBoardK,
16
+ quantizeBoardStep,
17
+ } from "./boardCamera";
18
+
19
+ describe("boardCamera — 5% geometric ladder", () => {
20
+ it("snaps arbitrary zooms onto 1.05ⁿ rungs", () => {
21
+ expect(quantizeBoardK(1)).toBe(1);
22
+ expect(quantizeBoardK(1.02)).toBe(1);
23
+ expect(quantizeBoardK(1.04)).toBeCloseTo(1.05, 5);
24
+ expect(quantizeBoardK(BOARD_ZOOM_FACTOR ** 3)).toBeCloseTo(BOARD_ZOOM_FACTOR ** 3, 5);
25
+ });
26
+
27
+ it("clamps to the global zoom bounds", () => {
28
+ expect(quantizeBoardK(0.01)).toBeLessThanOrEqual(0.2);
29
+ expect(quantizeBoardK(99)).toBeGreaterThanOrEqual(4);
30
+ });
31
+
32
+ it("quantizes a pinch step relative to its base", () => {
33
+ const base = 0.8;
34
+ // a pinch whose raw target is exactly two rungs above the base lands
35
+ // there: base·1.05² (ratio = raw/base)
36
+ expect(quantizeBoardStep(base, 1.05 ** 2)).toBeCloseTo(base * BOARD_ZOOM_FACTOR ** 2, 5);
37
+ // a ratio inside the same rung stays on the base rung
38
+ expect(quantizeBoardStep(base, 1.02)).toBeCloseTo(base, 5);
39
+ });
40
+ });
41
+
42
+ describe("boardCamera — anchor zoom", () => {
43
+ it("keeps the world point under the screen anchor fixed", () => {
44
+ const cam = { x: 40, y: -20, k: 1 };
45
+ const anchor = { x: 300, y: 200 };
46
+ const worldBefore = boardToWorld(cam, anchor.x, anchor.y);
47
+ const zoomed = boardZoomAt(cam, 1.5, anchor);
48
+ const worldAfter = boardToWorld(zoomed, anchor.x, anchor.y);
49
+ expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
50
+ expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
51
+ expect(zoomed.k).toBe(1.5);
52
+ });
53
+
54
+ it("round-trips screen→world→screen", () => {
55
+ const cam = { x: -130, y: 88, k: 1.3 };
56
+ const p = boardToScreen(cam, 42, -7);
57
+ const w = boardToWorld(cam, p.x, p.y);
58
+ expect(w.x).toBeCloseTo(42, 6);
59
+ expect(w.y).toBeCloseTo(-7, 6);
60
+ });
61
+ });
62
+
63
+ describe("boardCamera — pan clamp + fit", () => {
64
+ const content = { x: 0, y: 0, w: 2000, h: 1200 };
65
+ const viewport = { w: 800, h: 600 };
66
+
67
+ it("keeps half a viewport of slack for oversized content", () => {
68
+ const cam = boardClampPan({ k: 1, x: 9999, y: -9999 }, content, viewport);
69
+ expect(cam.x).toBeLessThanOrEqual(400);
70
+ expect(cam.y).toBeGreaterThanOrEqual(-1200 - 300);
71
+ });
72
+
73
+ it("re-centers content smaller than the viewport", () => {
74
+ const small = { x: 0, y: 0, w: 200, h: 100 };
75
+ const cam = boardClampPan({ k: 1, x: -500, y: 999 }, small, viewport);
76
+ expect(cam.x).toBe((viewport.w - 200) / 2);
77
+ expect(cam.y).toBe((viewport.h - 100) / 2);
78
+ });
79
+
80
+ it("fits the whole board with padding and honors the cap", () => {
81
+ const cam = boardFit(content, viewport);
82
+ expect(cam.k).toBeCloseTo(Math.min((800 - 80) / 2000, (600 - 80) / 1200), 6);
83
+ const tiny = boardFit({ x: 0, y: 0, w: 60, h: 40 }, viewport);
84
+ expect(tiny.k).toBeLessThanOrEqual(BOARD_FIT_CAP);
85
+ });
86
+ });
87
+
88
+ describe("boardCamera — animated tween", () => {
89
+ it("interpolates k geometrically and eases the endpoints", () => {
90
+ const from = { x: 0, y: 0, k: 1 };
91
+ const to = { x: 100, y: -50, k: 1.05 ** 4 };
92
+ // t=0.5 eases to 0.875 — the geometric mid sits at that eased point
93
+ const half = boardTweenCam(from, to, 0.5);
94
+ const eased = boardEaseOutCubic(0.5);
95
+ expect(half.k).toBeCloseTo(from.k * (to.k / from.k) ** eased, 6);
96
+ expect(half.x).toBeGreaterThan(0);
97
+ const done = boardTweenCam(from, to, 1);
98
+ expect(done).toEqual(to);
99
+ const start = boardTweenCam(from, to, 0);
100
+ expect(start).toEqual(from);
101
+ });
102
+
103
+ it("reports the ladder level consistently", () => {
104
+ expect(boardLevelOf(1)).toBe(0);
105
+ expect(boardLevelOf(BOARD_ZOOM_FACTOR)).toBeCloseTo(1, 6);
106
+ });
107
+ });
108
+
109
+ describe("boardCamera — content bbox", () => {
110
+ it("unions rects and ignores empty ones", () => {
111
+ const box = boardBBox([
112
+ { x: 10, y: 10, w: 100, h: 50 },
113
+ { x: 200, y: 0, w: 50, h: 40 },
114
+ { x: 0, y: 0, w: 0, h: 0 },
115
+ ]);
116
+ expect(box).toEqual({ x: 10, y: 0, w: 240, h: 60 });
117
+ });
118
+
119
+ it("falls back to a default rect with no content", () => {
120
+ expect(boardBBox([]).w).toBeGreaterThan(0);
121
+ });
122
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * boardCamera.ts — pure world-camera math for HkBoard.
3
+ *
4
+ * A board lives in a fixed WORLD coordinate space; the camera maps
5
+ * world → screen (`screen = world·k + (x, y)`, `transform-origin: 0 0`).
6
+ * This module owns the rules that keep the viewport decoupled from the
7
+ * graph, so every consumer (topic maps, SCADA scenes, PLC panels, …)
8
+ * shares ONE motion feel:
9
+ *
10
+ * - zoom is a GEOMETRIC 5% ladder (`k = 1.05ⁿ`): gestures accumulate
11
+ * into a continuous level and the applied zoom snaps to the rung;
12
+ * - `boardZoomAt` keeps the world point under a screen anchor (cursor /
13
+ * pinch midpoint) visually fixed while k changes;
14
+ * - pan is CLAMPED so the content bbox can never be pushed fully out of
15
+ * view (half a viewport of slack on each side); content smaller than
16
+ * the viewport re-centers;
17
+ * - `boardFit` fits the whole bbox (pad + floor + cap);
18
+ * - `boardTweenCam` eases between two cameras with an ease-out curve and
19
+ * GEOMETRIC k interpolation (log-space lerp) so animated zooms still
20
+ * land exactly on ladder rungs.
21
+ *
22
+ * Pure functions only — HkBoard / useBoardCamera supply state, timing and
23
+ * gestures. Semantics intentionally mirror the chest SCADA scene camera
24
+ * (the first consumer of this contract).
25
+ */
26
+
27
+ export interface BoardCamera {
28
+ x: number;
29
+ y: number;
30
+ k: number;
31
+ }
32
+
33
+ export interface BoardRect {
34
+ x: number;
35
+ y: number;
36
+ w: number;
37
+ h: number;
38
+ }
39
+
40
+ export interface BoardPoint {
41
+ x: number;
42
+ y: number;
43
+ }
44
+
45
+ export interface BoardViewport {
46
+ w: number;
47
+ h: number;
48
+ }
49
+
50
+ /** One zoom step = ±5% (geometric). */
51
+ export const BOARD_ZOOM_FACTOR = 1.05;
52
+ export const BOARD_K_MIN = 0.2;
53
+ export const BOARD_K_MAX = 4;
54
+ /** fit() may shrink as far as needed to show the whole board. */
55
+ export const BOARD_FIT_FLOOR = 0.2;
56
+ /** fit() never blows above this (nearly-empty boards). */
57
+ export const BOARD_FIT_CAP = 1.25;
58
+ /** Padding around the bbox used by fit(), world px. */
59
+ export const BOARD_FIT_PAD = 40;
60
+
61
+ export const boardLevelOf = (k: number): number =>
62
+ Math.log(k) / Math.log(BOARD_ZOOM_FACTOR);
63
+
64
+ export const boardKOf = (level: number): number =>
65
+ Math.pow(BOARD_ZOOM_FACTOR, level);
66
+
67
+ export const boardClampK = (k: number): number =>
68
+ Math.min(BOARD_K_MAX, Math.max(BOARD_K_MIN, k));
69
+
70
+ /** Clamp a raw zoom into the legal ladder, snapped to a 5% rung. */
71
+ export function quantizeBoardK(k: number): number {
72
+ const clamped = boardClampK(k);
73
+ if (clamped <= BOARD_K_MIN) return BOARD_K_MIN;
74
+ if (clamped >= BOARD_K_MAX) return BOARD_K_MAX;
75
+ return boardKOf(Math.round(boardLevelOf(clamped)));
76
+ }
77
+
78
+ /**
79
+ * Quantize a zoom CHANGE (relative rung ladder anchored at `base`):
80
+ * base·ratio stays exactly on `base·1.05ⁿ`, so a pinch always feels like
81
+ * clean 5% steps from wherever the gesture started. Clamped to the
82
+ * global bounds.
83
+ */
84
+ export function quantizeBoardStep(base: number, ratio: number): number {
85
+ const raw = base * ratio;
86
+ const level = Math.round(boardLevelOf(raw) - boardLevelOf(base));
87
+ return boardClampK(base * boardKOf(level));
88
+ }
89
+
90
+ export function boardToScreen(cam: BoardCamera, wx: number, wy: number): BoardPoint {
91
+ return { x: wx * cam.k + cam.x, y: wy * cam.k + cam.y };
92
+ }
93
+
94
+ export function boardToWorld(cam: BoardCamera, sx: number, sy: number): BoardPoint {
95
+ return { x: (sx - cam.x) / cam.k, y: (sy - cam.y) / cam.k };
96
+ }
97
+
98
+ /**
99
+ * Zoom while keeping the world point under the screen anchor visually
100
+ * fixed (cursor / pinch midpoint zoom). The result is NOT rung-quantized —
101
+ * callers quantize when they want the ladder (see quantizeBoardK).
102
+ */
103
+ export function boardZoomAt(
104
+ cam: BoardCamera,
105
+ targetK: number,
106
+ anchorScreen: BoardPoint,
107
+ ): BoardCamera {
108
+ const k = boardClampK(targetK);
109
+ const world = boardToWorld(cam, anchorScreen.x, anchorScreen.y);
110
+ return { k, x: anchorScreen.x - world.x * k, y: anchorScreen.y - world.y * k };
111
+ }
112
+
113
+ /**
114
+ * Clamp the pan so the content bbox can never be dragged fully out of
115
+ * view: content larger than the viewport keeps half a viewport of slack
116
+ * on each side; content smaller than the viewport re-centers.
117
+ */
118
+ export function boardClampPan(
119
+ cam: BoardCamera,
120
+ content: BoardRect,
121
+ viewport: BoardViewport,
122
+ slack = 0.5,
123
+ ): BoardCamera {
124
+ const clampAxis = (pan: number, view: number, size: number): number => {
125
+ const drawn = size * cam.k;
126
+ if (drawn <= view) return (view - drawn) / 2;
127
+ const slackPx = view * slack;
128
+ return Math.min(Math.max(pan, view - drawn - slackPx), slackPx);
129
+ };
130
+ return {
131
+ k: cam.k,
132
+ x: clampAxis(cam.x, viewport.w, content.w),
133
+ y: clampAxis(cam.y, viewport.h, content.h),
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Fit the whole content bbox into the viewport (centered, padded). Wide
139
+ * boards shrink fully into view; tiny boards never blow up past the cap.
140
+ */
141
+ export function boardFit(
142
+ content: BoardRect,
143
+ viewport: BoardViewport,
144
+ opts: { pad?: number; floor?: number; cap?: number } = {},
145
+ ): BoardCamera {
146
+ const pad = opts.pad ?? BOARD_FIT_PAD;
147
+ const floor = opts.floor ?? BOARD_FIT_FLOOR;
148
+ const cap = opts.cap ?? BOARD_FIT_CAP;
149
+ const kw = content.w > 0 ? (viewport.w - pad * 2) / content.w : cap;
150
+ const kh = content.h > 0 ? (viewport.h - pad * 2) / content.h : cap;
151
+ const k = Math.min(cap, Math.max(floor, Math.min(kw, kh)));
152
+ return {
153
+ k,
154
+ x: (viewport.w - content.w * k) / 2 - content.x * k,
155
+ y: (viewport.h - content.h * k) / 2 - content.y * k,
156
+ };
157
+ }
158
+
159
+ export const boardEaseOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3);
160
+
161
+ const clamp01 = (t: number): number => Math.min(1, Math.max(0, t));
162
+
163
+ /**
164
+ * Interpolate between two cameras for an animated transition. Pan lerps
165
+ * linearly, k interpolates GEOMETRICALLY (log-space) so a tween between
166
+ * two ladder rungs passes through intermediate rungs, not linear noise.
167
+ * `t` is clamped to [0, 1] and eased with ease-out cubic.
168
+ */
169
+ export function boardTweenCam(from: BoardCamera, to: BoardCamera, t: number): BoardCamera {
170
+ const e = boardEaseOutCubic(clamp01(t));
171
+ const k = from.k > 0 && to.k > 0 ? from.k * Math.pow(to.k / from.k, e) : to.k;
172
+ return {
173
+ k,
174
+ x: from.x + (to.x - from.x) * e,
175
+ y: from.y + (to.y - from.y) * e,
176
+ };
177
+ }
178
+
179
+ /** Union of rects (ignoring empty ones) — the content bbox for clamp/fit. */
180
+ export function boardBBox(rects: BoardRect[]): BoardRect {
181
+ let minX = Infinity;
182
+ let minY = Infinity;
183
+ let maxX = -Infinity;
184
+ let maxY = -Infinity;
185
+ for (const r of rects) {
186
+ if (r.w <= 0 || r.h <= 0) continue;
187
+ minX = Math.min(minX, r.x);
188
+ minY = Math.min(minY, r.y);
189
+ maxX = Math.max(maxX, r.x + r.w);
190
+ maxY = Math.max(maxY, r.y + r.h);
191
+ }
192
+ if (!Number.isFinite(minX)) return { x: 0, y: 0, w: 1200, h: 800 };
193
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
194
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ boardAnchor,
5
+ boardEdgePath,
6
+ boardViaPath,
7
+ type BoardNodeRect,
8
+ } from "./boardEdges";
9
+
10
+ const rect: BoardNodeRect = { x: 100, y: 100, w: 120, h: 60 };
11
+ const rightTarget = { x: 400, y: 130 };
12
+ const belowTarget = { x: 160, y: 400 };
13
+
14
+ describe("boardEdges — anchor modes", () => {
15
+ it("center exits through the facing border midpoint", () => {
16
+ const a = boardAnchor(rect, "center", rightTarget);
17
+ expect(a.y).toBe(130);
18
+ expect(a.x).toBe(220);
19
+ });
20
+
21
+ it("top pins to the top-border midpoint, top-left to the corner", () => {
22
+ expect(boardAnchor(rect, "top", belowTarget)).toEqual({ x: 160, y: 100 });
23
+ expect(boardAnchor(rect, "top-left", belowTarget)).toEqual({ x: 100, y: 100 });
24
+ });
25
+
26
+ it("nearest snaps to the closest perimeter point", () => {
27
+ const a = boardAnchor(rect, "nearest", { x: 500, y: 105 });
28
+ expect(a).toEqual({ x: 220, y: 105 });
29
+ });
30
+
31
+ it("fan spreads multiple edges along the exit border", () => {
32
+ const first = boardAnchor(rect, "fan", rightTarget, 0, 3);
33
+ const second = boardAnchor(rect, "fan", rightTarget, 1, 3);
34
+ const third = boardAnchor(rect, "fan", rightTarget, 2, 3);
35
+ expect(first.x).toBe(220);
36
+ expect(first.y).toBeLessThan(second.y);
37
+ expect(second.y).toBeLessThan(third.y);
38
+ expect(new Set([first.y, second.y, third.y]).size).toBe(3);
39
+ });
40
+ });
41
+
42
+ describe("boardEdges — route styles", () => {
43
+ const a = { x: 220, y: 130 };
44
+ const b = { x: 400, y: 260 };
45
+
46
+ it("straight is a single segment", () => {
47
+ expect(boardEdgePath(a, b, "straight")).toBe("M 220 130 L 400 260");
48
+ });
49
+
50
+ it("bezier is a horizontal-tangent cubic", () => {
51
+ const d = boardEdgePath(a, b, "bezier");
52
+ expect(d).toContain("C 310 130, 310 260, 400 260");
53
+ });
54
+
55
+ it("orthogonal elbows through the mid X", () => {
56
+ expect(boardEdgePath(a, b, "orthogonal")).toBe("M 220 130 H 310 V 260 H 400");
57
+ });
58
+
59
+ it("spine drops vertically first", () => {
60
+ expect(boardEdgePath(a, b, "spine")).toBe("M 220 130 V 195 H 400 V 260");
61
+ });
62
+ });
63
+
64
+ describe("boardEdges — via (empty corner nodes)", () => {
65
+ const a = { x: 0, y: 0 };
66
+ const corner = { x: 200, y: 200 };
67
+ const b = { x: 400, y: 200 };
68
+
69
+ it("chains orthogonal legs through the via point", () => {
70
+ const d = boardViaPath(a, [corner], b, "orthogonal");
71
+ // leg1: M 0 0 H 100 V 200 ; leg2: H 300 V 200 H 400 — a clean Z
72
+ expect(d).toContain("H 100 V 200");
73
+ expect(d).toContain("V 200 H 400");
74
+ });
75
+
76
+ it("chains straight legs as a polyline through the via point", () => {
77
+ const d = boardViaPath(a, [corner], b, "straight");
78
+ expect(d).toBe("M 0 0 L 200 200 L 400 200");
79
+ });
80
+
81
+ it("smooths multi-via bezier chains", () => {
82
+ const d = boardViaPath(a, [{ x: 100, y: 300 }, { x: 300, y: 300 }], b, "bezier");
83
+ expect(d.startsWith("M 0 0 C ")).toBe(true);
84
+ expect(d).toContain("400 200");
85
+ });
86
+ });
@@ -0,0 +1,185 @@
1
+ /**
2
+ * boardEdges.ts — pure anchor + path geometry for HkBoard edges.
3
+ *
4
+ * Edges connect node rects. The two independently-configurable halves of
5
+ * that contract:
6
+ *
7
+ * - ANCHOR MODE (`BoardAnchorMode`) — where on the node border an edge
8
+ * attaches:
9
+ * • "center" — the border point the center→center ray exits through
10
+ * (the classic mind-map / graph look);
11
+ * • "nearest" — the border point closest to the other end;
12
+ * • "top" — top-border midpoint (top-aligned columns, SCADA-style
13
+ * vertical drops);
14
+ * • "top-left" — the top-left corner (top-left aligned rows);
15
+ * • "fan" — 天女散花: when one node feeds SEVERAL edges, the
16
+ * attach points spread evenly across the exit border
17
+ * instead of stacking on one dot (pass index/count).
18
+ *
19
+ * - ROUTE STYLE (`BoardEdgeStyle`) — how the two anchors join:
20
+ * • "straight" — a straight segment;
21
+ * • "bezier" — a horizontal-tangent cubic (flowing curve);
22
+ * • "orthogonal" — strict elbow (H-V-H), the SCADA pipe discipline;
23
+ * • "spine" — vertical-first elbow (V-H), for tree rails.
24
+ *
25
+ * Edges may also route THROUGH empty corner nodes: `boardViaPath` chains
26
+ * the segment builders across the via points (the centers of invisible
27
+ * junction nodes), which is how consumers bake a right-angle turn into
28
+ * the graph itself.
29
+ *
30
+ * Pure functions only — path strings are world-space SVG `d` attributes.
31
+ */
32
+
33
+ export interface BoardPoint {
34
+ x: number;
35
+ y: number;
36
+ }
37
+
38
+ export interface BoardNodeRect {
39
+ x: number;
40
+ y: number;
41
+ w: number;
42
+ h: number;
43
+ }
44
+
45
+ export type BoardAnchorMode = "center" | "nearest" | "top" | "top-left" | "fan";
46
+
47
+ export type BoardEdgeStyle = "straight" | "bezier" | "orthogonal" | "spine";
48
+
49
+ const r1 = (n: number): number => Math.round(n * 10) / 10;
50
+
51
+ /** Border point of `rect` where the ray from rect center toward `to`
52
+ * exits the rect. Falls back to the facing-border midpoint on degenerate
53
+ * (zero-size) rects. */
54
+ function centerExit(rect: BoardNodeRect, to: BoardPoint): BoardPoint {
55
+ const cx = rect.x + rect.w / 2;
56
+ const cy = rect.y + rect.h / 2;
57
+ const dx = to.x - cx;
58
+ const dy = to.y - cy;
59
+ if (dx === 0 && dy === 0) return { x: cx, y: rect.y };
60
+ const hw = rect.w / 2;
61
+ const hh = rect.h / 2;
62
+ const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
63
+ const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
64
+ const s = Math.min(sx, sy);
65
+ return { x: cx + dx * s, y: cy + dy * s };
66
+ }
67
+
68
+ /** Closest point of `rect` perimeter to `to`. */
69
+ function nearestPoint(rect: BoardNodeRect, to: BoardPoint): BoardPoint {
70
+ const cx = rect.x + rect.w / 2;
71
+ const cy = rect.y + rect.h / 2;
72
+ const dx = to.x - cx;
73
+ const dy = to.y - cy;
74
+ if (dx === 0 && dy === 0) return { x: cx, y: rect.y };
75
+ const hw = rect.w / 2;
76
+ const hh = rect.h / 2;
77
+ const sx = dx !== 0 ? hw / Math.abs(dx) : Infinity;
78
+ const sy = dy !== 0 ? hh / Math.abs(dy) : Infinity;
79
+ if (sx < sy) {
80
+ return { x: cx + Math.sign(dx) * hw, y: Math.min(Math.max(to.y, rect.y), rect.y + rect.h) };
81
+ }
82
+ return { x: Math.min(Math.max(to.x, rect.x), rect.x + rect.w), y: cy + Math.sign(dy) * hh };
83
+ }
84
+
85
+ /**
86
+ * Resolve the anchor point on `rect` for an edge toward `to`.
87
+ * `index`/`count` only matter for "fan" (even spread along the exit
88
+ * border; no-ops when count ≤ 1).
89
+ */
90
+ export function boardAnchor(
91
+ rect: BoardNodeRect,
92
+ mode: BoardAnchorMode,
93
+ to: BoardPoint,
94
+ index = 0,
95
+ count = 1,
96
+ ): BoardPoint {
97
+ switch (mode) {
98
+ case "top":
99
+ return { x: rect.x + rect.w / 2, y: rect.y };
100
+ case "top-left":
101
+ return { x: rect.x, y: rect.y };
102
+ case "nearest":
103
+ return nearestPoint(rect, to);
104
+ case "fan": {
105
+ const base = centerExit(rect, to);
106
+ if (count <= 1) return base;
107
+ // Spread along the exit border (whichever side centerExit left
108
+ // through) between 1/(count+1) and count/(count+1) of the side.
109
+ const t = (index + 1) / (count + 1);
110
+ const dx = to.x - (rect.x + rect.w / 2);
111
+ const dy = to.y - (rect.y + rect.h / 2);
112
+ const vertical = Math.abs(dy) * rect.w >= Math.abs(dx) * rect.h;
113
+ if (vertical) {
114
+ const x = rect.x + rect.w * t;
115
+ return { x, y: dy < 0 ? rect.y : rect.y + rect.h };
116
+ }
117
+ const y = rect.y + rect.h * t;
118
+ return { x: dx < 0 ? rect.x : rect.x + rect.w, y };
119
+ }
120
+ case "center":
121
+ default:
122
+ return centerExit(rect, to);
123
+ }
124
+ }
125
+
126
+ /** Segment WITHOUT the leading "M x y" — the drawable tail from `a` into
127
+ * `b`, so multi-leg paths can chain tails end to end. */
128
+ function segmentTail(a: BoardPoint, b: BoardPoint, style: BoardEdgeStyle): string {
129
+ switch (style) {
130
+ case "bezier": {
131
+ const dx = (b.x - a.x) / 2;
132
+ return `C ${r1(a.x + dx)} ${r1(a.y)}, ${r1(b.x - dx)} ${r1(b.y)}, ${r1(b.x)} ${r1(b.y)}`;
133
+ }
134
+ case "orthogonal": {
135
+ const midX = r1((a.x + b.x) / 2);
136
+ return `H ${midX} V ${r1(b.y)} H ${r1(b.x)}`;
137
+ }
138
+ case "spine": {
139
+ const midY = r1((a.y + b.y) / 2);
140
+ return `V ${midY} H ${r1(b.x)} V ${r1(b.y)}`;
141
+ }
142
+ case "straight":
143
+ default:
144
+ return `L ${r1(b.x)} ${r1(b.y)}`;
145
+ }
146
+ }
147
+
148
+ /** One edge between two anchor points. */
149
+ export function boardEdgePath(a: BoardPoint, b: BoardPoint, style: BoardEdgeStyle): string {
150
+ return `M ${r1(a.x)} ${r1(a.y)} ${segmentTail(a, b, style)}`;
151
+ }
152
+
153
+ /**
154
+ * One edge routed THROUGH via points (the centers of empty corner/junction
155
+ * nodes). The style applies to every leg; orthogonal legs chain into clean
156
+ * right angles, straight legs chain into a polyline, bezier legs chain
157
+ * with smooth midpoints so long vias read as one flowing curve.
158
+ */
159
+ export function boardViaPath(
160
+ a: BoardPoint,
161
+ via: BoardPoint[],
162
+ b: BoardPoint,
163
+ style: BoardEdgeStyle,
164
+ ): string {
165
+ const pts = [a, ...via, b];
166
+ if (pts.length < 2) return "";
167
+ if (style === "bezier" && pts.length > 2) {
168
+ // Smooth chain: cubic legs with tangents from the neighbor midpoints.
169
+ let d = `M ${r1(pts[0].x)} ${r1(pts[0].y)}`;
170
+ for (let i = 1; i < pts.length; i++) {
171
+ const prev = pts[i - 1];
172
+ const cur = pts[i];
173
+ const next = pts[Math.min(i + 1, pts.length - 1)];
174
+ const t1 = i === 1 ? prev : { x: (prev.x + cur.x) / 2, y: (prev.y + cur.y) / 2 };
175
+ const t2 = i === pts.length - 1 ? cur : { x: (cur.x + next.x) / 2, y: (cur.y + next.y) / 2 };
176
+ d += ` C ${r1(t1.x)} ${r1(t1.y)}, ${r1(t2.x)} ${r1(t2.y)}, ${r1(cur.x)} ${r1(cur.y)}`;
177
+ }
178
+ return d;
179
+ }
180
+ let d = `M ${r1(pts[0].x)} ${r1(pts[0].y)}`;
181
+ for (let i = 1; i < pts.length; i++) {
182
+ d += ` ${segmentTail(pts[i - 1], pts[i], style)}`;
183
+ }
184
+ return d;
185
+ }