@celestia-island/hikari 0.55.216 → 0.55.217
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/HkPopover.hover.test.tsx +42 -0
- package/src/components/HkPopover.tsx +8 -0
- package/src/components/HkTooltip.test.tsx +60 -0
- package/src/components/HkTooltip.tsx +24 -2
- package/src/components/HkTreemap.test.tsx +42 -5
- package/src/components/HkTreemap.tsx +19 -19
- package/src/components/HkTreemapStage.test.tsx +138 -5
- package/src/components/HkTreemapStage.tsx +74 -20
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { createApp, h, nextTick } from "vue";
|
|
3
|
+
|
|
4
|
+
import HkPopover from "./HkPopover";
|
|
5
|
+
|
|
6
|
+
const mounts: Array<{ app: ReturnType<typeof createApp>; container: HTMLElement }> = [];
|
|
7
|
+
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
for (const { app, container } of mounts.splice(0)) {
|
|
10
|
+
app.unmount();
|
|
11
|
+
container.remove();
|
|
12
|
+
}
|
|
13
|
+
document.body.querySelectorAll(".hk-popover-panel").forEach((el) => el.remove());
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("HkPopover panel hover intent", () => {
|
|
17
|
+
it("emits from the whole panel, including padding outside the slot content", async () => {
|
|
18
|
+
const events: string[] = [];
|
|
19
|
+
const container = document.createElement("div");
|
|
20
|
+
document.body.appendChild(container);
|
|
21
|
+
const app = createApp({
|
|
22
|
+
render: () => h(
|
|
23
|
+
HkPopover as never,
|
|
24
|
+
{
|
|
25
|
+
modelValue: true,
|
|
26
|
+
title: "hover probe",
|
|
27
|
+
onMouseenter: () => events.push("enter"),
|
|
28
|
+
onMouseleave: () => events.push("leave"),
|
|
29
|
+
},
|
|
30
|
+
() => h("div", { class: "probe-body" }, "content"),
|
|
31
|
+
),
|
|
32
|
+
});
|
|
33
|
+
app.mount(container);
|
|
34
|
+
mounts.push({ app, container });
|
|
35
|
+
await nextTick();
|
|
36
|
+
const panel = document.body.querySelector<HTMLElement>(".hk-popover-panel")!;
|
|
37
|
+
expect(panel).not.toBeNull();
|
|
38
|
+
panel.dispatchEvent(new MouseEvent("mouseenter"));
|
|
39
|
+
panel.dispatchEvent(new MouseEvent("mouseleave"));
|
|
40
|
+
expect(events).toEqual(["enter", "leave"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -103,6 +103,12 @@ export default defineComponent({
|
|
|
103
103
|
* emits on unmounted instances) — a host that itself is going away
|
|
104
104
|
* has nothing to reclaim anyway. */
|
|
105
105
|
closed: () => true,
|
|
106
|
+
/** Hover intent for anchor↔popover safe travel. These fire on the WHOLE
|
|
107
|
+
* panel (padding and chrome included), not only its slot content; a
|
|
108
|
+
* consumer can track anchor/panel hover independently without the
|
|
109
|
+
* overlap open→leave→close→enter loop. */
|
|
110
|
+
mouseenter: (_event: MouseEvent) => true,
|
|
111
|
+
mouseleave: (_event: MouseEvent) => true,
|
|
106
112
|
},
|
|
107
113
|
setup(props, { emit, slots }) {
|
|
108
114
|
const attrs = useAttrs();
|
|
@@ -732,6 +738,8 @@ export default defineComponent({
|
|
|
732
738
|
aria-label={props.title || undefined}
|
|
733
739
|
tabindex={sheetBranch ? "-1" : undefined}
|
|
734
740
|
onKeydown={onPanelKeydown}
|
|
741
|
+
onMouseenter={(event: MouseEvent) => emit("mouseenter", event)}
|
|
742
|
+
onMouseleave={(event: MouseEvent) => emit("mouseleave", event)}
|
|
735
743
|
>
|
|
736
744
|
{sheetBranch && (
|
|
737
745
|
<div class="hk-popover-sheet-grabber" aria-hidden="true" onClick={() => close()} />
|
|
@@ -57,6 +57,10 @@ function wrapper(c: HTMLElement): HTMLElement {
|
|
|
57
57
|
async function settle() {
|
|
58
58
|
await new Promise((r) => setTimeout(r, 20));
|
|
59
59
|
await nextTick();
|
|
60
|
+
// Wait for the show-time rAF position pass itself; a wall-clock sleep is
|
|
61
|
+
// not a frame barrier under a loaded test host.
|
|
62
|
+
await new Promise<void>((resolve) => { requestAnimationFrame(() => resolve()); });
|
|
63
|
+
await nextTick();
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
function enter(c: HTMLElement) {
|
|
@@ -266,6 +270,24 @@ describe("HkTooltip touch taps", () => {
|
|
|
266
270
|
expect(popup().className).toContain("hk-tooltip-visible");
|
|
267
271
|
});
|
|
268
272
|
|
|
273
|
+
it("follows a moving anchor on the touch-opened path too", async () => {
|
|
274
|
+
const { container } = mount({ text: "touch moving", delay: 3000, placement: "bottom" });
|
|
275
|
+
let top = 100;
|
|
276
|
+
wrapper(container).getBoundingClientRect = () => ({
|
|
277
|
+
x: 200, y: top, left: 200, top, right: 280, bottom: top + 40,
|
|
278
|
+
width: 80, height: 40, toJSON: () => ({}),
|
|
279
|
+
}) as DOMRect;
|
|
280
|
+
tap(wrapper(container));
|
|
281
|
+
await settle();
|
|
282
|
+
expect(popup().style.top).toBe("148px");
|
|
283
|
+
top = 120;
|
|
284
|
+
await settle();
|
|
285
|
+
expect(popup().style.top).toBe("168px");
|
|
286
|
+
tap(wrapper(container)); // close also stops the tracking loop
|
|
287
|
+
await nextTick();
|
|
288
|
+
expect(popup().className).not.toContain("hk-tooltip-visible");
|
|
289
|
+
});
|
|
290
|
+
|
|
269
291
|
it("toggles closed when the trigger is tapped again", async () => {
|
|
270
292
|
const { container } = mount({ text: "toggle", delay: 3000 });
|
|
271
293
|
tap(wrapper(container));
|
|
@@ -321,6 +343,44 @@ describe("HkTooltip touch taps", () => {
|
|
|
321
343
|
});
|
|
322
344
|
|
|
323
345
|
describe("HkTooltip anchoring", () => {
|
|
346
|
+
it("uses an explicitly-sized absolute wrapper as the visible cell anchor", async () => {
|
|
347
|
+
const { container } = mount({
|
|
348
|
+
text: "cell", delay: 0, placement: "bottom",
|
|
349
|
+
style: { position: "absolute", left: "200px", top: "100px", width: "400px", height: "240px" },
|
|
350
|
+
});
|
|
351
|
+
const anchor = wrapper(container);
|
|
352
|
+
expect(anchor.style.position).toBe("absolute");
|
|
353
|
+
expect(anchor.style.width).toBe("400px");
|
|
354
|
+
anchor.getBoundingClientRect = () => ({
|
|
355
|
+
x: 200, y: 100, left: 200, top: 100, right: 600, bottom: 340,
|
|
356
|
+
width: 400, height: 240, toJSON: () => ({}),
|
|
357
|
+
}) as DOMRect;
|
|
358
|
+
enter(container);
|
|
359
|
+
await settle();
|
|
360
|
+
expect(popup().style.top).toBe("348px"); // cell.bottom 340 + 8px gap
|
|
361
|
+
expect(popup().style.left).toBe("400px"); // visible frame's centre
|
|
362
|
+
expect(popup().style.transform).toBe("translate(-50%, 0)");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("follows a moving anchor while visible (wheel zoom / minimap pan)", async () => {
|
|
366
|
+
const { container } = mount({ text: "moving", delay: 0, placement: "bottom" });
|
|
367
|
+
let top = 100;
|
|
368
|
+
wrapper(container).getBoundingClientRect = () => ({
|
|
369
|
+
x: 200, y: top, left: 200, top, right: 280, bottom: top + 40,
|
|
370
|
+
width: 80, height: 40, toJSON: () => ({}),
|
|
371
|
+
}) as DOMRect;
|
|
372
|
+
enter(container);
|
|
373
|
+
await settle();
|
|
374
|
+
expect(popup().style.top).toBe("148px");
|
|
375
|
+
|
|
376
|
+
top = 120; // the same hovered cell moves with the camera; no new enter
|
|
377
|
+
await settle();
|
|
378
|
+
expect(popup().style.top).toBe("168px");
|
|
379
|
+
leave(container);
|
|
380
|
+
await nextTick();
|
|
381
|
+
expect(popup().className).not.toContain("hk-tooltip-visible");
|
|
382
|
+
});
|
|
383
|
+
|
|
324
384
|
it("places the popup above the anchor with the top placement (gap + transform)", async () => {
|
|
325
385
|
const { container } = mount({ text: "up", delay: 0, placement: "top" });
|
|
326
386
|
enter(container);
|
|
@@ -38,6 +38,7 @@ export default defineComponent({
|
|
|
38
38
|
const wrapperRef = ref<HTMLElement | null>(null);
|
|
39
39
|
const popupRef = ref<HTMLElement | null>(null);
|
|
40
40
|
let showTimer: ReturnType<typeof setTimeout> | null = null;
|
|
41
|
+
let positionFrame: number | null = null;
|
|
41
42
|
|
|
42
43
|
// The band handle and the popup DOM are both LAZY (dense-list contract,
|
|
43
44
|
// 2026-10-05): a tooltip that is never engaged — the overwhelming
|
|
@@ -74,12 +75,31 @@ export default defineComponent({
|
|
|
74
75
|
applyTooltipPosition(popupRef.value, rect, props.placement, props.maxWidth);
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function stopPositionTracking() {
|
|
79
|
+
if (positionFrame !== null) cancelAnimationFrame(positionFrame);
|
|
80
|
+
positionFrame = null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function trackPositionWhileVisible() {
|
|
84
|
+
if (positionFrame !== null) return;
|
|
85
|
+
const tick = () => {
|
|
86
|
+
positionFrame = null;
|
|
87
|
+
if (!visible.value) return;
|
|
88
|
+
// A visible trigger can move without a pointer event (treemap wheel
|
|
89
|
+
// zoom, minimap pan, layout animation). Re-read its rect per frame
|
|
90
|
+
// until hide/unmount; each visible tooltip owns one such loop.
|
|
91
|
+
updatePosition();
|
|
92
|
+
positionFrame = requestAnimationFrame(tick);
|
|
93
|
+
};
|
|
94
|
+
positionFrame = requestAnimationFrame(tick);
|
|
95
|
+
}
|
|
96
|
+
|
|
77
97
|
function show() {
|
|
78
98
|
clearShowTimer();
|
|
79
99
|
ensureEngaged();
|
|
80
100
|
showTimer = setTimeout(() => {
|
|
81
101
|
visible.value = true;
|
|
82
|
-
|
|
102
|
+
trackPositionWhileVisible();
|
|
83
103
|
}, props.delay);
|
|
84
104
|
}
|
|
85
105
|
|
|
@@ -87,12 +107,13 @@ export default defineComponent({
|
|
|
87
107
|
clearShowTimer();
|
|
88
108
|
ensureEngaged();
|
|
89
109
|
visible.value = true;
|
|
90
|
-
|
|
110
|
+
trackPositionWhileVisible();
|
|
91
111
|
}
|
|
92
112
|
|
|
93
113
|
function hide() {
|
|
94
114
|
clearShowTimer();
|
|
95
115
|
visible.value = false;
|
|
116
|
+
stopPositionTracking();
|
|
96
117
|
dismissTouchListener();
|
|
97
118
|
}
|
|
98
119
|
|
|
@@ -144,6 +165,7 @@ export default defineComponent({
|
|
|
144
165
|
|
|
145
166
|
onBeforeUnmount(() => {
|
|
146
167
|
clearShowTimer();
|
|
168
|
+
stopPositionTracking();
|
|
147
169
|
dismissTouchListener();
|
|
148
170
|
if (popupHandle) {
|
|
149
171
|
manager.unregister(popupHandle.id);
|
|
@@ -11,9 +11,15 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
|
11
11
|
vi.mock("./HkTooltip", () => ({
|
|
12
12
|
default: {
|
|
13
13
|
name: "HkTooltipStub",
|
|
14
|
-
props: {
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
props: {
|
|
15
|
+
text: { type: String, required: true },
|
|
16
|
+
placement: { type: String, default: "top" },
|
|
17
|
+
},
|
|
18
|
+
setup(props: { text: string; placement: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
|
|
19
|
+
return () => h("span", {
|
|
20
|
+
"data-hk-tooltip-text": props.text,
|
|
21
|
+
"data-hk-tooltip-placement": props.placement,
|
|
22
|
+
}, slots.default?.({}) as never);
|
|
17
23
|
},
|
|
18
24
|
},
|
|
19
25
|
}));
|
|
@@ -316,8 +322,35 @@ describe("HkTreemap component", () => {
|
|
|
316
322
|
// 400×240 LOCAL space (the old gBCR-based measure painted 2× cells).
|
|
317
323
|
await setSize(mounted, 400, 240, 2);
|
|
318
324
|
const cell = mounted.container.querySelector<HTMLElement>(".hk-treemap__cell")!;
|
|
319
|
-
|
|
320
|
-
expect(
|
|
325
|
+
const frame = cell.closest<HTMLElement>("[data-hk-tooltip-text]")!;
|
|
326
|
+
expect(frame.style.width).toBe("400px");
|
|
327
|
+
expect(frame.style.height).toBe("240px");
|
|
328
|
+
expect(cell.style.inset).toBe("0px");
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("keeps every small-canvas cell frame inside the chart at 1×/2×/3× DPI", async () => {
|
|
332
|
+
const mounted = mountTreemap({ nodes: NODES });
|
|
333
|
+
for (const zoom of [1, 2, 3]) {
|
|
334
|
+
await setSize(mounted, 220, 120, zoom);
|
|
335
|
+
const cells = [...mounted.container.querySelectorAll<HTMLElement>(".hk-treemap__cell")];
|
|
336
|
+
expect(cells.length).toBeGreaterThanOrEqual(4);
|
|
337
|
+
for (const cell of cells) {
|
|
338
|
+
const frame = cell.classList.contains("hk-treemap__cell--plate")
|
|
339
|
+
? cell
|
|
340
|
+
: cell.closest<HTMLElement>("[data-hk-tooltip-text]")!;
|
|
341
|
+
if (frame !== cell) expect(frame.style.position).toBe("absolute");
|
|
342
|
+
const x = Number.parseFloat(frame.style.left);
|
|
343
|
+
const y = Number.parseFloat(frame.style.top);
|
|
344
|
+
const w = Number.parseFloat(frame.style.width);
|
|
345
|
+
const h = Number.parseFloat(frame.style.height);
|
|
346
|
+
expect([x, y, w, h].every(Number.isFinite)).toBe(true);
|
|
347
|
+
expect(x).toBeGreaterThanOrEqual(0);
|
|
348
|
+
expect(y).toBeGreaterThanOrEqual(0);
|
|
349
|
+
expect(x + w).toBeLessThanOrEqual(220.001);
|
|
350
|
+
expect(y + h).toBeLessThanOrEqual(120.001);
|
|
351
|
+
if (frame !== cell) expect(cell.style.inset).toBe("0px");
|
|
352
|
+
}
|
|
353
|
+
}
|
|
321
354
|
});
|
|
322
355
|
|
|
323
356
|
it("every cell carries the standard HkTooltip with label, value and share", async () => {
|
|
@@ -332,6 +365,10 @@ describe("HkTreemap component", () => {
|
|
|
332
365
|
expect(tooltips).toContain("Group: 6u · 60%");
|
|
333
366
|
expect(tooltips).toContain("Alpha: 2u · 20%");
|
|
334
367
|
expect(tooltips).toContain("Other: 4u · 40%");
|
|
368
|
+
const placements = [...mounted.container.querySelectorAll("[data-hk-tooltip-placement]")]
|
|
369
|
+
.map((node) => node.getAttribute("data-hk-tooltip-placement"));
|
|
370
|
+
expect(placements).toHaveLength(tooltips.length);
|
|
371
|
+
expect(new Set(placements)).toEqual(new Set(["bottom"]));
|
|
335
372
|
});
|
|
336
373
|
|
|
337
374
|
it("re-renders cells when data changes on a same-size container", async () => {
|
|
@@ -488,36 +488,36 @@ export default defineComponent({
|
|
|
488
488
|
}
|
|
489
489
|
const head = entry.kind === "head";
|
|
490
490
|
const bandH = Math.min(22, Math.max(0, h));
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
: {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
491
|
+
// The tooltip wrapper IS the absolute cell frame. Its trigger fills
|
|
492
|
+
// that frame, so the wrapper both establishes the correct containing
|
|
493
|
+
// block and reports the visible cell's rect to tooltipPosition. The
|
|
494
|
+
// old shape positioned the trigger inside a zero-sized inline wrapper
|
|
495
|
+
// (wrong origin + chart-edge clipping + every tooltip at the top-left).
|
|
496
|
+
const frameStyle = {
|
|
497
|
+
position: "absolute" as const,
|
|
498
|
+
left: `${x}px`,
|
|
499
|
+
top: `${y}px`,
|
|
500
|
+
width: `${w}px`,
|
|
501
|
+
height: `${head ? bandH : h}px`,
|
|
502
|
+
};
|
|
503
|
+
const cellStyle = {
|
|
504
|
+
inset: "0px",
|
|
505
|
+
background: node.color,
|
|
506
|
+
"--hk-treemap-fg": contrastTextColor(node.color),
|
|
507
|
+
};
|
|
508
508
|
const label = showLabel || head ? node.label : null;
|
|
509
509
|
const meta = showValue
|
|
510
510
|
? `${formatValue(node.value)} · ${formatHkTreemapPercent(node.percent)}`
|
|
511
511
|
: null;
|
|
512
512
|
return (
|
|
513
|
-
<HkTooltip key={entry.key} text={cellText(node)} placement="
|
|
513
|
+
<HkTooltip key={entry.key} text={cellText(node)} placement="bottom" style={frameStyle}>
|
|
514
514
|
{() => (
|
|
515
515
|
<div
|
|
516
516
|
class={{
|
|
517
517
|
"hk-treemap__cell": true,
|
|
518
518
|
"hk-treemap__cell--group": head,
|
|
519
519
|
}}
|
|
520
|
-
style={
|
|
520
|
+
style={cellStyle}
|
|
521
521
|
role="img"
|
|
522
522
|
aria-label={cellText(node)}
|
|
523
523
|
>
|
|
@@ -6,9 +6,15 @@ import { createApp, defineComponent, h, nextTick, reactive } from "vue";
|
|
|
6
6
|
vi.mock("./HkTooltip", () => ({
|
|
7
7
|
default: {
|
|
8
8
|
name: "HkTooltipStub",
|
|
9
|
-
props: {
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
props: {
|
|
10
|
+
text: { type: String, required: true },
|
|
11
|
+
placement: { type: String, default: "top" },
|
|
12
|
+
},
|
|
13
|
+
setup(props: { text: string; placement: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
|
|
14
|
+
return () => h("span", {
|
|
15
|
+
"data-hk-tooltip-text": props.text,
|
|
16
|
+
"data-hk-tooltip-placement": props.placement,
|
|
17
|
+
}, slots.default?.({}) as never);
|
|
12
18
|
},
|
|
13
19
|
},
|
|
14
20
|
}));
|
|
@@ -76,11 +82,13 @@ function mountStage(initialProps: Record<string, unknown> = { nodes: NODES }) {
|
|
|
76
82
|
};
|
|
77
83
|
}
|
|
78
84
|
|
|
79
|
-
async function setSize(mounted: { container: HTMLElement }, width = 400, height = 240) {
|
|
85
|
+
async function setSize(mounted: { container: HTMLElement }, width = 400, height = 240, zoom = 1) {
|
|
80
86
|
const el = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
81
|
-
el.getBoundingClientRect = () => fakeRect(width, height);
|
|
87
|
+
el.getBoundingClientRect = () => fakeRect(width * zoom, height * zoom);
|
|
82
88
|
Object.defineProperty(el, "clientWidth", { value: width, configurable: true });
|
|
83
89
|
Object.defineProperty(el, "clientHeight", { value: height, configurable: true });
|
|
90
|
+
Object.defineProperty(el, "offsetWidth", { value: width, configurable: true });
|
|
91
|
+
Object.defineProperty(el, "offsetHeight", { value: height, configurable: true });
|
|
84
92
|
FakeResizeObserver.instances[0]?.callback();
|
|
85
93
|
await nextTick();
|
|
86
94
|
}
|
|
@@ -112,9 +120,34 @@ describe("HkTreemapStage — the drill path", () => {
|
|
|
112
120
|
(n) => n.getAttribute("data-hk-tooltip-text"),
|
|
113
121
|
);
|
|
114
122
|
expect(tooltips.some((t) => t!.startsWith("Group:"))).toBe(true);
|
|
123
|
+
const placements = [...mounted.container.querySelectorAll("[data-hk-tooltip-placement]")]
|
|
124
|
+
.map((node) => node.getAttribute("data-hk-tooltip-placement"));
|
|
125
|
+
expect(new Set(placements)).toEqual(new Set(["bottom"]));
|
|
115
126
|
expect(mounted.container.textContent).toContain("Usage"); // the root crumb
|
|
116
127
|
});
|
|
117
128
|
|
|
129
|
+
it("keeps the tooltip-owned cell frames inside a small canvas at 2×/3× DPI", async () => {
|
|
130
|
+
const mounted = mountStage({ nodes: NODES });
|
|
131
|
+
for (const zoom of [2, 3]) {
|
|
132
|
+
await setSize(mounted, 220, 120, zoom);
|
|
133
|
+
const cells = [...mounted.container.querySelectorAll<HTMLElement>(".hk-treemap-stage__cell")];
|
|
134
|
+
expect(cells).toHaveLength(2);
|
|
135
|
+
for (const cell of cells) {
|
|
136
|
+
const frame = cell.closest<HTMLElement>("[data-hk-tooltip-text]")!;
|
|
137
|
+
expect(frame.style.position).toBe("absolute");
|
|
138
|
+
const x = Number.parseFloat(frame.style.left);
|
|
139
|
+
const y = Number.parseFloat(frame.style.top);
|
|
140
|
+
const w = Number.parseFloat(frame.style.width);
|
|
141
|
+
const h = Number.parseFloat(frame.style.height);
|
|
142
|
+
expect(x).toBeGreaterThanOrEqual(0);
|
|
143
|
+
expect(y).toBeGreaterThanOrEqual(0);
|
|
144
|
+
expect(x + w).toBeLessThanOrEqual(220.001);
|
|
145
|
+
expect(y + h).toBeLessThanOrEqual(120.001);
|
|
146
|
+
expect(cell.style.inset).toBe("0px");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
118
151
|
it("clicking a GROUP cell drills in: children fill the canvas, back button appears", async () => {
|
|
119
152
|
const mounted = mountStage({ nodes: NODES });
|
|
120
153
|
await setSize(mounted);
|
|
@@ -223,6 +256,106 @@ describe("HkTreemapStage — the camera + minimap", () => {
|
|
|
223
256
|
expect(mounted.container.querySelector(".hk-mm-zoom-label")!.textContent).toContain("110");
|
|
224
257
|
});
|
|
225
258
|
|
|
259
|
+
it("wheel gestures zoom the canvas and never scroll the host page", async () => {
|
|
260
|
+
const mounted = mountStage({ nodes: NODES });
|
|
261
|
+
await setSize(mounted);
|
|
262
|
+
const canvas = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
263
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
264
|
+
const inEvent = new WheelEvent("wheel", { deltaY: -100, bubbles: true, cancelable: true });
|
|
265
|
+
canvas.dispatchEvent(inEvent);
|
|
266
|
+
await nextTick();
|
|
267
|
+
expect(inEvent.defaultPrevented).toBe(true);
|
|
268
|
+
expect(surface.style.transform).toContain("scale(1.1)");
|
|
269
|
+
canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true }));
|
|
270
|
+
await nextTick();
|
|
271
|
+
expect(surface.style.transform).toContain("scale(1)");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("clamps wheel zoom at 50–200% and ignores zero-delta motion", async () => {
|
|
275
|
+
const mounted = mountStage({ nodes: NODES });
|
|
276
|
+
await setSize(mounted);
|
|
277
|
+
const canvas = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
278
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
279
|
+
const zero = new WheelEvent("wheel", { deltaY: 0, bubbles: true, cancelable: true });
|
|
280
|
+
canvas.dispatchEvent(zero);
|
|
281
|
+
await nextTick();
|
|
282
|
+
expect(zero.defaultPrevented).toBe(true);
|
|
283
|
+
expect(surface.style.transform).toContain("scale(1)");
|
|
284
|
+
for (let i = 0; i < 8; i += 1) {
|
|
285
|
+
canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true }));
|
|
286
|
+
}
|
|
287
|
+
await nextTick();
|
|
288
|
+
expect(surface.style.transform).toContain("scale(0.5)");
|
|
289
|
+
for (let i = 0; i < 20; i += 1) {
|
|
290
|
+
canvas.dispatchEvent(new WheelEvent("wheel", { deltaY: -100, bubbles: true, cancelable: true }));
|
|
291
|
+
}
|
|
292
|
+
await nextTick();
|
|
293
|
+
expect(surface.style.transform).toContain("scale(2)");
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("keeps wheel zoom anchored under the pointer at 2× root DPI", async () => {
|
|
297
|
+
const mounted = mountStage({ nodes: [{ id: "leaf", label: "Leaf", value: 1 }] });
|
|
298
|
+
await setSize(mounted, 400, 240, 2);
|
|
299
|
+
const canvas = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
300
|
+
canvas.getBoundingClientRect = () => ({
|
|
301
|
+
x: 100, y: 50, left: 100, top: 50, right: 900, bottom: 530,
|
|
302
|
+
width: 800, height: 480, toJSON: () => ({}),
|
|
303
|
+
}) as DOMRect;
|
|
304
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
305
|
+
const wheel = new WheelEvent("wheel", {
|
|
306
|
+
deltaY: -100, bubbles: true, cancelable: true,
|
|
307
|
+
});
|
|
308
|
+
// happy-dom does not apply MouseEventInit's client coords to WheelEvent;
|
|
309
|
+
// pin them explicitly (same convention as HkBoard.scale.test.ts).
|
|
310
|
+
Object.defineProperty(wheel, "clientX", { value: 500 });
|
|
311
|
+
Object.defineProperty(wheel, "clientY", { value: 290 });
|
|
312
|
+
// (500,290) drawn = (200,120) local. At 100→110%, pan must become
|
|
313
|
+
// (-20,-12) so -20 + 200×1.1 = 200 (and likewise on y).
|
|
314
|
+
canvas.dispatchEvent(wheel);
|
|
315
|
+
await nextTick();
|
|
316
|
+
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/.exec(
|
|
317
|
+
surface.style.transform,
|
|
318
|
+
);
|
|
319
|
+
expect(match).not.toBeNull();
|
|
320
|
+
expect(Number(match![1])).toBeCloseTo(-20, 8);
|
|
321
|
+
expect(Number(match![2])).toBeCloseTo(-12, 8);
|
|
322
|
+
expect(Number(match![3])).toBeCloseTo(1.1, 8);
|
|
323
|
+
// The world point under the pointer is invariant, not merely "zoomed".
|
|
324
|
+
expect(Number(match![1]) + 200 * Number(match![3])).toBeCloseTo(200, 8);
|
|
325
|
+
expect(Number(match![2]) + 120 * Number(match![3])).toBeCloseTo(120, 8);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("controlled zoom changes pan only when the parent actually applies the request", async () => {
|
|
329
|
+
const mounted = mountStage({
|
|
330
|
+
nodes: [{ id: "leaf", label: "Leaf", value: 1 }],
|
|
331
|
+
zoomPercent: 100,
|
|
332
|
+
});
|
|
333
|
+
await setSize(mounted, 400, 240, 2);
|
|
334
|
+
const canvas = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__canvas")!;
|
|
335
|
+
canvas.getBoundingClientRect = () => ({
|
|
336
|
+
x: 100, y: 50, left: 100, top: 50, right: 900, bottom: 530,
|
|
337
|
+
width: 800, height: 480, toJSON: () => ({}),
|
|
338
|
+
}) as DOMRect;
|
|
339
|
+
const surface = mounted.container.querySelector<HTMLElement>(".hk-treemap-stage__surface")!;
|
|
340
|
+
const wheel = new WheelEvent("wheel", { deltaY: -100, bubbles: true, cancelable: true });
|
|
341
|
+
Object.defineProperty(wheel, "clientX", { value: 500 });
|
|
342
|
+
Object.defineProperty(wheel, "clientY", { value: 290 });
|
|
343
|
+
canvas.dispatchEvent(wheel);
|
|
344
|
+
await nextTick();
|
|
345
|
+
// Parent rejected/omitted the update: neither scale nor pan may move.
|
|
346
|
+
expect(surface.style.transform).toBe("translate(0px, 0px) scale(1)");
|
|
347
|
+
|
|
348
|
+
mounted.update({ zoomPercent: 110 });
|
|
349
|
+
await nextTick();
|
|
350
|
+
const match = /translate\(([-\d.]+)px, ([-\d.]+)px\) scale\(([\d.]+)\)/.exec(
|
|
351
|
+
surface.style.transform,
|
|
352
|
+
);
|
|
353
|
+
expect(match).not.toBeNull();
|
|
354
|
+
expect(Number(match![1])).toBeCloseTo(-20, 8);
|
|
355
|
+
expect(Number(match![2])).toBeCloseTo(-12, 8);
|
|
356
|
+
expect(Number(match![3])).toBeCloseTo(1.1, 8);
|
|
357
|
+
});
|
|
358
|
+
|
|
226
359
|
it("zoom reveals detail: a small cell's label appears at a higher rung", async () => {
|
|
227
360
|
// One dominant leaf + crumbs: at 100% the crumbs sit under the label
|
|
228
361
|
// floor; zoom in and the effective size crosses it.
|
|
@@ -5,6 +5,7 @@ import { ChevronLeft } from "lucide-vue-next";
|
|
|
5
5
|
import HkIconButton from "./HkIconButton";
|
|
6
6
|
import HkMinimap, { type MinimapBox } from "./HkMinimap";
|
|
7
7
|
import HkTooltip from "./HkTooltip";
|
|
8
|
+
import { drawnScale } from "../composables/layoutGeometry";
|
|
8
9
|
import {
|
|
9
10
|
buildHkTreemapLayout,
|
|
10
11
|
contrastTextColor,
|
|
@@ -86,30 +87,73 @@ export default defineComponent({
|
|
|
86
87
|
const panX = ref(0);
|
|
87
88
|
const panY = ref(0);
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
interface ZoomAnchor { x: number; y: number }
|
|
91
|
+
let pendingControlledZoom: { percent: number; anchor: ZoomAnchor } | null = null;
|
|
92
|
+
|
|
93
|
+
function rescalePan(prev: number, next: number, anchor: ZoomAnchor): void {
|
|
94
|
+
// transform = translate(pan) scale(k): keep the WORLD point under
|
|
95
|
+
// `anchor` stationary. Minimap buttons keep the historical origin
|
|
96
|
+
// anchor; wheel passes the pointer's LOCAL canvas point (DPI-safe).
|
|
97
|
+
if (prev <= 0 || next === prev) return;
|
|
98
|
+
const ratio = next / prev;
|
|
99
|
+
panX.value = anchor.x - (anchor.x - panX.value) * ratio;
|
|
100
|
+
panY.value = anchor.y - (anchor.y - panY.value) * ratio;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyZoom(target: number, anchor = { x: 0, y: 0 }): void {
|
|
90
104
|
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Math.round(target)));
|
|
91
|
-
// Capture the PREVIOUS rung before writing the new one — reading
|
|
92
|
-
// zoomPct after the write always yields `next` and turns the pan
|
|
93
|
-
// rescale below into a silent no-op (R2 finding).
|
|
94
105
|
const prev = zoomPct.value;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const ratio = next / prev;
|
|
103
|
-
panX.value *= ratio;
|
|
104
|
-
panY.value *= ratio;
|
|
106
|
+
if (props.zoomPercent === undefined) {
|
|
107
|
+
internalZoom.value = next;
|
|
108
|
+
rescalePan(prev, next, anchor);
|
|
109
|
+
} else {
|
|
110
|
+
// Controlled mode is a REQUEST until the parent applies it. If the
|
|
111
|
+
// parent rejects/omits the update, scale AND pan must both stay put.
|
|
112
|
+
pendingControlledZoom = { percent: next, anchor };
|
|
105
113
|
}
|
|
114
|
+
emit("update:zoomPercent", next);
|
|
106
115
|
}
|
|
107
116
|
|
|
117
|
+
watch(() => props.zoomPercent, (next, prev) => {
|
|
118
|
+
if (next == null || prev == null || next === prev) return;
|
|
119
|
+
const pending = pendingControlledZoom;
|
|
120
|
+
pendingControlledZoom = null;
|
|
121
|
+
rescalePan(prev, next, pending?.percent === next ? pending.anchor : { x: 0, y: 0 });
|
|
122
|
+
});
|
|
123
|
+
|
|
108
124
|
function applyPanDelta(dx: number, dy: number): void {
|
|
109
125
|
panX.value += dx;
|
|
110
126
|
panY.value += dy;
|
|
111
127
|
}
|
|
112
128
|
|
|
129
|
+
function localPoint(event: WheelEvent): { x: number; y: number } {
|
|
130
|
+
const el = hostRef.value;
|
|
131
|
+
const rect = el?.getBoundingClientRect();
|
|
132
|
+
if (!el) return { x: 0, y: 0 };
|
|
133
|
+
// Pointer coordinates and gBCR are DRAWN px; the camera lives in the
|
|
134
|
+
// canvas's LOCAL CSS px. Follow HkBoard's root-zoom conversion rather
|
|
135
|
+
// than letting a 200%/300% root move the zoom anchor off the pointer.
|
|
136
|
+
const scale = drawnScale(el);
|
|
137
|
+
const factor = el.offsetWidth > 0 ? scale.x : el.offsetHeight > 0 ? scale.y : 1;
|
|
138
|
+
const k = factor > 0 ? factor : 1;
|
|
139
|
+
return {
|
|
140
|
+
x: (event.clientX - (rect?.left ?? 0)) / k,
|
|
141
|
+
y: (event.clientY - (rect?.top ?? 0)) / k,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function onWheel(event: WheelEvent): void {
|
|
146
|
+
// The stage is a map canvas: wheel gestures belong to its camera, not
|
|
147
|
+
// to the popover/page behind it. Prevent the scroll even at a zoom
|
|
148
|
+
// limit so reaching 50%/200% never unexpectedly scrolls the host.
|
|
149
|
+
event.preventDefault();
|
|
150
|
+
if (event.deltaY === 0) return;
|
|
151
|
+
applyZoom(
|
|
152
|
+
zoomPct.value + (event.deltaY < 0 ? STEP_ZOOM : -STEP_ZOOM),
|
|
153
|
+
localPoint(event),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
113
157
|
function resetCamera(): void {
|
|
114
158
|
internalZoom.value = 100;
|
|
115
159
|
emit("update:zoomPercent", 100);
|
|
@@ -276,7 +320,13 @@ export default defineComponent({
|
|
|
276
320
|
</header>
|
|
277
321
|
)}
|
|
278
322
|
<div class="hk-treemap-stage__body">
|
|
279
|
-
<div
|
|
323
|
+
<div
|
|
324
|
+
ref={hostRef}
|
|
325
|
+
class="hk-treemap-stage__canvas"
|
|
326
|
+
role="application"
|
|
327
|
+
aria-label={props.ariaLabel}
|
|
328
|
+
onWheel={onWheel}
|
|
329
|
+
>
|
|
280
330
|
{hasData.value && (
|
|
281
331
|
<div class="hk-treemap-stage__surface" style={surfaceStyle.value}>
|
|
282
332
|
{cells.value.map((cell) => {
|
|
@@ -289,8 +339,15 @@ export default defineComponent({
|
|
|
289
339
|
const zh = h * (zoomPct.value / 100);
|
|
290
340
|
const showValue = zh >= 40 && zw >= 56;
|
|
291
341
|
const showLabel = zh >= 18 && zw >= 24;
|
|
342
|
+
const frameStyle = {
|
|
343
|
+
position: "absolute" as const,
|
|
344
|
+
left: `${x}px`,
|
|
345
|
+
top: `${y}px`,
|
|
346
|
+
width: `${w}px`,
|
|
347
|
+
height: `${h}px`,
|
|
348
|
+
};
|
|
292
349
|
return (
|
|
293
|
-
<HkTooltip key={node.id} text={cellText(node)} placement="
|
|
350
|
+
<HkTooltip key={node.id} text={cellText(node)} placement="bottom" style={frameStyle}>
|
|
294
351
|
{() => (
|
|
295
352
|
<div
|
|
296
353
|
class={{
|
|
@@ -300,10 +357,7 @@ export default defineComponent({
|
|
|
300
357
|
"hk-treemap-stage__cell--drillable": isGroup,
|
|
301
358
|
}}
|
|
302
359
|
style={{
|
|
303
|
-
|
|
304
|
-
top: `${y}px`,
|
|
305
|
-
width: `${w}px`,
|
|
306
|
-
height: `${h}px`,
|
|
360
|
+
inset: "0px",
|
|
307
361
|
background: node.color,
|
|
308
362
|
"--hk-treemap-fg": contrastTextColor(node.color),
|
|
309
363
|
}}
|
package/src/index.ts
CHANGED
|
@@ -730,6 +730,7 @@ export {
|
|
|
730
730
|
export { default as HkMinimap3D } from "./components/HkMinimap3D";
|
|
731
731
|
export { default as HkTrendChart } from "./components/HkTrendChart";
|
|
732
732
|
export { default as HkTreemap } from "./components/HkTreemap";
|
|
733
|
+
export { default as HkTreemapStage } from "./components/HkTreemapStage";
|
|
733
734
|
export { default as HkErrorBoundary } from "./components/HkErrorBoundary";
|
|
734
735
|
export { default as HkDraggableList } from "./components/HkDraggableList";
|
|
735
736
|
export { default as HkDraggableGrid } from "./components/HkDraggableGrid";
|