@celestia-island/hikari 0.35.0 → 0.36.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/HkAuthCard.test.tsx +259 -0
- package/src/components/HkAuthCard.tsx +152 -26
- package/src/components/HkBoard.tsx +101 -32
- package/src/components/HkIconButton.scss +0 -1
- package/src/components/HkMinimap.scss +4 -0
- package/src/composables/useBoardCamera.ts +28 -11
- package/src/styles/admin-tokens.scss +24 -0
- package/src/theme/tokenGroups.test.ts +7 -2
- package/src/theme/useTheme.test.ts +67 -0
- package/src/theme/useTheme.ts +71 -3
- package/src/utils/boardCamera.test.ts +55 -0
- package/src/utils/boardCamera.ts +28 -7
- package/src/utils/boardEdges.test.ts +34 -0
- package/src/utils/boardEdges.ts +23 -0
package/package.json
CHANGED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createApp, h } from "vue";
|
|
3
|
+
|
|
4
|
+
import { HkAuthCard } from "./HkAuthCard";
|
|
5
|
+
|
|
6
|
+
/** Injectable ResizeObserver: captures the callback so tests can fire
|
|
7
|
+
* content changes deterministically (happy-dom's own RO never fires —
|
|
8
|
+
* there is no layout engine). Same harness as useSizeMorph.test.ts. */
|
|
9
|
+
class FakeResizeObserver {
|
|
10
|
+
static instances: FakeResizeObserver[] = [];
|
|
11
|
+
callback: () => void;
|
|
12
|
+
observed: Element[] = [];
|
|
13
|
+
disconnected = false;
|
|
14
|
+
|
|
15
|
+
constructor(callback: () => void) {
|
|
16
|
+
this.callback = callback;
|
|
17
|
+
FakeResizeObserver.instances.push(this);
|
|
18
|
+
}
|
|
19
|
+
observe(el: Element): void {
|
|
20
|
+
this.observed.push(el);
|
|
21
|
+
}
|
|
22
|
+
disconnect(): void {
|
|
23
|
+
this.disconnected = true;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const originalRO = globalThis.ResizeObserver;
|
|
28
|
+
|
|
29
|
+
/** The prototype that actually defines `offsetHeight` in this DOM
|
|
30
|
+
* implementation, so the stub below is portable across environments. */
|
|
31
|
+
function offsetHeightOwner(): object {
|
|
32
|
+
let proto = Object.getPrototypeOf(document.createElement("div")) as object | null;
|
|
33
|
+
while (proto) {
|
|
34
|
+
if (Object.getOwnPropertyDescriptor(proto, "offsetHeight")) return proto;
|
|
35
|
+
proto = Object.getPrototypeOf(proto);
|
|
36
|
+
}
|
|
37
|
+
return HTMLElement.prototype;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
|
41
|
+
offsetHeightOwner(),
|
|
42
|
+
"offsetHeight",
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** Natural height every element reports for `offsetHeight` in tests. */
|
|
46
|
+
let naturalHeight = 0;
|
|
47
|
+
|
|
48
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
49
|
+
|
|
50
|
+
function mount(node: ReturnType<typeof h>) {
|
|
51
|
+
const container = document.createElement("div");
|
|
52
|
+
document.body.appendChild(container);
|
|
53
|
+
const app = createApp({ render: () => node });
|
|
54
|
+
app.mount(container);
|
|
55
|
+
mounts.push({ app, container });
|
|
56
|
+
return container;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function cardOf(c: HTMLElement) {
|
|
60
|
+
return c.querySelector<HTMLElement>(".s-auth-card")!;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function wrapperOf(c: HTMLElement) {
|
|
64
|
+
return c.querySelector<HTMLElement>(".s-auth-card-height")!;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fireTransitionEnd(el: HTMLElement, propertyName: string) {
|
|
68
|
+
const event = new Event("transitionend", { bubbles: true });
|
|
69
|
+
Object.defineProperty(event, "propertyName", { value: propertyName });
|
|
70
|
+
el.dispatchEvent(event);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
FakeResizeObserver.instances = [];
|
|
75
|
+
naturalHeight = 140;
|
|
76
|
+
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver;
|
|
77
|
+
Object.defineProperty(offsetHeightOwner(), "offsetHeight", {
|
|
78
|
+
configurable: true,
|
|
79
|
+
get: () => naturalHeight,
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
globalThis.ResizeObserver = originalRO;
|
|
85
|
+
if (originalOffsetHeight) {
|
|
86
|
+
Object.defineProperty(offsetHeightOwner(), "offsetHeight", originalOffsetHeight);
|
|
87
|
+
} else {
|
|
88
|
+
delete (offsetHeightOwner() as { offsetHeight?: number }).offsetHeight;
|
|
89
|
+
}
|
|
90
|
+
vi.restoreAllMocks();
|
|
91
|
+
vi.useRealTimers();
|
|
92
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
93
|
+
app.unmount();
|
|
94
|
+
container.remove();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("HkAuthCard", () => {
|
|
99
|
+
it("renders title, subtitle and every slot inside the unchanged card shell", () => {
|
|
100
|
+
const c = mount(
|
|
101
|
+
h(HkAuthCard, { title: "Sign in", subtitle: "Welcome back" }, {
|
|
102
|
+
default: () => h("input", { name: "probe-field" }),
|
|
103
|
+
methods: () => h("div", { class: "probe-methods" }, "methods"),
|
|
104
|
+
footer: () => h("div", { class: "probe-footer" }, "footer"),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
// The measuring wrapper is present and the card is still `.s-auth-card`.
|
|
108
|
+
const wrapper = wrapperOf(c);
|
|
109
|
+
const card = cardOf(c);
|
|
110
|
+
expect(wrapper).toBeTruthy();
|
|
111
|
+
expect(card).toBeTruthy();
|
|
112
|
+
expect(c.querySelector(".s-auth-title")?.textContent).toBe("Sign in");
|
|
113
|
+
expect(c.querySelector(".s-auth-subtitle")?.textContent).toBe("Welcome back");
|
|
114
|
+
expect(card.querySelector(".s-auth-form input[name='probe-field']")).toBeTruthy();
|
|
115
|
+
expect(card.querySelector(".s-auth-methods .probe-methods")).toBeTruthy();
|
|
116
|
+
expect(card.querySelector(".s-auth-footer .probe-footer")).toBeTruthy();
|
|
117
|
+
// The wrapper is transparent to descendant styling: the header, form,
|
|
118
|
+
// methods and footer blocks all remain children of `.s-auth-card`.
|
|
119
|
+
expect(wrapper.querySelector(".s-auth-card")).toBe(card);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("seeds the wrapper height from the card on mount without animating", () => {
|
|
123
|
+
naturalHeight = 140;
|
|
124
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
125
|
+
const wrapper = wrapperOf(c);
|
|
126
|
+
expect(wrapper.style.height).toBe("140px");
|
|
127
|
+
// Seeding must not animate: the measuring class is absent on first paint.
|
|
128
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
129
|
+
// The observer watches the card element itself, not the wrapper.
|
|
130
|
+
expect(FakeResizeObserver.instances).toHaveLength(1);
|
|
131
|
+
expect(FakeResizeObserver.instances[0]!.observed).toEqual([cardOf(c)]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("animates growth through the measuring class and releases it on the backstop timer", () => {
|
|
135
|
+
vi.useFakeTimers();
|
|
136
|
+
naturalHeight = 140;
|
|
137
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
138
|
+
const wrapper = wrapperOf(c);
|
|
139
|
+
expect(wrapper.style.height).toBe("140px");
|
|
140
|
+
|
|
141
|
+
naturalHeight = 240;
|
|
142
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
143
|
+
expect(wrapper.style.height).toBe("240px");
|
|
144
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
145
|
+
|
|
146
|
+
vi.advanceTimersByTime(449);
|
|
147
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
148
|
+
vi.advanceTimersByTime(1);
|
|
149
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("releases the measuring class when the height transition finishes", () => {
|
|
153
|
+
naturalHeight = 100;
|
|
154
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
155
|
+
const wrapper = wrapperOf(c);
|
|
156
|
+
|
|
157
|
+
naturalHeight = 180;
|
|
158
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
159
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
160
|
+
|
|
161
|
+
// An unrelated property finishing its transition must not release.
|
|
162
|
+
fireTransitionEnd(wrapper, "color");
|
|
163
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
164
|
+
|
|
165
|
+
fireTransitionEnd(wrapper, "height");
|
|
166
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("ignores height transitionsend events bubbling up from descendant content", () => {
|
|
170
|
+
naturalHeight = 100;
|
|
171
|
+
const c = mount(
|
|
172
|
+
h(HkAuthCard, { title: "T" }, { default: () => h("div", { class: "probe-anim" }) }),
|
|
173
|
+
);
|
|
174
|
+
const wrapper = wrapperOf(c);
|
|
175
|
+
const child = c.querySelector<HTMLElement>(".probe-anim")!;
|
|
176
|
+
|
|
177
|
+
naturalHeight = 180;
|
|
178
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
179
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
180
|
+
|
|
181
|
+
// A descendant slot element's own height transition bubbling up must
|
|
182
|
+
// NOT release the clip — it did not open this measuring window.
|
|
183
|
+
fireTransitionEnd(child, "height");
|
|
184
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(true);
|
|
185
|
+
|
|
186
|
+
// Only the wrapper's own height transition may end it.
|
|
187
|
+
fireTransitionEnd(wrapper, "height");
|
|
188
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("snaps unclipped under prefers-reduced-motion without arming the backstop", () => {
|
|
192
|
+
vi.useFakeTimers();
|
|
193
|
+
vi.spyOn(window, "matchMedia").mockReturnValue({ matches: true } as unknown as MediaQueryList);
|
|
194
|
+
naturalHeight = 140;
|
|
195
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
196
|
+
const wrapper = wrapperOf(c);
|
|
197
|
+
|
|
198
|
+
naturalHeight = 240;
|
|
199
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
200
|
+
// Height still tracks the content…
|
|
201
|
+
expect(wrapper.style.height).toBe("240px");
|
|
202
|
+
// …but with the transition disabled there is nothing to animate, so
|
|
203
|
+
// no measuring window opens: no clip, no pending backstop timer.
|
|
204
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
205
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("ignores observer firings that move the height by less than a pixel", () => {
|
|
209
|
+
naturalHeight = 140;
|
|
210
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
211
|
+
const wrapper = wrapperOf(c);
|
|
212
|
+
|
|
213
|
+
naturalHeight = 140.6;
|
|
214
|
+
FakeResizeObserver.instances[0]!.callback();
|
|
215
|
+
expect(wrapper.style.height).toBe("140px");
|
|
216
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("with smoothHeight=false renders the wrapper but installs no measuring", () => {
|
|
220
|
+
naturalHeight = 140;
|
|
221
|
+
const c = mount(h(HkAuthCard, { title: "T", smoothHeight: false }));
|
|
222
|
+
const wrapper = wrapperOf(c);
|
|
223
|
+
// The wrapper still exists (stable DOM shape for consumers) but is
|
|
224
|
+
// completely unmanaged: no explicit height and no observer.
|
|
225
|
+
expect(wrapper).toBeTruthy();
|
|
226
|
+
expect(wrapper.style.height).toBe("");
|
|
227
|
+
expect(FakeResizeObserver.instances).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("degrades to an unmanaged wrapper when ResizeObserver is unavailable", () => {
|
|
231
|
+
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = undefined;
|
|
232
|
+
naturalHeight = 140;
|
|
233
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
234
|
+
const wrapper = wrapperOf(c);
|
|
235
|
+
expect(wrapper).toBeTruthy();
|
|
236
|
+
expect(wrapper.style.height).toBe("");
|
|
237
|
+
expect(wrapper.classList.contains("s-auth-card--measuring")).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("disconnects the observer and clears the backstop on unmount", () => {
|
|
241
|
+
vi.useFakeTimers();
|
|
242
|
+
naturalHeight = 140;
|
|
243
|
+
const c = mount(h(HkAuthCard, { title: "T" }));
|
|
244
|
+
const ro = FakeResizeObserver.instances[0]!;
|
|
245
|
+
|
|
246
|
+
naturalHeight = 200;
|
|
247
|
+
ro.callback();
|
|
248
|
+
expect(wrapperOf(c).classList.contains("s-auth-card--measuring")).toBe(true);
|
|
249
|
+
|
|
250
|
+
// Pop the entry so the shared afterEach does not unmount it twice.
|
|
251
|
+
const entry = mounts.pop()!;
|
|
252
|
+
entry.app.unmount();
|
|
253
|
+
expect(ro.disconnected).toBe(true);
|
|
254
|
+
// The pending backstop timer was cleared on unmount: nothing is left
|
|
255
|
+
// scheduled (a leaked timer here would also fire releaseClip on a
|
|
256
|
+
// dead element).
|
|
257
|
+
expect(vi.getTimerCount()).toBe(0);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import { defineComponent,
|
|
1
|
+
import { defineComponent, onBeforeUnmount, onMounted, ref } from "vue";
|
|
2
|
+
|
|
3
|
+
/** Grace window that force-releases the `--measuring` clip if the height
|
|
4
|
+
* `transitionend` never arrives (interrupted transition, backgrounded
|
|
5
|
+
* tab, reduced-motion). Slightly longer than the 0.3s CSS height
|
|
6
|
+
* transition so a normal completion is always released by the event. */
|
|
7
|
+
const MEASURE_BACKSTOP_MS = 450;
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* HkAuthCard — the shared shell of every Celestia auth screen (login,
|
|
@@ -19,42 +25,162 @@ import { defineComponent, type PropType } from "vue";
|
|
|
19
25
|
* per-row centering; content that must sit on one row belongs inside
|
|
20
26
|
* one child element.
|
|
21
27
|
* - `logo` swaps the header's logo slot; `default` is the form body.
|
|
28
|
+
*
|
|
29
|
+
* Smooth height contract:
|
|
30
|
+
* The card's content is consumer-driven and changes at runtime — channel
|
|
31
|
+
* tabs swap the OAuth `methods` list (3 buttons ↔ 1), validation banners
|
|
32
|
+
* appear, locale switches reflow labels — and the card's height would
|
|
33
|
+
* otherwise snap between the two layouts. The root card is therefore
|
|
34
|
+
* wrapped in a `.s-auth-card-height` measuring wrapper that owns an
|
|
35
|
+
* explicit `height` and animates toward whatever natural height the card
|
|
36
|
+
* grows or shrinks to:
|
|
37
|
+
*
|
|
38
|
+
* - On mount the wrapper seeds its `height` from the card's current
|
|
39
|
+
* `offsetHeight` while the animating state is impossible, so the first
|
|
40
|
+
* paint never animates.
|
|
41
|
+
* - A `ResizeObserver` on the card element (not the wrapper) fires when
|
|
42
|
+
* the content-driven natural height changes. Changes smaller than 1px
|
|
43
|
+
* are ignored to keep rounding noise from starting a cycle. A real
|
|
44
|
+
* change adds the `s-auth-card--measuring` class — the ONLY place
|
|
45
|
+
* `overflow: hidden` is applied — then rewrites the height, and the CSS
|
|
46
|
+
* `height` transition animates the wrapper between the two sizes.
|
|
47
|
+
* - The clipping class is released by the wrapper's `transitionend`
|
|
48
|
+
* (propertyName === "height") and, if that event never fires, by a
|
|
49
|
+
* 450ms backstop timer. Between animations the card carries no clip,
|
|
50
|
+
* so its shadow, focus rings and popovers are never cut off at rest.
|
|
51
|
+
* - `smoothHeight: false` opts out entirely: the wrapper still renders
|
|
52
|
+
* (the DOM shape stays stable for consumers) but no height style, no
|
|
53
|
+
* observer and no listeners are installed, and height changes snap as
|
|
54
|
+
* before. The prop is read ONCE at mount time — runtime toggles are
|
|
55
|
+
* ignored, making it a static opt-out rather than a reactive switch
|
|
56
|
+
* (remount with a changed `:key` to apply a new value).
|
|
57
|
+
* - Environments without `ResizeObserver` degrade the same way: instant
|
|
58
|
+
* snap, no measurement, no clipping.
|
|
59
|
+
* - `prefers-reduced-motion: reduce` disables the transition in CSS, so
|
|
60
|
+
* the height still tracks the content, just without animation. While
|
|
61
|
+
* it matches, the observer updates the height directly WITHOUT opening
|
|
62
|
+
* a measuring window — no clip and no backstop timer — because a clip
|
|
63
|
+
* held for the backstop would cut off the card's shadow with nothing
|
|
64
|
+
* animating to mask it.
|
|
65
|
+
*
|
|
66
|
+
* The wrapper is transparent to descendant styling: nothing inside
|
|
67
|
+
* `.s-auth-card` changes, and no hikari stylesheet targets the card as a
|
|
68
|
+
* direct child. Only consumer styles that select `.s-auth-card` as a
|
|
69
|
+
* DIRECT child of a specific parent must account for the extra wrapper
|
|
70
|
+
* (e.g. `.us-main > .s-auth-card` becomes
|
|
71
|
+
* `.us-main > .s-auth-card-height > .s-auth-card`).
|
|
22
72
|
*/
|
|
23
73
|
export const HkAuthCard = defineComponent({
|
|
24
74
|
name: "HkAuthCard",
|
|
25
75
|
props: {
|
|
26
76
|
title: { type: String, required: true },
|
|
27
77
|
subtitle: { type: String, default: "" },
|
|
78
|
+
smoothHeight: { type: Boolean, default: true },
|
|
28
79
|
},
|
|
29
80
|
setup(props, { slots }) {
|
|
81
|
+
const heightEl = ref<HTMLElement | null>(null);
|
|
82
|
+
const cardEl = ref<HTMLElement | null>(null);
|
|
83
|
+
|
|
84
|
+
let observer: ResizeObserver | null = null;
|
|
85
|
+
let backstopTimer: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
+
|
|
87
|
+
/** End the current measuring window: drop the clip and the backstop. */
|
|
88
|
+
const releaseClip = () => {
|
|
89
|
+
if (backstopTimer !== null) {
|
|
90
|
+
clearTimeout(backstopTimer);
|
|
91
|
+
backstopTimer = null;
|
|
92
|
+
}
|
|
93
|
+
heightEl.value?.classList.remove("s-auth-card--measuring");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const onTransitionEnd = (event: TransitionEvent) => {
|
|
97
|
+
// Only the wrapper's OWN height transition may release the clip —
|
|
98
|
+
// a descendant slot element's height transition bubbling up must
|
|
99
|
+
// not end a measuring window it did not open.
|
|
100
|
+
if (event.target !== heightEl.value) return;
|
|
101
|
+
if (event.propertyName !== "height") return;
|
|
102
|
+
releaseClip();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
onMounted(() => {
|
|
106
|
+
if (!props.smoothHeight) return;
|
|
107
|
+
const wrapper = heightEl.value;
|
|
108
|
+
const card = cardEl.value;
|
|
109
|
+
if (!wrapper || !card || typeof ResizeObserver === "undefined") return;
|
|
110
|
+
|
|
111
|
+
// Seed the explicit height BEFORE any animating class exists, so
|
|
112
|
+
// the first paint (wrapper already at the natural size) never
|
|
113
|
+
// animates.
|
|
114
|
+
wrapper.style.height = `${card.offsetHeight}px`;
|
|
115
|
+
wrapper.addEventListener("transitionend", onTransitionEnd);
|
|
116
|
+
|
|
117
|
+
observer = new ResizeObserver(() => {
|
|
118
|
+
const next = card.offsetHeight;
|
|
119
|
+
const current = parseFloat(wrapper.style.height) || 0;
|
|
120
|
+
// Ignore sub-pixel churn — rounding noise must not start a
|
|
121
|
+
// clip-and-animate cycle.
|
|
122
|
+
if (Math.abs(next - current) < 1) return;
|
|
123
|
+
|
|
124
|
+
// Reduced motion: the CSS transition is `none`, so transitionend
|
|
125
|
+
// will never fire and the clip would just sit there for the
|
|
126
|
+
// backstop window, cutting off the shadow with nothing to mask.
|
|
127
|
+
// Snap unclipped instead — no class, no timer.
|
|
128
|
+
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
|
129
|
+
wrapper.style.height = `${next}px`;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Clip ONLY while a height transition is actually running; the
|
|
134
|
+
// class is released by transitionend or the backstop timer.
|
|
135
|
+
wrapper.classList.add("s-auth-card--measuring");
|
|
136
|
+
wrapper.style.height = `${next}px`;
|
|
137
|
+
|
|
138
|
+
if (backstopTimer !== null) clearTimeout(backstopTimer);
|
|
139
|
+
backstopTimer = setTimeout(releaseClip, MEASURE_BACKSTOP_MS);
|
|
140
|
+
});
|
|
141
|
+
observer.observe(card);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
onBeforeUnmount(() => {
|
|
145
|
+
observer?.disconnect();
|
|
146
|
+
observer = null;
|
|
147
|
+
heightEl.value?.removeEventListener("transitionend", onTransitionEnd);
|
|
148
|
+
if (backstopTimer !== null) {
|
|
149
|
+
clearTimeout(backstopTimer);
|
|
150
|
+
backstopTimer = null;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
30
154
|
return () => (
|
|
31
|
-
<div class="s-auth-card">
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<
|
|
42
|
-
|
|
155
|
+
<div class="s-auth-card-height" ref={heightEl}>
|
|
156
|
+
<div class="s-auth-card" ref={cardEl}>
|
|
157
|
+
{slots.logo && (
|
|
158
|
+
<div class="s-auth-header">
|
|
159
|
+
{slots.logo()}
|
|
160
|
+
<h1 class="s-auth-title">{props.title}</h1>
|
|
161
|
+
{props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
{!slots.logo && (
|
|
165
|
+
<div class="s-auth-header">
|
|
166
|
+
<h1 class="s-auth-title">{props.title}</h1>
|
|
167
|
+
{props.subtitle && <p class="s-auth-subtitle">{props.subtitle}</p>}
|
|
168
|
+
</div>
|
|
169
|
+
)}
|
|
170
|
+
<div class="s-auth-form">
|
|
171
|
+
{slots.default?.()}
|
|
43
172
|
</div>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
173
|
+
{slots.methods && (
|
|
174
|
+
<div class="s-auth-methods">
|
|
175
|
+
{slots.methods()}
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
{slots.footer && (
|
|
179
|
+
<div class="s-auth-footer">
|
|
180
|
+
{slots.footer()}
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
47
183
|
</div>
|
|
48
|
-
{slots.methods && (
|
|
49
|
-
<div class="s-auth-methods">
|
|
50
|
-
{slots.methods()}
|
|
51
|
-
</div>
|
|
52
|
-
)}
|
|
53
|
-
{slots.footer && (
|
|
54
|
-
<div class="s-auth-footer">
|
|
55
|
-
{slots.footer()}
|
|
56
|
-
</div>
|
|
57
|
-
)}
|
|
58
184
|
</div>
|
|
59
185
|
);
|
|
60
186
|
},
|
|
@@ -44,6 +44,7 @@ import { useBoardCamera } from "../composables/useBoardCamera";
|
|
|
44
44
|
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
45
45
|
import {
|
|
46
46
|
boardBBox,
|
|
47
|
+
boardStepRung,
|
|
47
48
|
type BoardCamera,
|
|
48
49
|
type BoardPoint,
|
|
49
50
|
type BoardRect,
|
|
@@ -52,13 +53,19 @@ import {
|
|
|
52
53
|
import {
|
|
53
54
|
boardAnchor,
|
|
54
55
|
boardEdgePath,
|
|
56
|
+
boardFanOrdinals,
|
|
55
57
|
boardViaPath,
|
|
56
58
|
type BoardAnchorMode,
|
|
57
59
|
type BoardEdgeStyle,
|
|
58
60
|
} from "../utils/boardEdges";
|
|
59
61
|
import "./HkBoard.scss";
|
|
60
62
|
|
|
61
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* A node on the board. `hidden` nodes are junction-only (invisible).
|
|
65
|
+
* During a node drag the board mutates the node objects IN PLACE and
|
|
66
|
+
* emits the live reference through `node-move` / `nodeClick` — pass
|
|
67
|
+
* deep-reactive, mutable node objects, not frozen copies.
|
|
68
|
+
*/
|
|
62
69
|
export interface BoardNodeInput {
|
|
63
70
|
id: string;
|
|
64
71
|
x: number;
|
|
@@ -138,16 +145,7 @@ export default defineComponent({
|
|
|
138
145
|
});
|
|
139
146
|
|
|
140
147
|
/** 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
|
-
});
|
|
148
|
+
const fanOrdinal = computed(() => boardFanOrdinals(props.edges));
|
|
151
149
|
|
|
152
150
|
const edgePaths = computed(() => {
|
|
153
151
|
const rects = rectOf.value;
|
|
@@ -209,6 +207,9 @@ export default defineComponent({
|
|
|
209
207
|
let panState: { px: number; py: number } | null = null;
|
|
210
208
|
let pinchBase: { cam: BoardCamera; dist: number; mid: BoardPoint } | null = null;
|
|
211
209
|
let dragState: { node: BoardNodeInput; sx: number; sy: number; ox: number; oy: number; moved: boolean } | null = null;
|
|
210
|
+
// Every node press is tracked (draggable or not) so read-only boards
|
|
211
|
+
// — SCADA scenes, mind maps — still get `nodeClick` on a short tap.
|
|
212
|
+
let pressed: { id: string; pointerId: number; x: number; y: number } | null = null;
|
|
212
213
|
|
|
213
214
|
const localPoint = (e: PointerEvent | WheelEvent): BoardPoint => {
|
|
214
215
|
const rect = viewportRef.value?.getBoundingClientRect();
|
|
@@ -230,23 +231,39 @@ export default defineComponent({
|
|
|
230
231
|
|
|
231
232
|
let resizeObs: ResizeObserver | null = null;
|
|
232
233
|
|
|
234
|
+
/** Snapshot the camera + current finger pair as the pinch baseline —
|
|
235
|
+
* at gesture start, or whenever the pair's composition changes — so
|
|
236
|
+
* the scale keeps following the fingers 1:1 without a jump. */
|
|
237
|
+
function rearmPinch(): void {
|
|
238
|
+
const [a, b] = [...pointers.values()];
|
|
239
|
+
if (!a || !b) return;
|
|
240
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
241
|
+
pinchBase = {
|
|
242
|
+
cam: { ...camera.value },
|
|
243
|
+
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
|
|
244
|
+
mid: {
|
|
245
|
+
x: (a.x + b.x) / 2 - (rect?.left ?? 0),
|
|
246
|
+
y: (a.y + b.y) / 2 - (rect?.top ?? 0),
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
233
251
|
function onPointerDown(e: PointerEvent): void {
|
|
234
252
|
if (!props.interactive) return;
|
|
253
|
+
// Defensive prune: tracked pointers no gesture is using are stale
|
|
254
|
+
// (their up/cancel AND capture loss were both lost) — drop them so
|
|
255
|
+
// the next single-finger touch cannot become a phantom pinch.
|
|
256
|
+
if (pointers.size > 0 && !pinchBase && !panState && !dragState) pointers.clear();
|
|
235
257
|
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
}
|
|
258
|
+
// Capture EVERY finger: up/cancel are only guaranteed while capture
|
|
259
|
+
// holds, and an uncaptured pinch finger would never leave `pointers`.
|
|
260
|
+
viewportRef.value?.setPointerCapture(e.pointerId);
|
|
247
261
|
if (pointers.size === 1) {
|
|
248
262
|
panState = { px: e.clientX, py: e.clientY };
|
|
249
|
-
|
|
263
|
+
} else if (pointers.size === 2) {
|
|
264
|
+
// pinch begins: snapshot camera + finger geometry, suspend panning
|
|
265
|
+
rearmPinch();
|
|
266
|
+
panState = null;
|
|
250
267
|
}
|
|
251
268
|
}
|
|
252
269
|
|
|
@@ -256,10 +273,12 @@ export default defineComponent({
|
|
|
256
273
|
if (pinchBase && pointers.size >= 2) {
|
|
257
274
|
const [a, b] = [...pointers.values()];
|
|
258
275
|
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
|
|
276
|
+
const rect = viewportRef.value?.getBoundingClientRect();
|
|
259
277
|
const mid = {
|
|
260
|
-
x: (a.x + b.x) / 2 - (
|
|
261
|
-
y: (a.y + b.y) / 2 - (
|
|
278
|
+
x: (a.x + b.x) / 2 - (rect?.left ?? 0),
|
|
279
|
+
y: (a.y + b.y) / 2 - (rect?.top ?? 0),
|
|
262
280
|
};
|
|
281
|
+
pinchBase.mid = mid;
|
|
263
282
|
cam.pinchZoom(pinchBase.cam, dist / pinchBase.dist, mid);
|
|
264
283
|
return;
|
|
265
284
|
}
|
|
@@ -280,17 +299,62 @@ export default defineComponent({
|
|
|
280
299
|
}
|
|
281
300
|
|
|
282
301
|
function onPointerUp(e: PointerEvent): void {
|
|
283
|
-
|
|
284
|
-
|
|
302
|
+
const press = pressed;
|
|
303
|
+
pressed = null;
|
|
304
|
+
dragState = null;
|
|
305
|
+
retirePointer(e.pointerId);
|
|
306
|
+
// nodeClick: a press that belongs to THIS pointer and stayed within
|
|
307
|
+
// the drag slop clicks — regardless of `draggable`.
|
|
308
|
+
if (press && press.pointerId === e.pointerId
|
|
309
|
+
&& Math.abs(e.clientX - press.x) + Math.abs(e.clientY - press.y) < 3) {
|
|
310
|
+
const node = props.nodes.find((n) => n.id === press.id);
|
|
311
|
+
if (node) emit("nodeClick", node);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function onPointerCancel(e: PointerEvent): void {
|
|
316
|
+
// An aborted gesture must never synthesize a click: prune state only.
|
|
317
|
+
pressed = null;
|
|
318
|
+
dragState = null;
|
|
319
|
+
retirePointer(e.pointerId);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A pointer left the gesture (up, cancel, or capture loss): prune it,
|
|
323
|
+
* re-baseline or settle the pinch, and hand panning to any surviving
|
|
324
|
+
* pointer so a pinch that ends with one finger down keeps panning. */
|
|
325
|
+
function retirePointer(pointerId: number): void {
|
|
326
|
+
if (!pointers.delete(pointerId)) return;
|
|
327
|
+
if (pinchBase && pointers.size >= 2) {
|
|
328
|
+
// The pinch pair composition changed (a finger lifted while two
|
|
329
|
+
// or more remain): re-baseline onto the surviving pair.
|
|
330
|
+
rearmPinch();
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (pinchBase) {
|
|
334
|
+
const mid = pinchBase.mid;
|
|
285
335
|
pinchBase = null;
|
|
286
|
-
cam.settlePinch();
|
|
336
|
+
cam.settlePinch(mid);
|
|
287
337
|
}
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
const rest = pointers.values().next().value;
|
|
339
|
+
panState = rest ? { px: rest.x, py: rest.y } : null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Capture-loss backstop: up/cancel are only guaranteed while pointer
|
|
343
|
+
* capture holds; a pointer that was released without an up is pruned
|
|
344
|
+
* here — otherwise the next single-finger touch would silently become
|
|
345
|
+
* a phantom pinch. The event fires on the capture target and does not
|
|
346
|
+
* bubble, hence the capture-phase listener; after a normal up it is a
|
|
347
|
+
* no-op (the pointer was already pruned there). */
|
|
348
|
+
function onLostPointerCapture(e: PointerEvent): void {
|
|
349
|
+
pressed = null;
|
|
290
350
|
dragState = null;
|
|
351
|
+
retirePointer(e.pointerId);
|
|
291
352
|
}
|
|
292
353
|
|
|
293
354
|
function onNodePointerDown(e: PointerEvent, node: BoardNodeInput): void {
|
|
355
|
+
pressed = { id: node.id, pointerId: e.pointerId, x: e.clientX, y: e.clientY };
|
|
356
|
+
// Draggable nodes own the gesture (no board pan); every other press
|
|
357
|
+
// bubbles on so the board can pan — and still click on a short tap.
|
|
294
358
|
if (!props.interactive || !node.draggable) return;
|
|
295
359
|
e.stopPropagation();
|
|
296
360
|
dragState = { node, sx: e.clientX, sy: e.clientY, ox: node.x, oy: node.y, moved: false };
|
|
@@ -308,12 +372,17 @@ export default defineComponent({
|
|
|
308
372
|
resizeObs = new ResizeObserver(refreshSize);
|
|
309
373
|
if (viewportRef.value) resizeObs.observe(viewportRef.value);
|
|
310
374
|
viewportRef.value?.addEventListener("wheel", onWheel, { passive: false });
|
|
375
|
+
// Capture phase: lostpointercapture does not bubble and fires on the
|
|
376
|
+
// element that held capture.
|
|
377
|
+
viewportRef.value?.addEventListener("lostpointercapture", onLostPointerCapture, true);
|
|
311
378
|
if (props.fitOnMount) cam.fit();
|
|
312
379
|
});
|
|
313
380
|
|
|
314
381
|
onBeforeUnmount(() => {
|
|
382
|
+
cam.stop(); // kill any in-flight tween rAF
|
|
315
383
|
resizeObs?.disconnect();
|
|
316
384
|
viewportRef.value?.removeEventListener("wheel", onWheel);
|
|
385
|
+
viewportRef.value?.removeEventListener("lostpointercapture", onLostPointerCapture, true);
|
|
317
386
|
});
|
|
318
387
|
|
|
319
388
|
expose({
|
|
@@ -332,7 +401,7 @@ export default defineComponent({
|
|
|
332
401
|
onPointerdown={onPointerDown}
|
|
333
402
|
onPointermove={onPointerMove}
|
|
334
403
|
onPointerup={onPointerUp}
|
|
335
|
-
onPointercancel={
|
|
404
|
+
onPointercancel={onPointerCancel}
|
|
336
405
|
>
|
|
337
406
|
<div class="hk-board-grid" style={gridStyle.value} />
|
|
338
407
|
<div class="hk-board-world" style={worldStyle.value}>
|
|
@@ -384,7 +453,7 @@ export default defineComponent({
|
|
|
384
453
|
minZoomPercent={Math.round(props.minK * 100)}
|
|
385
454
|
maxZoomPercent={Math.round(props.maxK * 100)}
|
|
386
455
|
showReset
|
|
387
|
-
onZoomTo={(percent: number) => cam.
|
|
456
|
+
onZoomTo={(percent: number) => cam.zoomToK(boardStepRung(camera.value.k, percent / 100))}
|
|
388
457
|
onReset={() => cam.fit()}
|
|
389
458
|
onPanDelta={(dx: number, dy: number) => cam.panBy(dx, dy)}
|
|
390
459
|
/>
|
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
cursor: pointer;
|
|
12
12
|
overflow: hidden;
|
|
13
13
|
pointer-events: auto;
|
|
14
|
+
/* The minimap owns its own drag gestures — the surrounding pannable
|
|
15
|
+
surface must never claim them (mobile browsers would otherwise fire
|
|
16
|
+
pointercancel and kill the drag). */
|
|
17
|
+
touch-action: none;
|
|
14
18
|
|
|
15
19
|
&:hover {
|
|
16
20
|
border-color: rgb(var(--color-primary) / 0.3);
|
|
@@ -63,12 +63,15 @@ export interface UseBoardCameraReturn {
|
|
|
63
63
|
zoomOut: () => void;
|
|
64
64
|
/** Raw (unquantized, unanimated) pinch zoom — call per pointermove. */
|
|
65
65
|
pinchZoom: (base: BoardCamera, ratio: number, anchor: BoardPoint) => void;
|
|
66
|
-
/** Snap the post-pinch camera onto the nearest rung (animated)
|
|
67
|
-
|
|
66
|
+
/** Snap the post-pinch camera onto the nearest rung (animated), keeping
|
|
67
|
+
* the world point under `anchor` (the pinch midpoint) fixed. */
|
|
68
|
+
settlePinch: (anchor?: BoardPoint) => void;
|
|
68
69
|
panBy: (dx: number, dy: number) => void;
|
|
69
70
|
setCamera: (cam: BoardCamera, opts?: { animate?: boolean }) => void;
|
|
70
71
|
fit: () => void;
|
|
71
72
|
reset: () => void;
|
|
73
|
+
/** Cancel any in-flight tween and its pending rAF (call on unmount). */
|
|
74
|
+
stop: () => void;
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraReturn {
|
|
@@ -83,6 +86,12 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
83
86
|
const camera = ref<BoardCamera>({ x: 0, y: 0, k: 1 });
|
|
84
87
|
const isAnimating = ref(false);
|
|
85
88
|
|
|
89
|
+
// fit() may park the camera BELOW the interaction minimum (show the
|
|
90
|
+
// whole board wins over the zoom ladder, as in HkImageViewer) — the
|
|
91
|
+
// clamp floor follows it down so the fitted framing survives; any
|
|
92
|
+
// quantized gesture lands back on the ladder at ≥ minK.
|
|
93
|
+
let kFloor = minK;
|
|
94
|
+
|
|
86
95
|
let raf = 0;
|
|
87
96
|
let tweenFrom: BoardCamera = { x: 0, y: 0, k: 1 };
|
|
88
97
|
let tweenTo: BoardCamera = { x: 0, y: 0, k: 1 };
|
|
@@ -100,7 +109,7 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
100
109
|
const contentRect = (): BoardRect => toValue(content);
|
|
101
110
|
|
|
102
111
|
const clampCam = (cam: BoardCamera): BoardCamera => {
|
|
103
|
-
const k = Math.min(maxK, Math.max(
|
|
112
|
+
const k = Math.min(maxK, Math.max(kFloor, cam.k));
|
|
104
113
|
return boardClampPan({ ...cam, k }, contentRect(), viewport());
|
|
105
114
|
};
|
|
106
115
|
|
|
@@ -135,13 +144,16 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
135
144
|
};
|
|
136
145
|
|
|
137
146
|
function zoomByFactor(factor: number, anchor?: BoardPoint): void {
|
|
138
|
-
|
|
139
|
-
|
|
147
|
+
// Quantize FIRST, then solve the anchored pan for the QUANTIZED k —
|
|
148
|
+
// anchoring on the raw k and swapping in the quantized one afterwards
|
|
149
|
+
// lets the anchored world point jump by the raw/quantized ratio.
|
|
150
|
+
const k = quantizeBoardK(camera.value.k * factor);
|
|
151
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
140
152
|
}
|
|
141
153
|
|
|
142
154
|
function zoomToK(targetK: number, anchor?: BoardPoint): void {
|
|
143
|
-
const
|
|
144
|
-
animateTo(
|
|
155
|
+
const k = quantizeBoardK(targetK);
|
|
156
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
145
157
|
}
|
|
146
158
|
|
|
147
159
|
function zoomToPercent(percent: number, anchor?: BoardPoint): void {
|
|
@@ -162,9 +174,11 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
162
174
|
camera.value = clampCam(boardZoomAt(base, base.k * ratio, anchor));
|
|
163
175
|
}
|
|
164
176
|
|
|
165
|
-
function settlePinch(): void {
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
function settlePinch(anchor?: BoardPoint): void {
|
|
178
|
+
// Same quantize-then-anchor discipline as the wheel path: the pinch
|
|
179
|
+
// midpoint stays pinned to its world point while k snaps onto the rung.
|
|
180
|
+
const k = quantizeBoardK(camera.value.k);
|
|
181
|
+
animateTo(boardZoomAt(camera.value, k, anchorPoint(anchor)));
|
|
168
182
|
}
|
|
169
183
|
|
|
170
184
|
function panBy(dx: number, dy: number): void {
|
|
@@ -181,7 +195,9 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
181
195
|
}
|
|
182
196
|
|
|
183
197
|
function fit(): void {
|
|
184
|
-
|
|
198
|
+
const target = boardFit(contentRect(), viewport());
|
|
199
|
+
kFloor = Math.min(minK, target.k);
|
|
200
|
+
animateTo(target);
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
function reset(): void {
|
|
@@ -214,5 +230,6 @@ export function useBoardCamera(options: UseBoardCameraOptions): UseBoardCameraRe
|
|
|
214
230
|
setCamera,
|
|
215
231
|
fit,
|
|
216
232
|
reset,
|
|
233
|
+
stop: stopTween,
|
|
217
234
|
};
|
|
218
235
|
}
|
|
@@ -527,6 +527,30 @@
|
|
|
527
527
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
528
528
|
}
|
|
529
529
|
|
|
530
|
+
/* Measuring wrapper (HkAuthCard): owns an explicit height so content-
|
|
531
|
+
driven height changes (channel tabs swapping the methods list, error
|
|
532
|
+
banners, …) animate instead of snapping. The transition lives here,
|
|
533
|
+
always on; the `--measuring` clip below is applied by HkAuthCard only
|
|
534
|
+
for the duration of a height transition, so an idle card never clips
|
|
535
|
+
its shadow, focus rings or popovers. */
|
|
536
|
+
.s-auth-card-height {
|
|
537
|
+
/* The wrapper is sized from the card's offsetHeight (border-box); an
|
|
538
|
+
explicit box-sizing keeps that mapping honest on hosts without a
|
|
539
|
+
global border-box reset. */
|
|
540
|
+
box-sizing: border-box;
|
|
541
|
+
transition: height 0.3s var(--ease-out-expo, cubic-bezier(0.19, 1, 0.22, 1));
|
|
542
|
+
|
|
543
|
+
&.s-auth-card--measuring {
|
|
544
|
+
overflow: hidden;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
@media (prefers-reduced-motion: reduce) {
|
|
549
|
+
.s-auth-card-height {
|
|
550
|
+
transition: none;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
530
554
|
.s-auth-card input {
|
|
531
555
|
transition: color calc(infinity * 1s) step-end, background-color calc(infinity * 1s) step-end;
|
|
532
556
|
appearance: none;
|
|
@@ -343,14 +343,19 @@ describe("late registration", () => {
|
|
|
343
343
|
setMode("dark");
|
|
344
344
|
initTheme();
|
|
345
345
|
const el = document.documentElement;
|
|
346
|
+
// Theme vars live in the managed :root block (lean injection), so the
|
|
347
|
+
// html inline style stays clean until the late group lands.
|
|
348
|
+
const block = () => document.head.querySelector("style[data-hikari-theme-vars]")?.textContent ?? "";
|
|
346
349
|
expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
|
|
350
|
+
expect(block()).not.toContain("--late-wires-a");
|
|
347
351
|
|
|
348
352
|
registerTokenGroup(LATE_GROUP);
|
|
349
353
|
await flushMicrotasks();
|
|
350
354
|
|
|
351
355
|
// The injected hook re-applied the current dark theme, so the late
|
|
352
|
-
// group's registry defaults are already
|
|
353
|
-
expect(
|
|
356
|
+
// group's registry defaults are already in the managed block.
|
|
357
|
+
expect(block()).toContain("--late-wires-a:1 2 3");
|
|
358
|
+
expect(el.style.getPropertyValue("--late-wires-a")).toBe("");
|
|
354
359
|
});
|
|
355
360
|
|
|
356
361
|
it("coalesces same-tick registrations into a single re-apply", async () => {
|
|
@@ -70,3 +70,70 @@ describe("useTheme theme clock", () => {
|
|
|
70
70
|
expect(theme.useTheme().geo.value).toEqual({ lat: 1, lng: 2 });
|
|
71
71
|
});
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
describe("useTheme lean cssvar injection", () => {
|
|
75
|
+
let theme: ThemeModule;
|
|
76
|
+
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
vi.resetModules();
|
|
79
|
+
vi.unstubAllGlobals();
|
|
80
|
+
localStorage.clear();
|
|
81
|
+
document.documentElement.style.cssText = "";
|
|
82
|
+
document.documentElement.removeAttribute("data-theme");
|
|
83
|
+
document.documentElement.removeAttribute("data-mode");
|
|
84
|
+
document.head.querySelectorAll("style[data-hikari-theme-vars]").forEach((el) => el.remove());
|
|
85
|
+
vi.stubGlobal("fetch", vi.fn(async () => {
|
|
86
|
+
throw new Error("offline");
|
|
87
|
+
}));
|
|
88
|
+
theme = await import("./useTheme");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
theme.stopThemeClock();
|
|
93
|
+
vi.unstubAllGlobals();
|
|
94
|
+
vi.restoreAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("pickThemeVarDeltas keeps only values differing from the static cascade", () => {
|
|
98
|
+
const vars = {
|
|
99
|
+
"--a": "1 2 3",
|
|
100
|
+
"--b": "4 5 6",
|
|
101
|
+
"--c": "7 8 9",
|
|
102
|
+
"--d": " 10 11 12 ",
|
|
103
|
+
};
|
|
104
|
+
const baseline = {
|
|
105
|
+
"--a": "1 2 3",
|
|
106
|
+
"--b": "4 5 6",
|
|
107
|
+
"--c": "",
|
|
108
|
+
"--d": "10 11 12",
|
|
109
|
+
};
|
|
110
|
+
// Whitespace-only differences collapse (normalization), so only the
|
|
111
|
+
// genuinely absent/divergent values are injected.
|
|
112
|
+
expect(theme.pickThemeVarDeltas(vars, baseline)).toEqual({
|
|
113
|
+
"--c": "7 8 9",
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("writes theme vars into a managed :root style block, never inline", () => {
|
|
118
|
+
theme.initTheme();
|
|
119
|
+
const styleEl = document.head.querySelector("style[data-hikari-theme-vars]");
|
|
120
|
+
expect(styleEl).not.toBeNull();
|
|
121
|
+
// Real browsers compute the static defaults, so the block holds only the
|
|
122
|
+
// true deltas; either way it is a :root block, not an inline attribute.
|
|
123
|
+
expect(styleEl!.textContent).toMatch(/^:root\{/);
|
|
124
|
+
expect(styleEl!.textContent).toContain("--color-primary");
|
|
125
|
+
// The html inline style attribute stays clean — no token vars on it.
|
|
126
|
+
expect(document.documentElement.style.getPropertyValue("--color-primary")).toBe("");
|
|
127
|
+
// The epoch attributes that consumers watch are still written.
|
|
128
|
+
expect(document.documentElement.getAttribute("data-theme")).toBeTruthy();
|
|
129
|
+
expect(document.documentElement.getAttribute("data-mode")).toBeTruthy();
|
|
130
|
+
// Swapping theme keeps ONE managed block (no duplicates per apply) and
|
|
131
|
+
// actually re-applies: the block content changes with the preset.
|
|
132
|
+
const before = document.head.querySelector("style[data-hikari-theme-vars]")!.textContent;
|
|
133
|
+
theme.useTheme().setTheme("nord");
|
|
134
|
+
const blocks = document.head.querySelectorAll("style[data-hikari-theme-vars]");
|
|
135
|
+
expect(blocks).toHaveLength(1);
|
|
136
|
+
expect(blocks[0].textContent).toMatch(/^:root\{/);
|
|
137
|
+
expect(blocks[0].textContent).not.toBe(before);
|
|
138
|
+
});
|
|
139
|
+
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -148,6 +148,76 @@ function resolveEffectiveMode(mode: ThemeMode): "dark" | "light" {
|
|
|
148
148
|
const THEME_TRANSITION_DURATION = 300;
|
|
149
149
|
let transitionTimer: CronHandle | null = null;
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Theme vars live in ONE managed stylesheet block (`:root` overrides)
|
|
153
|
+
* instead of a hundred inline declarations on `<html style="...">`.
|
|
154
|
+
* - Inline style attributes bloat the DOM, show up as giant devtools
|
|
155
|
+
* noise on the root element, defeat caching of the token set and make
|
|
156
|
+
* every style recalc parse the whole attribute again.
|
|
157
|
+
* - A stylesheet block is a single atomic replacement (one recalc for
|
|
158
|
+
* the whole theme apply) and inspectable/serializable.
|
|
159
|
+
* - Only the DELTAS are written: hikari's static stylesheet already
|
|
160
|
+
* seeds :root with the full default token set (including pure
|
|
161
|
+
* derivations like `--hi-color-primary: rgb(var(--color-primary))`),
|
|
162
|
+
* so a token whose value already resolves identically needs no
|
|
163
|
+
* override at all — a default-ish theme injects a handful of vars
|
|
164
|
+
* instead of ~80.
|
|
165
|
+
*/
|
|
166
|
+
const THEME_VARS_STYLE_ATTR = "data-hikari-theme-vars";
|
|
167
|
+
|
|
168
|
+
function normalizeVarValue(value: string): string {
|
|
169
|
+
return value.replace(/\s+/g, " ").trim();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Keep only the vars whose value differs from the static cascade. */
|
|
173
|
+
export function pickThemeVarDeltas(
|
|
174
|
+
vars: Record<string, string>,
|
|
175
|
+
baseline: Record<string, string>,
|
|
176
|
+
): Record<string, string> {
|
|
177
|
+
const deltas: Record<string, string> = {};
|
|
178
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
179
|
+
const normalized = normalizeVarValue(value);
|
|
180
|
+
if (normalizeVarValue(baseline[key] ?? "") !== normalized) {
|
|
181
|
+
deltas[key] = normalized;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return deltas;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function themeVarsStyleElement(): HTMLStyleElement {
|
|
188
|
+
let styleEl = document.head.querySelector<HTMLStyleElement>(
|
|
189
|
+
`style[${THEME_VARS_STYLE_ATTR}]`,
|
|
190
|
+
);
|
|
191
|
+
if (!styleEl) {
|
|
192
|
+
styleEl = document.createElement("style");
|
|
193
|
+
styleEl.setAttribute(THEME_VARS_STYLE_ATTR, "");
|
|
194
|
+
document.head.appendChild(styleEl);
|
|
195
|
+
}
|
|
196
|
+
return styleEl;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function injectThemeVars(el: HTMLElement, vars: Record<string, string>): void {
|
|
200
|
+
const styleEl = themeVarsStyleElement();
|
|
201
|
+
// Measure the cascade WITHOUT our own previous overrides: disable the
|
|
202
|
+
// managed block, read every candidate, re-enable. One recalc serves
|
|
203
|
+
// all reads, and applyTheme is a rare switch-time action.
|
|
204
|
+
const baseline: Record<string, string> = {};
|
|
205
|
+
try {
|
|
206
|
+
styleEl.disabled = true;
|
|
207
|
+
const computed = getComputedStyle(el);
|
|
208
|
+
for (const key of Object.keys(vars)) {
|
|
209
|
+
baseline[key] = computed.getPropertyValue(key);
|
|
210
|
+
}
|
|
211
|
+
} finally {
|
|
212
|
+
styleEl.disabled = false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const deltas = pickThemeVarDeltas(vars, baseline);
|
|
216
|
+
styleEl.textContent = Object.keys(deltas).length > 0
|
|
217
|
+
? `:root{${Object.entries(deltas).map(([key, value]) => `${key}:${value}`).join(";")}}`
|
|
218
|
+
: "";
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
function applyTheme() {
|
|
152
222
|
const el = document.documentElement;
|
|
153
223
|
const all = getAllThemePresets();
|
|
@@ -168,9 +238,7 @@ function applyTheme() {
|
|
|
168
238
|
|
|
169
239
|
invalidateLuminanceCache();
|
|
170
240
|
|
|
171
|
-
|
|
172
|
-
el.style.setProperty(key, value);
|
|
173
|
-
}
|
|
241
|
+
injectThemeVars(el, vars);
|
|
174
242
|
|
|
175
243
|
el.setAttribute("data-theme", currentTheme.value);
|
|
176
244
|
el.setAttribute("data-mode", effectiveMode);
|
|
@@ -2,12 +2,14 @@ import { describe, expect, it } from "vitest";
|
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
BOARD_FIT_CAP,
|
|
5
|
+
BOARD_K_MAX,
|
|
5
6
|
BOARD_ZOOM_FACTOR,
|
|
6
7
|
boardBBox,
|
|
7
8
|
boardClampPan,
|
|
8
9
|
boardEaseOutCubic,
|
|
9
10
|
boardFit,
|
|
10
11
|
boardLevelOf,
|
|
12
|
+
boardStepRung,
|
|
11
13
|
boardToScreen,
|
|
12
14
|
boardTweenCam,
|
|
13
15
|
boardToWorld,
|
|
@@ -51,6 +53,20 @@ describe("boardCamera — anchor zoom", () => {
|
|
|
51
53
|
expect(zoomed.k).toBe(1.5);
|
|
52
54
|
});
|
|
53
55
|
|
|
56
|
+
it("keeps the anchor world point fixed when zooming to a QUANTIZED rung", () => {
|
|
57
|
+
// The settle path (wheel notch / pinch release) must quantize k FIRST
|
|
58
|
+
// and then solve the anchored pan for the quantized k — anchoring on
|
|
59
|
+
// the raw k and swapping the rung in afterwards jumps the world point.
|
|
60
|
+
const cam = { x: 40, y: -20, k: 2.3 };
|
|
61
|
+
const anchor = { x: 300, y: 200 };
|
|
62
|
+
const worldBefore = boardToWorld(cam, anchor.x, anchor.y);
|
|
63
|
+
const settled = boardZoomAt(cam, quantizeBoardK(cam.k), anchor);
|
|
64
|
+
const worldAfter = boardToWorld(settled, anchor.x, anchor.y);
|
|
65
|
+
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 6);
|
|
66
|
+
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 6);
|
|
67
|
+
expect(settled.k).toBe(quantizeBoardK(cam.k));
|
|
68
|
+
});
|
|
69
|
+
|
|
54
70
|
it("round-trips screen→world→screen", () => {
|
|
55
71
|
const cam = { x: -130, y: 88, k: 1.3 };
|
|
56
72
|
const p = boardToScreen(cam, 42, -7);
|
|
@@ -83,6 +99,45 @@ describe("boardCamera — pan clamp + fit", () => {
|
|
|
83
99
|
const tiny = boardFit({ x: 0, y: 0, w: 60, h: 40 }, viewport);
|
|
84
100
|
expect(tiny.k).toBeLessThanOrEqual(BOARD_FIT_CAP);
|
|
85
101
|
});
|
|
102
|
+
|
|
103
|
+
it("shrinks below the interaction minimum to fit very wide boards", () => {
|
|
104
|
+
// 8000 world px in an 800 px viewport: fit k = 720/8000 = 0.09, far
|
|
105
|
+
// below the old 0.2 floor that cropped such boards on open.
|
|
106
|
+
const wide = { x: 0, y: 0, w: 8000, h: 600 };
|
|
107
|
+
const cam = boardFit(wide, viewport);
|
|
108
|
+
expect(cam.k).toBeCloseTo((800 - 80) / 8000, 6);
|
|
109
|
+
expect(cam.k).toBeLessThan(0.2);
|
|
110
|
+
// the whole board lands inside the viewport
|
|
111
|
+
const tl = boardToScreen(cam, 0, 0);
|
|
112
|
+
const br = boardToScreen(cam, 8000, 600);
|
|
113
|
+
expect(tl.x).toBeGreaterThanOrEqual(0);
|
|
114
|
+
expect(br.x).toBeLessThanOrEqual(viewport.w);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("boardCamera — minimap step rungs", () => {
|
|
119
|
+
const lastRung = BOARD_ZOOM_FACTOR ** 28; // ≈3.92 — last rung below the cap
|
|
120
|
+
|
|
121
|
+
it("pushes a linear +5% step that nearest-rounds onto the current rung", () => {
|
|
122
|
+
// from 392%, the + button targets 397% — nearest rounding falls back
|
|
123
|
+
// onto rung 28 (the dead band), so the step resolves one rung further
|
|
124
|
+
// and clamps to the cap.
|
|
125
|
+
expect(quantizeBoardK(3.97)).toBeCloseTo(lastRung, 5);
|
|
126
|
+
expect(boardStepRung(lastRung, 3.97)).toBe(BOARD_K_MAX);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("pushes a linear −5% step onto the rung below, not the current one", () => {
|
|
130
|
+
expect(boardStepRung(lastRung, 3.87)).toBeCloseTo(BOARD_ZOOM_FACTOR ** 27, 5);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("leaves steps that already resolve to a different rung untouched", () => {
|
|
134
|
+
expect(boardStepRung(1, 1.05)).toBeCloseTo(BOARD_ZOOM_FACTOR, 5);
|
|
135
|
+
expect(boardStepRung(1, 0.95)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -1, 5);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("steps from the ladder floor up onto the nearest live rung", () => {
|
|
139
|
+
expect(boardStepRung(0.2, 0.25)).toBeCloseTo(BOARD_ZOOM_FACTOR ** -28, 5);
|
|
140
|
+
});
|
|
86
141
|
});
|
|
87
142
|
|
|
88
143
|
describe("boardCamera — animated tween", () => {
|
package/src/utils/boardCamera.ts
CHANGED
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
* - pan is CLAMPED so the content bbox can never be pushed fully out of
|
|
15
15
|
* view (half a viewport of slack on each side); content smaller than
|
|
16
16
|
* the viewport re-centers;
|
|
17
|
-
* - `boardFit` fits the whole bbox (pad +
|
|
17
|
+
* - `boardFit` fits the whole bbox (pad + cap) and may shrink as far as
|
|
18
|
+
* needed to show the whole board — fit sits below the interaction
|
|
19
|
+
* ladder freely;
|
|
18
20
|
* - `boardTweenCam` eases between two cameras with an ease-out curve and
|
|
19
21
|
* GEOMETRIC k interpolation (log-space lerp) so animated zooms still
|
|
20
22
|
* land exactly on ladder rungs.
|
|
@@ -51,8 +53,6 @@ export interface BoardViewport {
|
|
|
51
53
|
export const BOARD_ZOOM_FACTOR = 1.05;
|
|
52
54
|
export const BOARD_K_MIN = 0.2;
|
|
53
55
|
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
56
|
/** fit() never blows above this (nearly-empty boards). */
|
|
57
57
|
export const BOARD_FIT_CAP = 1.25;
|
|
58
58
|
/** Padding around the bbox used by fit(), world px. */
|
|
@@ -87,6 +87,26 @@ export function quantizeBoardStep(base: number, ratio: number): number {
|
|
|
87
87
|
return boardClampK(base * boardKOf(level));
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Quantize a fixed-step zoom target (the minimap ± button: linear ±5% on
|
|
92
|
+
* a geometric ladder) into a rung, in the step's DIRECTION. Nearest-rung
|
|
93
|
+
* rounding alone can collapse onto the rung the camera already sits on —
|
|
94
|
+
* from the last reachable rung below the cap, a +5% percent target rounds
|
|
95
|
+
* straight back to it and the press does nothing. A target that resolves
|
|
96
|
+
* to the current rung is therefore pushed ONE rung further in the press
|
|
97
|
+
* direction (clamped to the global bounds); targets that already resolve
|
|
98
|
+
* elsewhere are untouched.
|
|
99
|
+
*/
|
|
100
|
+
export function boardStepRung(currentK: number, targetK: number): number {
|
|
101
|
+
const clamped = boardClampK(targetK);
|
|
102
|
+
const rung = quantizeBoardK(clamped);
|
|
103
|
+
const currentLevel = Math.round(boardLevelOf(boardClampK(currentK)));
|
|
104
|
+
const rungLevel = Math.round(boardLevelOf(rung));
|
|
105
|
+
if (rungLevel !== currentLevel) return rung;
|
|
106
|
+
const dir = clamped >= currentK ? 1 : -1;
|
|
107
|
+
return boardClampK(boardKOf(currentLevel + dir));
|
|
108
|
+
}
|
|
109
|
+
|
|
90
110
|
export function boardToScreen(cam: BoardCamera, wx: number, wy: number): BoardPoint {
|
|
91
111
|
return { x: wx * cam.k + cam.x, y: wy * cam.k + cam.y };
|
|
92
112
|
}
|
|
@@ -136,19 +156,20 @@ export function boardClampPan(
|
|
|
136
156
|
|
|
137
157
|
/**
|
|
138
158
|
* Fit the whole content bbox into the viewport (centered, padded). Wide
|
|
139
|
-
* boards shrink
|
|
159
|
+
* boards shrink as far as needed to show everything — fit is allowed
|
|
160
|
+
* below the interaction minimum (the ladder is for gestures, not for
|
|
161
|
+
* the initial framing); tiny boards never blow up past the cap.
|
|
140
162
|
*/
|
|
141
163
|
export function boardFit(
|
|
142
164
|
content: BoardRect,
|
|
143
165
|
viewport: BoardViewport,
|
|
144
|
-
opts: { pad?: number;
|
|
166
|
+
opts: { pad?: number; cap?: number } = {},
|
|
145
167
|
): BoardCamera {
|
|
146
168
|
const pad = opts.pad ?? BOARD_FIT_PAD;
|
|
147
|
-
const floor = opts.floor ?? BOARD_FIT_FLOOR;
|
|
148
169
|
const cap = opts.cap ?? BOARD_FIT_CAP;
|
|
149
170
|
const kw = content.w > 0 ? (viewport.w - pad * 2) / content.w : cap;
|
|
150
171
|
const kh = content.h > 0 ? (viewport.h - pad * 2) / content.h : cap;
|
|
151
|
-
const k = Math.min(cap, Math.
|
|
172
|
+
const k = Math.min(cap, Math.min(kw, kh));
|
|
152
173
|
return {
|
|
153
174
|
k,
|
|
154
175
|
x: (viewport.w - content.w * k) / 2 - content.x * k,
|
|
@@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
|
|
|
3
3
|
import {
|
|
4
4
|
boardAnchor,
|
|
5
5
|
boardEdgePath,
|
|
6
|
+
boardFanOrdinals,
|
|
6
7
|
boardViaPath,
|
|
7
8
|
type BoardNodeRect,
|
|
8
9
|
} from "./boardEdges";
|
|
@@ -39,6 +40,39 @@ describe("boardEdges — anchor modes", () => {
|
|
|
39
40
|
});
|
|
40
41
|
});
|
|
41
42
|
|
|
43
|
+
describe("boardEdges — fan ordinals", () => {
|
|
44
|
+
it("gives a single edge the full fan (count = 1, no spread)", () => {
|
|
45
|
+
const ord = boardFanOrdinals([{ id: "e1", from: "a" }]);
|
|
46
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 1 });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("spreads three edges from one node as {0,3} {1,3} {2,3}", () => {
|
|
50
|
+
const ord = boardFanOrdinals([
|
|
51
|
+
{ id: "e1", from: "a" },
|
|
52
|
+
{ id: "e2", from: "a" },
|
|
53
|
+
{ id: "e3", from: "a" },
|
|
54
|
+
]);
|
|
55
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 3 });
|
|
56
|
+
expect(ord.get("e2")).toEqual({ index: 1, count: 3 });
|
|
57
|
+
expect(ord.get("e3")).toEqual({ index: 2, count: 3 });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("counts each source node independently", () => {
|
|
61
|
+
const ord = boardFanOrdinals([
|
|
62
|
+
{ id: "e1", from: "a" },
|
|
63
|
+
{ id: "e2", from: "b" },
|
|
64
|
+
{ id: "e3", from: "a" },
|
|
65
|
+
]);
|
|
66
|
+
expect(ord.get("e1")).toEqual({ index: 0, count: 2 });
|
|
67
|
+
expect(ord.get("e2")).toEqual({ index: 0, count: 1 });
|
|
68
|
+
expect(ord.get("e3")).toEqual({ index: 1, count: 2 });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns an empty map for empty input", () => {
|
|
72
|
+
expect(boardFanOrdinals([]).size).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
42
76
|
describe("boardEdges — route styles", () => {
|
|
43
77
|
const a = { x: 220, y: 130 };
|
|
44
78
|
const b = { x: 400, y: 260 };
|
package/src/utils/boardEdges.ts
CHANGED
|
@@ -35,6 +35,29 @@ export interface BoardPoint {
|
|
|
35
35
|
y: number;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Fan ordinals for a set of edges: every edge sharing a `from` node gets
|
|
40
|
+
* its `{ index, count }` slot in the 天女散花 spread across that node's
|
|
41
|
+
* exit border. Two passes — totals first, then positions — so `count` is
|
|
42
|
+
* the FINAL total per node (a running count would feed the first edges
|
|
43
|
+
* count = 1 and collapse the whole fan onto one anchor point). Edges from
|
|
44
|
+
* different nodes are counted independently.
|
|
45
|
+
*/
|
|
46
|
+
export function boardFanOrdinals(
|
|
47
|
+
edges: ReadonlyArray<{ id: string; from: string }>,
|
|
48
|
+
): Map<string, { index: number; count: number }> {
|
|
49
|
+
const totals = new Map<string, number>();
|
|
50
|
+
for (const e of edges) totals.set(e.from, (totals.get(e.from) ?? 0) + 1);
|
|
51
|
+
const seen = new Map<string, number>();
|
|
52
|
+
const ordinals = new Map<string, { index: number; count: number }>();
|
|
53
|
+
for (const e of edges) {
|
|
54
|
+
const index = seen.get(e.from) ?? 0;
|
|
55
|
+
seen.set(e.from, index + 1);
|
|
56
|
+
ordinals.set(e.id, { index, count: totals.get(e.from)! });
|
|
57
|
+
}
|
|
58
|
+
return ordinals;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
export interface BoardNodeRect {
|
|
39
62
|
x: number;
|
|
40
63
|
y: number;
|