@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 +1 -1
- package/src/components/HkBoard.scss +83 -0
- package/src/components/HkBoard.tsx +396 -0
- package/src/composables/useBoardCamera.ts +218 -0
- package/src/index.ts +4 -0
- package/src/utils/boardCamera.test.ts +122 -0
- package/src/utils/boardCamera.ts +194 -0
- package/src/utils/boardEdges.test.ts +86 -0
- package/src/utils/boardEdges.ts +185 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|