@celestia-island/hikari 0.55.340 → 0.55.341
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
|
@@ -163,6 +163,20 @@ function boxOf(element: HTMLElement): { left: number; width: number } {
|
|
|
163
163
|
return { left: parseFloat(element.style.left), width: parseFloat(element.style.width) };
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
+
/** Inline box assertions at the geometry tolerance. `percent()` rounds to four
|
|
167
|
+
* decimals OF A PERCENT (~1e-6 fraction), so a four-decimal `toBeCloseTo`
|
|
168
|
+
* sits exactly on that rounding boundary; 3 keeps a margin while still
|
|
169
|
+
* pinning well below a percent. */
|
|
170
|
+
function expectBox(
|
|
171
|
+
element: HTMLElement | null,
|
|
172
|
+
expected: { left: number; width: number },
|
|
173
|
+
label: string,
|
|
174
|
+
): void {
|
|
175
|
+
expect(element, label).toBeTruthy();
|
|
176
|
+
expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
|
|
177
|
+
expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
|
|
178
|
+
}
|
|
179
|
+
|
|
166
180
|
function lastWindow(mock: ReturnType<typeof vi.fn>): [number, number] | null {
|
|
167
181
|
const calls = mock.mock.calls;
|
|
168
182
|
expect(calls.length, "windowChange was emitted").toBeGreaterThan(0);
|
|
@@ -304,9 +318,11 @@ describe("HkRangeRibbon full domain", () => {
|
|
|
304
318
|
// …and a segment can never invent an undeclared lane.
|
|
305
319
|
expect(segmentById(root, "orphan")).toBeNull();
|
|
306
320
|
expect(root.querySelector('[data-segment-id="orphan"]')).toBeNull();
|
|
307
|
-
// Content wholly outside the axis paints NOTHING
|
|
308
|
-
// clipping rule, not a clamp
|
|
309
|
-
//
|
|
321
|
+
// Content wholly outside the axis paints NOTHING on either layer: the
|
|
322
|
+
// domain is a clipping rule, not a clamp. (Pre-domain, `fullDomainBox`
|
|
323
|
+
// emitted the raw fractions — left 120%, width 20% for this bar — which
|
|
324
|
+
// the overview's `overflow: hidden` cut and the detail track painted
|
|
325
|
+
// overflowing; nothing ever clamped it to an edge.)
|
|
310
326
|
expect(segmentById(root, "outside")).toBeNull();
|
|
311
327
|
expect(root.querySelector('[data-segment-id="outside"]')).toBeNull();
|
|
312
328
|
});
|
|
@@ -429,6 +445,38 @@ describe("HkRangeRibbon windowed view", () => {
|
|
|
429
445
|
expect(laneSegments(root)).toHaveLength(1);
|
|
430
446
|
});
|
|
431
447
|
|
|
448
|
+
it("paints a domain-edge point in the full view but not through a window edge", () => {
|
|
449
|
+
// DELIBERATE asymmetry: the DOMAIN edges are INCLUSIVE (a zero-length
|
|
450
|
+
// point on the axis paints as a tick), while the WINDOW is strict
|
|
451
|
+
// half-open (`end > win[0] && start < win[1]`), so the same point sitting
|
|
452
|
+
// exactly on the window end vanishes in the detail. Chest's
|
|
453
|
+
// `traceRecordsInRange` mirrors that half-open rule verbatim — do NOT
|
|
454
|
+
// make the window inclusive to "fix" this; it would fork the consumer's
|
|
455
|
+
// mirrored filter.
|
|
456
|
+
const segments: RibbonSegment[] = [
|
|
457
|
+
{ id: "edgePoint", lane: "cpu", start: 2, end: 2, label: "edge point" },
|
|
458
|
+
];
|
|
459
|
+
const full = mountRibbon({ window: null, domain: [0, 2], segments });
|
|
460
|
+
expectBox(
|
|
461
|
+
full.querySelector<HTMLElement>(
|
|
462
|
+
'.hk-range-ribbon-overview-segment[data-segment-id="edgePoint"]',
|
|
463
|
+
),
|
|
464
|
+
{ left: 100, width: 0 },
|
|
465
|
+
"the inclusive domain end keeps the point as a tick",
|
|
466
|
+
);
|
|
467
|
+
expect(segmentById(full, "edgePoint"), "the no-window detail paints it too").toBeTruthy();
|
|
468
|
+
|
|
469
|
+
const windowed = mountRibbon({ window: [1.98, 2], domain: [0, 2], segments });
|
|
470
|
+
expect(
|
|
471
|
+
segmentById(windowed, "edgePoint"),
|
|
472
|
+
"a point ON the window end does not intersect the half-open window",
|
|
473
|
+
).toBeNull();
|
|
474
|
+
expect(
|
|
475
|
+
windowed.querySelector('.hk-range-ribbon-overview-segment[data-segment-id="edgePoint"]'),
|
|
476
|
+
"the minimap keeps painting the domain box",
|
|
477
|
+
).toBeTruthy();
|
|
478
|
+
});
|
|
479
|
+
|
|
432
480
|
it("normalizes inverted, out-of-domain and degenerate windows; NaN means full", () => {
|
|
433
481
|
// Inverted [0.8, 0.2] → [0.2, 0.8].
|
|
434
482
|
const inverted = mountRibbon({ window: [0.8, 0.2] as unknown as [number, number] });
|
|
@@ -902,6 +950,52 @@ describe("HkRangeRibbon reset", () => {
|
|
|
902
950
|
expect(host.root.querySelector(".hk-range-ribbon-reset")).toBeNull();
|
|
903
951
|
});
|
|
904
952
|
|
|
953
|
+
it("scopes the full-view epsilon to the axis span", () => {
|
|
954
|
+
// On a 1e-12 axis the window [0, 5e-13] is a REAL sub-selection half the
|
|
955
|
+
// axis wide, but an absolute 1e-9 slack would call it "full". The
|
|
956
|
+
// span-relative slack (1e-9 * span) keeps the distinction; the unit axis
|
|
957
|
+
// still uses the historical 1e-9.
|
|
958
|
+
const tiny = mountRibbon({ domain: [0, 1e-12], minWindow: 1e-13, window: [0, 5e-13] });
|
|
959
|
+
expect(
|
|
960
|
+
tiny.querySelector(".hk-range-ribbon-reset"),
|
|
961
|
+
"a half-axis window on a tiny axis is a real sub-selection",
|
|
962
|
+
).toBeTruthy();
|
|
963
|
+
const full = mountRibbon({ domain: [0, 1e-12], minWindow: 1e-13, window: [0, 1e-12] });
|
|
964
|
+
expect(
|
|
965
|
+
full.querySelector(".hk-range-ribbon-reset"),
|
|
966
|
+
"the whole tiny axis needs no reset",
|
|
967
|
+
).toBeNull();
|
|
968
|
+
|
|
969
|
+
// A NEGATIVE-ORIGIN axis catches a slack scaled by the wrong end: with
|
|
970
|
+
// `eps = WINDOW_EPSILON * d1` (~ -1e-9 here) the FULL view would become a
|
|
971
|
+
// phantom sub-selection. The shipped slack (1e-9 * span) keeps the full
|
|
972
|
+
// axis full and half the axis a real selection.
|
|
973
|
+
const negativeDomain: [number, number] = [-1, -1 + 1e-12];
|
|
974
|
+
const negativeFull = mountRibbon({
|
|
975
|
+
domain: negativeDomain,
|
|
976
|
+
minWindow: 1e-13,
|
|
977
|
+
window: [-1, -1 + 1e-12],
|
|
978
|
+
});
|
|
979
|
+
expect(
|
|
980
|
+
negativeFull.querySelector(".hk-range-ribbon-reset"),
|
|
981
|
+
"the full negative-origin axis needs no reset",
|
|
982
|
+
).toBeNull();
|
|
983
|
+
expect(
|
|
984
|
+
keydown(negativeFull, "Escape").defaultPrevented,
|
|
985
|
+
"a full-axis Escape passes through to the host",
|
|
986
|
+
).toBe(false);
|
|
987
|
+
|
|
988
|
+
const negativeHalf = mountRibbon({
|
|
989
|
+
domain: negativeDomain,
|
|
990
|
+
minWindow: 1e-13,
|
|
991
|
+
window: [-1, -1 + 5e-13],
|
|
992
|
+
});
|
|
993
|
+
expect(
|
|
994
|
+
negativeHalf.querySelector(".hk-range-ribbon-reset"),
|
|
995
|
+
"half of the negative-origin axis is a real sub-selection",
|
|
996
|
+
).toBeTruthy();
|
|
997
|
+
});
|
|
998
|
+
|
|
905
999
|
it("cancels an in-flight brush with Escape and consumes the key", async () => {
|
|
906
1000
|
const host = mountHost(null);
|
|
907
1001
|
const { strip, x } = stubStrip(host.root);
|
|
@@ -1165,6 +1259,52 @@ describe("HkRangeRibbon tip card", () => {
|
|
|
1165
1259
|
expect(host.root.querySelector(".hk-range-ribbon-tip")).toBeNull();
|
|
1166
1260
|
});
|
|
1167
1261
|
|
|
1262
|
+
it("does not resurrect a tip for a segment that left the axis", async () => {
|
|
1263
|
+
// The no-window tip gate: when the domain re-widens, a stale tip id must
|
|
1264
|
+
// not re-open a card for a segment that had left the axis.
|
|
1265
|
+
const container = document.createElement("div");
|
|
1266
|
+
document.body.appendChild(container);
|
|
1267
|
+
const liveDomain = ref<[number, number]>([0, 1]);
|
|
1268
|
+
const Wrapper = defineComponent({
|
|
1269
|
+
setup() {
|
|
1270
|
+
return () =>
|
|
1271
|
+
h(HkRangeRibbon, {
|
|
1272
|
+
lanes: LANES,
|
|
1273
|
+
segments: SEGMENTS,
|
|
1274
|
+
domain: liveDomain.value,
|
|
1275
|
+
window: null,
|
|
1276
|
+
});
|
|
1277
|
+
},
|
|
1278
|
+
});
|
|
1279
|
+
const app = createApp(Wrapper);
|
|
1280
|
+
app.mount(container);
|
|
1281
|
+
mounts.push({ app, container });
|
|
1282
|
+
const root = container.querySelector<HTMLElement>(".hk-range-ribbon")!;
|
|
1283
|
+
|
|
1284
|
+
// Hover flush (0.95..1) so its tip card floats.
|
|
1285
|
+
const bar = segmentById(root, "flush")!;
|
|
1286
|
+
expect(bar, "flush paints on the unit axis").toBeTruthy();
|
|
1287
|
+
bar.dispatchEvent(new MouseEvent("mouseenter"));
|
|
1288
|
+
await nextTick();
|
|
1289
|
+
expect(bar.querySelector(".hk-range-ribbon-tip"), "the tip floats on hover").toBeTruthy();
|
|
1290
|
+
|
|
1291
|
+
// Narrow the axis so flush leaves it: the bar unmounts, so the watch has
|
|
1292
|
+
// to drop the tip id (a removed element never fires mouseleave).
|
|
1293
|
+
liveDomain.value = [0.2, 0.8];
|
|
1294
|
+
await nextTick();
|
|
1295
|
+
expect(segmentById(root, "flush"), "flush left the axis").toBeNull();
|
|
1296
|
+
expect(root.querySelector(".hk-range-ribbon-tip"), "no tip without a mounted bar").toBeNull();
|
|
1297
|
+
|
|
1298
|
+
// Widen back: flush returns, but nothing hovered or focused it again.
|
|
1299
|
+
liveDomain.value = [0, 1];
|
|
1300
|
+
await nextTick();
|
|
1301
|
+
expect(segmentById(root, "flush"), "flush is back").toBeTruthy();
|
|
1302
|
+
expect(
|
|
1303
|
+
root.querySelector(".hk-range-ribbon-tip"),
|
|
1304
|
+
"the stale tip must not resurrect without a pointer or focus",
|
|
1305
|
+
).toBeNull();
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1168
1308
|
it("anchors the tip inward at the track edges", async () => {
|
|
1169
1309
|
const segments: RibbonSegment[] = [
|
|
1170
1310
|
{ id: "left", lane: "cpu", start: 0.02, end: 0.12, label: "left", tip: { title: "Left" } },
|
|
@@ -1422,29 +1562,6 @@ describe("HkRangeRibbon dual-layer waterfall", () => {
|
|
|
1422
1562
|
return (fraction / (1 + headroom)) * 100;
|
|
1423
1563
|
}
|
|
1424
1564
|
|
|
1425
|
-
/** The minimap's x of a DOMAIN coordinate / of a domain LENGTH, in percent. */
|
|
1426
|
-
function domainPercent(value: number, domain: [number, number]): number {
|
|
1427
|
-
return ((value - domain[0]) / (domain[1] - domain[0])) * 100;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
function domainWidthPercent(length: number, domain: [number, number]): number {
|
|
1431
|
-
return (length / (domain[1] - domain[0])) * 100;
|
|
1432
|
-
}
|
|
1433
|
-
|
|
1434
|
-
/** Inline box assertions at the geometry tolerance. `percent()` rounds to
|
|
1435
|
-
* four decimals OF A PERCENT (~1e-6 fraction), so a four-decimal
|
|
1436
|
-
* `toBeCloseTo` sits exactly on that rounding boundary; 3 keeps a margin
|
|
1437
|
-
* while still pinning well below a percent. */
|
|
1438
|
-
function expectBox(
|
|
1439
|
-
element: HTMLElement | null,
|
|
1440
|
-
expected: { left: number; width: number },
|
|
1441
|
-
label: string,
|
|
1442
|
-
): void {
|
|
1443
|
-
expect(element, label).toBeTruthy();
|
|
1444
|
-
expect(boxOf(element!).left, `${label} left`).toBeCloseTo(expected.left, 3);
|
|
1445
|
-
expect(boxOf(element!).width, `${label} width`).toBeCloseTo(expected.width, 3);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
1565
|
it("marks both layers explicitly and keeps the lane markup intact", () => {
|
|
1449
1566
|
const root = mountRibbon({ window: [0.35, 0.67] });
|
|
1450
1567
|
const minimap = root.querySelector<HTMLElement>(".hk-range-ribbon-overview");
|
|
@@ -107,14 +107,21 @@ type WindowRange = [number, number];
|
|
|
107
107
|
const MIN_WINDOW_FALLBACK = 0.02;
|
|
108
108
|
/** Keyboard nudge as a fraction of the AXIS SPAN for move and resize. */
|
|
109
109
|
const KEYBOARD_STEP = 0.05;
|
|
110
|
-
/** Slack for "did this interaction change anything" comparisons.
|
|
110
|
+
/** Slack for "did this interaction change anything" comparisons. Window to
|
|
111
|
+
* window (`windowsMatch`) is absolute — both sides live on the same axis —
|
|
112
|
+
* while the full-domain test scales it by the axis SPAN, so a tiny axis can
|
|
113
|
+
* still tell its full view from a hairline window. */
|
|
111
114
|
const WINDOW_EPSILON = 1e-9;
|
|
112
115
|
|
|
113
|
-
/** An ordered pair of caller coordinates: the axis everything is drawn on.
|
|
114
|
-
|
|
116
|
+
/** An ordered pair of caller coordinates: the axis everything is drawn on.
|
|
117
|
+
* Read-only by contract: the same pair is handed out to every consumer. */
|
|
118
|
+
type DomainRange = readonly [number, number];
|
|
115
119
|
|
|
116
|
-
/** The fallback axis: the familiar 0%..100% reading.
|
|
117
|
-
|
|
120
|
+
/** The fallback axis: the familiar 0%..100% reading. FROZEN because this
|
|
121
|
+
* exact array is handed out by `resolveDomain`, stored in the `domain`
|
|
122
|
+
* computed and used as the normalizer's default — an in-place edit would
|
|
123
|
+
* corrupt every ribbon on the page. */
|
|
124
|
+
const UNIT_DOMAIN: DomainRange = Object.freeze([0, 1] as const);
|
|
118
125
|
|
|
119
126
|
function clampTo(value: number, lo: number, hi: number): number {
|
|
120
127
|
return value < lo ? lo : value > hi ? hi : value;
|
|
@@ -550,7 +557,10 @@ export default defineComponent({
|
|
|
550
557
|
* close while the bar held focus). */
|
|
551
558
|
function isFullDomain(win: WindowRange | null): boolean {
|
|
552
559
|
const [d0, d1] = domain.value;
|
|
553
|
-
|
|
560
|
+
// Span-relative: an absolute slack would call every window "full" on an
|
|
561
|
+
// axis shorter than ~2e-9. On the unit axis this is the historical 1e-9.
|
|
562
|
+
const eps = WINDOW_EPSILON * (d1 - d0);
|
|
563
|
+
return win !== null && win[0] <= d0 + eps && win[1] >= d1 - eps;
|
|
554
564
|
}
|
|
555
565
|
|
|
556
566
|
/** Returns true when a window actually existed AND was narrower than the
|