@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celestia-island/hikari",
3
- "version": "0.55.216",
3
+ "version": "0.55.217",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Hikari UI library, Vue 3 runtime — one design system for Vue 3 and Rust/WASM applications",
@@ -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
- requestAnimationFrame(updatePosition);
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
- requestAnimationFrame(updatePosition);
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: { text: { type: String, required: true } },
15
- setup(props: { text: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
16
- return () => h("span", { "data-hk-tooltip-text": props.text }, slots.default?.({}) as never);
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
- expect(cell.style.width).toBe("400px");
320
- expect(cell.style.height).toBe("240px");
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
- const style = head
492
- ? {
493
- left: `${x}px`,
494
- top: `${y}px`,
495
- width: `${w}px`,
496
- height: `${bandH}px`,
497
- background: node.color,
498
- "--hk-treemap-fg": contrastTextColor(node.color),
499
- }
500
- : {
501
- left: `${x}px`,
502
- top: `${y}px`,
503
- width: `${w}px`,
504
- height: `${h}px`,
505
- background: node.color,
506
- "--hk-treemap-fg": contrastTextColor(node.color),
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="top">
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={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: { text: { type: String, required: true } },
10
- setup(props: { text: string }, { slots }: { slots: { default?: (p: unknown) => unknown } }) {
11
- return () => h("span", { "data-hk-tooltip-text": props.text }, slots.default?.({}) as never);
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
- function applyZoom(target: number): void {
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
- internalZoom.value = next;
96
- emit("update:zoomPercent", next);
97
- // The camera anchors the ORIGIN (transform-origin: 0 0), so a rung
98
- // change rescales any accumulated pan by the same ratio to keep the
99
- // content under the cursor tracking — an origin-anchored re-scale,
100
- // not a centre-anchored one.
101
- if (prev > 0 && next !== prev) {
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 ref={hostRef} class="hk-treemap-stage__canvas" role="application" aria-label={props.ariaLabel}>
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="top">
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
- left: `${x}px`,
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";