@celestia-island/hikari 0.55.287 → 0.55.288
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/HkCornerBadge.scss +49 -13
- package/src/components/HkCornerBadge.styles.test.ts +25 -7
- package/src/components/HkCornerBadge.test.tsx +18 -3
- package/src/components/HkCornerBadge.tsx +45 -16
- package/src/components/HkFilterTrigger.tsx +1 -1
- package/src/components/HkSearchTrigger.tsx +1 -1
- package/src/components/HkTabs.test.ts +31 -8
- package/src/components/HkTabs.tsx +39 -30
- package/src/index.ts +1 -1
- package/src/scene3d/holographicFleetBody.test.ts +149 -30
- package/src/scene3d/holographicFleetBody.ts +140 -36
- package/src/scene3d/holographicFleetScene.ts +1 -1
package/package.json
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HkCornerBadge — the generic
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* with an optional
|
|
2
|
+
* HkCornerBadge — the generic anchored mark (dot / count / label) for the
|
|
3
|
+
* default-slot anchor (see HkCornerBadge.tsx for the contract). Geometry
|
|
4
|
+
* has TWO placement families, selected by `data-placement`:
|
|
5
|
+
*
|
|
6
|
+
* corners (bottom-right / …) OVERLAY the anchor: the anchor is a
|
|
7
|
+
* relative inline-flex wrapper (the badge escapes nothing), the mark
|
|
8
|
+
* centers ON the corner via translate(±50%) with an optional
|
|
9
|
+
* --hk-corner-badge-offset clearance — the subscript mark;
|
|
10
|
+
*
|
|
11
|
+
* sides (`right` / `left`) put the mark in the wrapper's FLOW beside the
|
|
12
|
+
* anchor, vertically centered, so the anchor's own box (and text) is
|
|
13
|
+
* never covered. The wrapper is already a row; the mark spaces ITSELF
|
|
14
|
+
* (margin = --hk-corner-badge-gap + --hk-corner-badge-offset, default
|
|
15
|
+
* 6px + 0), so a multi-child anchor's own layout is never touched.
|
|
9
16
|
*/
|
|
10
17
|
|
|
11
18
|
.hk-corner-badge-anchor {
|
|
@@ -15,41 +22,70 @@
|
|
|
15
22
|
|
|
16
23
|
.hk-corner-badge {
|
|
17
24
|
position: absolute;
|
|
18
|
-
/*
|
|
19
|
-
*
|
|
25
|
+
/* Corner placements center ON the corner; the side placement rules
|
|
26
|
+
* below return the mark to the flow (static, no transform). */
|
|
20
27
|
transform: translate(var(--hk-corner-badge-tx, 50%), var(--hk-corner-badge-ty, -50%));
|
|
21
28
|
pointer-events: none;
|
|
22
29
|
white-space: nowrap;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
|
-
.hk-corner-badge[data-
|
|
32
|
+
.hk-corner-badge[data-placement="top-right"] {
|
|
26
33
|
top: var(--hk-corner-badge-offset, 0px);
|
|
27
34
|
right: var(--hk-corner-badge-offset, 0px);
|
|
28
35
|
--hk-corner-badge-tx: 50%;
|
|
29
36
|
--hk-corner-badge-ty: -50%;
|
|
30
37
|
}
|
|
31
38
|
|
|
32
|
-
.hk-corner-badge[data-
|
|
39
|
+
.hk-corner-badge[data-placement="top-left"] {
|
|
33
40
|
top: var(--hk-corner-badge-offset, 0px);
|
|
34
41
|
left: var(--hk-corner-badge-offset, 0px);
|
|
35
42
|
--hk-corner-badge-tx: -50%;
|
|
36
43
|
--hk-corner-badge-ty: -50%;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
.hk-corner-badge[data-
|
|
46
|
+
.hk-corner-badge[data-placement="bottom-right"] {
|
|
40
47
|
bottom: var(--hk-corner-badge-offset, 0px);
|
|
41
48
|
right: var(--hk-corner-badge-offset, 0px);
|
|
42
49
|
--hk-corner-badge-tx: 50%;
|
|
43
50
|
--hk-corner-badge-ty: 50%;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
.hk-corner-badge[data-
|
|
53
|
+
.hk-corner-badge[data-placement="bottom-left"] {
|
|
47
54
|
bottom: var(--hk-corner-badge-offset, 0px);
|
|
48
55
|
left: var(--hk-corner-badge-offset, 0px);
|
|
49
56
|
--hk-corner-badge-tx: -50%;
|
|
50
57
|
--hk-corner-badge-ty: 50%;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/* Side placements: the mark rides the wrapper's row. `right` stays after
|
|
61
|
+
* the anchor (DOM order), `left` flips ahead of it; `align-self` centers
|
|
62
|
+
* the mark on the row and the mark's own margin spaces it from the anchor,
|
|
63
|
+
* so the anchor's other children keep their default alignment. The flex
|
|
64
|
+
* row follows the writing direction, so an RTL host mirrors the side for
|
|
65
|
+
* free (the house flex-row convention, see HkNumberSteppers). The two
|
|
66
|
+
* sides are written out separately (rather than one shared rule) so each
|
|
67
|
+
* side's block stays a single, greppable declaration. */
|
|
68
|
+
.hk-corner-badge[data-placement="right"] {
|
|
69
|
+
position: static;
|
|
70
|
+
transform: none;
|
|
71
|
+
align-self: center;
|
|
72
|
+
margin-inline-start: calc(
|
|
73
|
+
var(--hk-corner-badge-gap, var(--space-6, 6px)) +
|
|
74
|
+
var(--hk-corner-badge-offset, 0px)
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.hk-corner-badge[data-placement="left"] {
|
|
79
|
+
position: static;
|
|
80
|
+
transform: none;
|
|
81
|
+
align-self: center;
|
|
82
|
+
order: -1;
|
|
83
|
+
margin-inline-end: calc(
|
|
84
|
+
var(--hk-corner-badge-gap, var(--space-6, 6px)) +
|
|
85
|
+
var(--hk-corner-badge-offset, 0px)
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
/* Dot form: a filled circle in the variant's tone (same token trio
|
|
54
90
|
* HkBadge uses — variant classes below set all three channels). */
|
|
55
91
|
.hk-corner-badge--dot {
|
|
@@ -7,9 +7,14 @@ import { describe, expect, it } from "vitest";
|
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Compiled-stylesheet guard for HkCornerBadge — the anchoring contract
|
|
10
|
-
* (2026-10-15
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* (2026-10-15, two placement families since 2026-11-05): the anchor
|
|
11
|
+
* wrapper is a relative inline-flex host (any child can carry the mark);
|
|
12
|
+
* the four CORNER placements overlay it, centering ON the corner via
|
|
13
|
+
* translate(±50%,±50%) with each data-placement flipping the right axes;
|
|
14
|
+
* the SIDE placements (`right` / `left`) return the mark to the flow,
|
|
15
|
+
* centered on the wrapper's row by `align-self: center`, and the mark's
|
|
16
|
+
* own margin spaces it from the anchor (gap + offset), so the anchor's
|
|
17
|
+
* own box — and its other children's alignment — is never touched.
|
|
13
18
|
* Vitest stubs CSS, so the runtime suite can never catch a lost rule.
|
|
14
19
|
*/
|
|
15
20
|
|
|
@@ -46,10 +51,23 @@ describe("HkCornerBadge stylesheet contract", () => {
|
|
|
46
51
|
});
|
|
47
52
|
|
|
48
53
|
it("each corner flips the translate axes", () => {
|
|
49
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
50
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
51
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
52
|
-
expect(ruleFor('.hk-corner-badge[data-
|
|
54
|
+
expect(ruleFor('.hk-corner-badge[data-placement=top-right] {')).toContain("--hk-corner-badge-tx: 50%");
|
|
55
|
+
expect(ruleFor('.hk-corner-badge[data-placement=top-left] {')).toContain("--hk-corner-badge-tx: -50%");
|
|
56
|
+
expect(ruleFor('.hk-corner-badge[data-placement=bottom-right] {')).toContain("--hk-corner-badge-ty: 50%");
|
|
57
|
+
expect(ruleFor('.hk-corner-badge[data-placement=bottom-left] {')).toContain("--hk-corner-badge-tx: -50%");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("side placements return to the flow and space themselves from the anchor", () => {
|
|
61
|
+
const right = ruleFor('.hk-corner-badge[data-placement=right] {');
|
|
62
|
+
expect(right).toContain("position: static");
|
|
63
|
+
expect(right).toContain("transform: none");
|
|
64
|
+
expect(right).toContain("align-self: center");
|
|
65
|
+
expect(right).toContain("margin-inline-start: calc(var(--hk-corner-badge-gap");
|
|
66
|
+
const left = ruleFor('.hk-corner-badge[data-placement=left] {');
|
|
67
|
+
expect(left).toContain("position: static");
|
|
68
|
+
expect(left).toContain("align-self: center");
|
|
69
|
+
expect(left).toContain("order: -1");
|
|
70
|
+
expect(left).toContain("margin-inline-end: calc(var(--hk-corner-badge-gap");
|
|
53
71
|
});
|
|
54
72
|
|
|
55
73
|
it("the dot is round and the pill is a tabular-numbers chip", () => {
|
|
@@ -21,7 +21,7 @@ afterEach(() => {
|
|
|
21
21
|
}
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
describe("HkCornerBadge — the generic
|
|
24
|
+
describe("HkCornerBadge — the generic anchored mark (2026-10-15)", () => {
|
|
25
25
|
it("anchors the default slot in a relative wrapper with no mark by default", () => {
|
|
26
26
|
const c = mount(h(HkCornerBadge, null, () => h("button", { class: "probe" }, "x")));
|
|
27
27
|
const anchor = c.querySelector(".hk-corner-badge-anchor")!;
|
|
@@ -32,19 +32,34 @@ describe("HkCornerBadge — the generic corner mark (2026-10-15)", () => {
|
|
|
32
32
|
expect(c.querySelector(".hk-corner-badge")).toBeNull();
|
|
33
33
|
});
|
|
34
34
|
|
|
35
|
+
it("defaults to the subscript overlay (bottom-right) exactly like an avatar mark", () => {
|
|
36
|
+
const c = mount(h(HkCornerBadge, { count: 2 }, () => h("i")));
|
|
37
|
+
const anchor = c.querySelector(".hk-corner-badge-anchor")!;
|
|
38
|
+
expect(anchor.getAttribute("data-placement")).toBe("bottom-right");
|
|
39
|
+
expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("bottom-right");
|
|
40
|
+
});
|
|
41
|
+
|
|
35
42
|
it("dot form: a variant-colored dot at the requested corner, decorative by default", () => {
|
|
36
43
|
const c = mount(
|
|
37
|
-
h(HkCornerBadge, { dot: true, variant: "success",
|
|
44
|
+
h(HkCornerBadge, { dot: true, variant: "success", placement: "top-right" }, () => h("i")),
|
|
38
45
|
);
|
|
39
46
|
const badge = c.querySelector(".hk-corner-badge")!;
|
|
40
47
|
expect(badge).toBeTruthy();
|
|
41
48
|
expect(badge.className).toContain("hk-corner-badge--dot");
|
|
42
49
|
expect(badge.className).toContain("hk-corner-badge-success");
|
|
43
|
-
expect(badge.getAttribute("data-
|
|
50
|
+
expect(badge.getAttribute("data-placement")).toBe("top-right");
|
|
44
51
|
expect(badge.getAttribute("aria-hidden")).toBe("true");
|
|
45
52
|
expect(badge.getAttribute("role")).toBeNull();
|
|
46
53
|
});
|
|
47
54
|
|
|
55
|
+
it("side placements carry the same data attribute on anchor and mark (button-group default)", () => {
|
|
56
|
+
const c = mount(
|
|
57
|
+
h(HkCornerBadge, { count: 4, placement: "right" }, () => h("span", "Active")),
|
|
58
|
+
);
|
|
59
|
+
expect(c.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
|
|
60
|
+
expect(c.querySelector(".hk-corner-badge")!.getAttribute("data-placement")).toBe("right");
|
|
61
|
+
});
|
|
62
|
+
|
|
48
63
|
it("count form: renders the number, caps past max, hides zero unless showZero", () => {
|
|
49
64
|
const c = mount(h(HkCornerBadge, { count: 7 }, () => h("i")));
|
|
50
65
|
expect(c.querySelector(".hk-corner-badge")!.textContent).toBe("7");
|
|
@@ -13,17 +13,41 @@ export type CornerBadgeVariant =
|
|
|
13
13
|
| "info"
|
|
14
14
|
| "muted";
|
|
15
15
|
|
|
16
|
-
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Where the mark sits relative to the anchor — TWO families, not just
|
|
18
|
+
* corners (user direction 2026-11-05: a corner overlay on every anchor
|
|
19
|
+
* was wrong; "(n)" replaced by a badge must not cover the trigger's own
|
|
20
|
+
* label).
|
|
21
|
+
*
|
|
22
|
+
* - `top-right` / `top-left` / `bottom-right` / `bottom-left` OVERLAY the
|
|
23
|
+
* anchor: the mark centers ON the anchor's edge (half of it straddles
|
|
24
|
+
* the corner) — the classic subscript / notification mark for a fixed
|
|
25
|
+
* box (an avatar, an icon button).
|
|
26
|
+
* - `right` / `left` place the mark BESIDE the anchor in the flow,
|
|
27
|
+
* vertically centered, so the anchor's own box (and text) is never
|
|
28
|
+
* covered; the wrapper grows to fit both — the button-group / tab
|
|
29
|
+
* trigger default.
|
|
30
|
+
*
|
|
31
|
+
* Scenario defaults follow the same line: an anchor that can grow (a
|
|
32
|
+
* trigger inside a button group / tab strip) wants a side placement, a
|
|
33
|
+
* fixed-box anchor (an avatar) wants the bottom-right overlay. The
|
|
34
|
+
* component default is `bottom-right` (the subscript mark); hosts whose
|
|
35
|
+
* scenario is a button group pass `right` as their own default (HkTabs).
|
|
36
|
+
*/
|
|
37
|
+
export type CornerBadgePlacement =
|
|
38
|
+
| "top-right"
|
|
39
|
+
| "top-left"
|
|
40
|
+
| "bottom-right"
|
|
41
|
+
| "bottom-left"
|
|
42
|
+
| "right"
|
|
43
|
+
| "left";
|
|
21
44
|
|
|
22
45
|
/**
|
|
23
|
-
* HkCornerBadge — the generic
|
|
46
|
+
* HkCornerBadge — the generic anchored mark for ANY anchor (user
|
|
24
47
|
* direction 2026-10-15): a green/red/traffic dot, a notification count
|
|
25
|
-
* ("9+"), or a custom text label pinned to
|
|
26
|
-
* in the default slot. The Vue
|
|
48
|
+
* ("9+"), or a custom text label pinned to a corner of — or beside —
|
|
49
|
+
* whatever sits in the default slot (see CornerBadgePlacement). The Vue
|
|
50
|
+
* package had every consumer hand-rolling
|
|
27
51
|
* its own absolute span (the filter/search trigger dots, the icon-group
|
|
28
52
|
* marker, chest's dock time-range pill); this is the one component they
|
|
29
53
|
* converge on — API parity with the Rust family's badge.rs (dot / count
|
|
@@ -48,13 +72,18 @@ export default defineComponent({
|
|
|
48
72
|
* ANCHOR, never the label — overloading it made every anchored
|
|
49
73
|
* child's own text leak into the badge. */
|
|
50
74
|
text: { type: String, default: undefined },
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
75
|
+
/** Where the mark hangs (see CornerBadgePlacement). The four corners
|
|
76
|
+
* overlay the anchor; `right` / `left` sit beside it, vertically
|
|
77
|
+
* centered and never covering it. Default `bottom-right` = the
|
|
78
|
+
* subscript mark (avatar / icon). Button-group hosts (HkTabs)
|
|
79
|
+
* default to `right` instead. */
|
|
80
|
+
placement: {
|
|
81
|
+
type: String as PropType<CornerBadgePlacement>,
|
|
82
|
+
default: "bottom-right",
|
|
55
83
|
},
|
|
56
|
-
/** Extra clearance
|
|
57
|
-
* the translate(50
|
|
84
|
+
/** Extra clearance in px. Corner placements: clearance from the
|
|
85
|
+
* corner (0 = the mark centers ON it via translate(±50%,±50%));
|
|
86
|
+
* side placements: the gap beyond the shared --hk-corner-badge-gap. */
|
|
58
87
|
offset: { type: Number, default: 0 },
|
|
59
88
|
/** Accessible name for the mark (without it the badge is
|
|
60
89
|
* aria-hidden decoration). */
|
|
@@ -73,7 +102,7 @@ export default defineComponent({
|
|
|
73
102
|
const show = computed(() => props.dot || text.value !== null);
|
|
74
103
|
|
|
75
104
|
return () => (
|
|
76
|
-
<span class="hk-corner-badge-anchor">
|
|
105
|
+
<span class="hk-corner-badge-anchor" data-placement={props.placement}>
|
|
77
106
|
{slots.default?.()}
|
|
78
107
|
{show.value && (
|
|
79
108
|
<span
|
|
@@ -82,7 +111,7 @@ export default defineComponent({
|
|
|
82
111
|
`hk-corner-badge-${props.variant}`,
|
|
83
112
|
props.dot ? "hk-corner-badge--dot" : "hk-corner-badge--pill",
|
|
84
113
|
]}
|
|
85
|
-
data-
|
|
114
|
+
data-placement={props.placement}
|
|
86
115
|
style={props.offset ? { "--hk-corner-badge-offset": `${props.offset}px` } : undefined}
|
|
87
116
|
aria-hidden={props.label ? undefined : "true"}
|
|
88
117
|
role={props.label ? "img" : undefined}
|
|
@@ -68,7 +68,7 @@ export default defineComponent({
|
|
|
68
68
|
const label = props.label ?? t("hikari::filterBar.trigger", "Filters");
|
|
69
69
|
return (
|
|
70
70
|
<span ref={anchorRef} class="hk-filter-trigger">
|
|
71
|
-
<HkCornerBadge dot={active.value} variant="success"
|
|
71
|
+
<HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
|
|
72
72
|
<HkIconButton
|
|
73
73
|
size={chromeSize.value}
|
|
74
74
|
variant="ghost"
|
|
@@ -96,7 +96,7 @@ const HkSearchTrigger = defineComponent({
|
|
|
96
96
|
{/* The active-keyword marker rides the generic corner badge —
|
|
97
97
|
* the same green bottom-right dot the filter trigger wears
|
|
98
98
|
* (2026-10-15: was a hand-rolled primary dot, top-right). */}
|
|
99
|
-
<HkCornerBadge dot={active.value} variant="success"
|
|
99
|
+
<HkCornerBadge dot={active.value} variant="success" placement="bottom-right" offset={1}>
|
|
100
100
|
<HkIconButton
|
|
101
101
|
size={chromeSize.value}
|
|
102
102
|
variant="ghost"
|
|
@@ -691,24 +691,47 @@ describe("HkTabs trigger corner badge", () => {
|
|
|
691
691
|
return { container };
|
|
692
692
|
}
|
|
693
693
|
|
|
694
|
-
it("renders the count through HkCornerBadge
|
|
694
|
+
it("renders the count through HkCornerBadge BESIDE the label (side default, no hand-rolled suffix)", () => {
|
|
695
695
|
const { container } = mountBadge({ count: 3, label: "3 items" });
|
|
696
696
|
const trig = container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[0]!;
|
|
697
|
-
// The mark is the family's
|
|
698
|
-
//
|
|
697
|
+
// The mark is the family's pill, placed on the BUTTON-GROUP default:
|
|
698
|
+
// `right` — beside the label, vertically centered, never covering
|
|
699
|
+
// it (user direction 2026-11-05: the corner overlay read as "歪了/
|
|
700
|
+
// 盖住了" on a tab trigger). Not a "(3)" text suffix either.
|
|
699
701
|
const mark = trig.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
700
702
|
expect(mark).not.toBeNull();
|
|
701
703
|
expect(mark.textContent).toBe("3");
|
|
702
|
-
expect(mark.dataset.
|
|
704
|
+
expect(mark.dataset.placement).toBe("right");
|
|
705
|
+
expect(trig.querySelector(".hk-corner-badge-anchor")!.getAttribute("data-placement")).toBe("right");
|
|
703
706
|
expect(trig.textContent).not.toContain("(3)");
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
|
|
707
|
-
|
|
707
|
+
// Side placement needs no corner clearance: the wrapper gap is the
|
|
708
|
+
// spacing, so the tab default writes no offset var.
|
|
709
|
+
expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("");
|
|
710
|
+
// Side flow order: the label comes first, the mark after it.
|
|
711
|
+
const anchor = trig.querySelector(".hk-corner-badge-anchor")!;
|
|
712
|
+
expect(anchor.firstElementChild!.textContent).toBe("Alpha");
|
|
713
|
+
expect(anchor.lastElementChild).toBe(mark);
|
|
708
714
|
// The sibling trigger stays badge-free.
|
|
709
715
|
expect(container.querySelectorAll<HTMLElement>(".hk-tabs-trigger")[1]!.querySelector(".hk-corner-badge")).toBeNull();
|
|
710
716
|
});
|
|
711
717
|
|
|
718
|
+
it("keeps the explicit corner override, with the corner clearance default", () => {
|
|
719
|
+
// A host that opts back into the overlay must still get the 4px
|
|
720
|
+
// clearance the scroller viewport needs (the placement-aware default),
|
|
721
|
+
// without restating it.
|
|
722
|
+
const { container } = mountBadge({ count: 3, label: "3 items", placement: "bottom-right" });
|
|
723
|
+
const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
724
|
+
expect(mark.dataset.placement).toBe("bottom-right");
|
|
725
|
+
expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("4px");
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("honours an explicit offset in either placement family", () => {
|
|
729
|
+
const side = mountBadge({ count: 3, label: "3 items", offset: 2 });
|
|
730
|
+
const mark = side.container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
731
|
+
expect(mark.dataset.placement).toBe("right");
|
|
732
|
+
expect(mark.style.getPropertyValue("--hk-corner-badge-offset")).toBe("2px");
|
|
733
|
+
});
|
|
734
|
+
|
|
712
735
|
it("keeps zero visible (presence count, not notification noise)", () => {
|
|
713
736
|
const { container } = mountBadge({ count: 0, label: "0 items" });
|
|
714
737
|
const mark = container.querySelector<HTMLElement>(".hk-corner-badge--pill")!;
|
|
@@ -6,7 +6,7 @@ import HkHoverRevealAction from "./HkHoverRevealAction";
|
|
|
6
6
|
import HkIconButton from "./HkIconButton";
|
|
7
7
|
import HkTransitionContainer from "./HkTransitionContainer";
|
|
8
8
|
import HkCornerBadge from "./HkCornerBadge";
|
|
9
|
-
import type {
|
|
9
|
+
import type { CornerBadgePlacement, CornerBadgeVariant } from "./HkCornerBadge";
|
|
10
10
|
import { iconByName } from "../composables/iconRegistry";
|
|
11
11
|
import { useI18n } from "../i18n/context";
|
|
12
12
|
import { clearLeaveGeometry, pinLeaveGeometry, type LeaveBoxSnapshot } from "../utils/dom";
|
|
@@ -20,34 +20,34 @@ export interface TabItem {
|
|
|
20
20
|
/** Optional icon vnode rendered before the label (the `icon-${key}`
|
|
21
21
|
* slot wins when provided). */
|
|
22
22
|
icon?: unknown;
|
|
23
|
-
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* suffix it replaces must at least be loud). */
|
|
23
|
+
/** Count badge rendered on the trigger through HkCornerBadge — the ONE
|
|
24
|
+
* sup/sub decoration shell (user direction 2026-11-05: hand-rolled
|
|
25
|
+
* "(n)" count suffixes baked into labels are replaced by the standard
|
|
26
|
+
* mark, and triggers are badge hosts like every other button). Zero
|
|
27
|
+
* stays visible: a tab count is presence information (the "(0)" the
|
|
28
|
+
* inline suffix carried), not notification noise — pass no badge to
|
|
29
|
+
* hide it entirely. The badge anchors the LABEL, so a tab with an
|
|
30
|
+
* empty label renders no badge either; pass `label` (an accessible
|
|
31
|
+
* name like "{n} active") or the mark is aria-hidden decoration and
|
|
32
|
+
* the count is announced nowhere (a DEV warning fires — a silent a11y
|
|
33
|
+
* regression must at least be loud). */
|
|
35
34
|
badge?: {
|
|
36
35
|
count: number;
|
|
37
36
|
/** "N+" ceiling (HkCornerBadge.max). */
|
|
38
37
|
max?: number;
|
|
39
|
-
/**
|
|
40
|
-
*
|
|
41
|
-
|
|
38
|
+
/** Where the mark hangs (HkCornerBadge.placement). Default `right`:
|
|
39
|
+
* a tab trigger is a BUTTON-GROUP member, so the mark sits BESIDE
|
|
40
|
+
* the label, vertically centered and never covering it (user
|
|
41
|
+
* direction 2026-11-05 — the subscript corner overlay read as
|
|
42
|
+
* "歪了/盖住了"). Pass a corner only when the host really wants an
|
|
43
|
+
* overlay mark on the label. */
|
|
44
|
+
placement?: CornerBadgePlacement;
|
|
42
45
|
/** Tone channel (HkCornerBadge.variant names, default "primary"). */
|
|
43
46
|
variant?: CornerBadgeVariant;
|
|
44
|
-
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* clips — offset 4 keeps the protrusion at 5px, inside that
|
|
49
|
-
* clearance. 0 sits ON the corner and clips ~3px on the default
|
|
50
|
-
* scrollable strip; raise it if your host adds no extra padding. */
|
|
47
|
+
/** Extra clearance in px (HkCornerBadge.offset). Placement-aware
|
|
48
|
+
* default: side marks are already spaced by the wrapper gap, so they
|
|
49
|
+
* default to 0; corner marks keep the historical 4 (the pill
|
|
50
|
+
* straddles the label and the scroller clips ~6px below it). */
|
|
51
51
|
offset?: number;
|
|
52
52
|
/** Accessible name for the mark — strongly expected (DEV-warned
|
|
53
53
|
* when missing). */
|
|
@@ -569,6 +569,13 @@ export default defineComponent({
|
|
|
569
569
|
}
|
|
570
570
|
const active = tab.key === props.modelValue;
|
|
571
571
|
const disabled = tab.disabled;
|
|
572
|
+
// The badge default is placement-aware: a side mark is spaced by
|
|
573
|
+
// the wrapper (offset 0), a corner mark keeps the clearance that
|
|
574
|
+
// keeps the straddling pill inside the scroller viewport.
|
|
575
|
+
const badgePlacement = tab.badge?.placement ?? "right";
|
|
576
|
+
const badgeOffset =
|
|
577
|
+
tab.badge?.offset ??
|
|
578
|
+
(badgePlacement === "right" || badgePlacement === "left" ? 0 : 4);
|
|
572
579
|
// `icon` is a deliberately loose `unknown` on the public item type
|
|
573
580
|
// (hosts may pass vnodes materialized against another vue store);
|
|
574
581
|
// at the render site its documented contract is a render child.
|
|
@@ -599,18 +606,20 @@ export default defineComponent({
|
|
|
599
606
|
{icon}
|
|
600
607
|
{/* The badge anchors the LABEL (inside the trigger's own
|
|
601
608
|
* padding box) rather than the button box: the mark reads
|
|
602
|
-
* as part of the control's silhouette
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
606
|
-
*
|
|
609
|
+
* as part of the control's silhouette. A tab is a
|
|
610
|
+
* BUTTON-GROUP member, so its default is the side placement
|
|
611
|
+
* (`right`, vertically centered): the label keeps its full
|
|
612
|
+
* box and the strip grows by the mark + gap — a corner
|
|
613
|
+
* overlay straddled the label and read as covering it (user
|
|
614
|
+
* report 2026-11-05). Corner placements stay available for
|
|
615
|
+
* hosts that want the overlay on purpose. */}
|
|
607
616
|
{tab.label && (tab.badge ? (
|
|
608
617
|
<HkCornerBadge
|
|
609
618
|
count={tab.badge.count}
|
|
610
619
|
max={tab.badge.max}
|
|
611
620
|
showZero
|
|
612
|
-
|
|
613
|
-
offset={
|
|
621
|
+
placement={badgePlacement}
|
|
622
|
+
offset={badgeOffset}
|
|
614
623
|
variant={tab.badge.variant ?? "primary"}
|
|
615
624
|
label={tab.badge.label}
|
|
616
625
|
>
|
package/src/index.ts
CHANGED
|
@@ -201,7 +201,7 @@ export { default as HLogo } from "./components/HkLogo";
|
|
|
201
201
|
|
|
202
202
|
// Component types
|
|
203
203
|
export { type BadgeVariant } from "./components/HkBadge";
|
|
204
|
-
export { type CornerBadgeVariant } from "./components/HkCornerBadge";
|
|
204
|
+
export { type CornerBadgePlacement, type CornerBadgeVariant } from "./components/HkCornerBadge";
|
|
205
205
|
export { type BoardNodeInput, type BoardEdgeInput } from "./components/HkBoard";
|
|
206
206
|
export { isSafeHref, safeHref } from "./utils/safeHref";
|
|
207
207
|
export { type BoardAnchorMode, type BoardEdgeStyle, type BoardPoint } from "./utils/boardEdges";
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
2
2
|
import * as THREE from "three";
|
|
3
3
|
|
|
4
4
|
import { fakeAs } from "../test-utils/fakes";
|
|
5
|
+
import { HIKARI_FONT_SANS } from "../theme/fontContext";
|
|
5
6
|
import {
|
|
6
7
|
hologramHudText,
|
|
7
8
|
makeFleetHologram,
|
|
@@ -13,24 +14,81 @@ import {
|
|
|
13
14
|
const INPUT: FleetHologramInput = {
|
|
14
15
|
id: "node-1",
|
|
15
16
|
label: "node-1 engine stack",
|
|
16
|
-
status: "Online",
|
|
17
17
|
detail: "3 endpoints",
|
|
18
18
|
role: "planet",
|
|
19
19
|
radius: 1,
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
interface CanvasSpy {
|
|
23
|
+
fillText: ReturnType<typeof vi.fn>;
|
|
24
|
+
/** The font currently on the most recently created 2D context. */
|
|
25
|
+
font: () => string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Font metrics scale with the requested size, like a real canvas: this
|
|
29
|
+
* keeps the fit/ellipsis paths testable instead of always fitting at 64px. */
|
|
30
|
+
function mockFontPx(font: string): number {
|
|
31
|
+
const match = /(\d+)px/.exec(font);
|
|
32
|
+
return match ? Number(match[1]) : 64;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mockCanvas(): CanvasSpy {
|
|
23
36
|
const fillText = vi.fn();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
37
|
+
const stateByCanvas = new WeakMap<HTMLCanvasElement, { font: string }>();
|
|
38
|
+
let current: { font: string } | null = null;
|
|
39
|
+
const widthDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "width");
|
|
40
|
+
const heightDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, "height");
|
|
41
|
+
|
|
42
|
+
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (
|
|
43
|
+
this: HTMLCanvasElement,
|
|
44
|
+
) {
|
|
45
|
+
const state = { font: "" };
|
|
46
|
+
stateByCanvas.set(this, state);
|
|
47
|
+
current = state;
|
|
48
|
+
const gradient = fakeAs<CanvasGradient>({ addColorStop: vi.fn() });
|
|
49
|
+
return fakeAs<CanvasRenderingContext2D>({
|
|
50
|
+
clearRect: vi.fn(),
|
|
51
|
+
fillRect: vi.fn(),
|
|
52
|
+
fillText,
|
|
53
|
+
measureText: (text: string) =>
|
|
54
|
+
fakeAs<TextMetrics>({ width: text.length * mockFontPx(state.font) * 0.45 }),
|
|
55
|
+
createRadialGradient: () => gradient,
|
|
56
|
+
get font() {
|
|
57
|
+
return state.font;
|
|
58
|
+
},
|
|
59
|
+
set font(value: string) {
|
|
60
|
+
state.font = value;
|
|
61
|
+
},
|
|
62
|
+
});
|
|
31
63
|
});
|
|
32
|
-
|
|
33
|
-
|
|
64
|
+
|
|
65
|
+
// A real canvas resets its 2D state (including the font) whenever width
|
|
66
|
+
// or height changes. Model that here so "font applied AFTER the resize"
|
|
67
|
+
// is actually guarded, not just "font assigned once somewhere".
|
|
68
|
+
const resetState = (canvas: HTMLCanvasElement): void => {
|
|
69
|
+
const state = stateByCanvas.get(canvas);
|
|
70
|
+
if (state) state.font = "";
|
|
71
|
+
};
|
|
72
|
+
if (widthDescriptor?.set) {
|
|
73
|
+
vi.spyOn(HTMLCanvasElement.prototype, "width", "set").mockImplementation(function (
|
|
74
|
+
this: HTMLCanvasElement,
|
|
75
|
+
value: number,
|
|
76
|
+
) {
|
|
77
|
+
widthDescriptor.set!.call(this, value);
|
|
78
|
+
resetState(this);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (heightDescriptor?.set) {
|
|
82
|
+
vi.spyOn(HTMLCanvasElement.prototype, "height", "set").mockImplementation(function (
|
|
83
|
+
this: HTMLCanvasElement,
|
|
84
|
+
value: number,
|
|
85
|
+
) {
|
|
86
|
+
heightDescriptor.set!.call(this, value);
|
|
87
|
+
resetState(this);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { fillText, font: () => current?.font ?? "" };
|
|
34
92
|
}
|
|
35
93
|
|
|
36
94
|
afterEach(() => {
|
|
@@ -38,41 +96,55 @@ afterEach(() => {
|
|
|
38
96
|
});
|
|
39
97
|
|
|
40
98
|
describe("hologramHudText", () => {
|
|
41
|
-
it("
|
|
42
|
-
expect(hologramHudText(INPUT)).toBe("node-1 engine stack ·
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
99
|
+
it("carries the label and detail, never a supplied liveness status", () => {
|
|
100
|
+
expect(hologramHudText(INPUT)).toBe("node-1 engine stack · 3 endpoints");
|
|
101
|
+
// `status` is no longer part of the HUD input type; a caller that
|
|
102
|
+
// still spreads the host body must not leak it into the ball.
|
|
103
|
+
const withStatus = { ...INPUT, status: "Offline", detail: "" };
|
|
104
|
+
expect(hologramHudText(withStatus)).toBe("node-1 engine stack");
|
|
105
|
+
expect(hologramHudText({ ...INPUT, label: "", detail: "" })).toBe("node-1");
|
|
47
106
|
});
|
|
48
107
|
});
|
|
49
108
|
|
|
50
109
|
describe("makeFleetHologram", () => {
|
|
51
|
-
it("pins the real-data
|
|
52
|
-
const fillText = mockCanvas();
|
|
110
|
+
it("pins the real-data HUD line to the centre of the front dial", () => {
|
|
111
|
+
const { fillText, font } = mockCanvas();
|
|
53
112
|
const hologram = makeFleetHologram(INPUT, new THREE.Color(0x7fb8dd), false);
|
|
54
113
|
|
|
55
114
|
expect(hologram.hudTexture).not.toBeNull();
|
|
56
115
|
expect(hologram.hudPanel).not.toBeNull();
|
|
57
116
|
expect(hologram.hudPanel!.geometry).toBeInstanceOf(THREE.PlaneGeometry);
|
|
58
|
-
// The
|
|
117
|
+
// The HUD line is a child of the camera-facing dial, AT its centre: the
|
|
59
118
|
// dial owns the plane, so facing the dial faces the text (user
|
|
60
119
|
// direction 2026-10-31 — no wrapping ribbon).
|
|
61
120
|
expect(hologram.frontDial.children).toContain(hologram.hudPanel);
|
|
62
121
|
expect(hologram.hudPanel!.position.x).toBe(0);
|
|
63
122
|
expect(hologram.hudPanel!.position.y).toBe(0);
|
|
64
|
-
// Not vacuous: a blank
|
|
65
|
-
// text when it renders anything at all.
|
|
123
|
+
// Not vacuous: a blank HUD line must fail here, not just render wrong
|
|
124
|
+
// text when it renders anything at all. The third argument is the
|
|
125
|
+
// only legal one — canvas's optional `maxWidth` condenses glyphs and
|
|
126
|
+
// is exactly the 2026-11-05 defect.
|
|
66
127
|
expect(fillText.mock.calls.length).toBeGreaterThan(0);
|
|
67
128
|
for (const call of fillText.mock.calls) {
|
|
68
|
-
expect(call[0]).toBe("node-1 engine stack ·
|
|
129
|
+
expect(call[0]).toBe("node-1 engine stack · 3 endpoints");
|
|
130
|
+
expect(call).toHaveLength(3);
|
|
69
131
|
}
|
|
70
132
|
|
|
71
|
-
// The
|
|
72
|
-
//
|
|
133
|
+
// The canvas is painted in the host's UI sans stack, not a mono face:
|
|
134
|
+
// the chip beside the ball and the in-ball line must read as one UI.
|
|
135
|
+
// The mock clears context state on every canvas resize, so this also
|
|
136
|
+
// proves the font is re-applied AFTER the dimensions are set (the real
|
|
137
|
+
// canvas trap), not merely assigned once before them.
|
|
138
|
+
expect(font()).toBe(`600 64px ${HIKARI_FONT_SANS}`);
|
|
139
|
+
expect(font()).toContain("sans-serif");
|
|
140
|
+
expect(font()).not.toContain("monospace");
|
|
141
|
+
|
|
142
|
+
// The plane's aspect matches the texture tile's own aspect: a
|
|
143
|
+
// mismatched aspect compresses the glyphs horizontally.
|
|
144
|
+
const image = hologram.hudTexture!.image as HTMLCanvasElement;
|
|
73
145
|
const plane = hologram.hudPanel!.geometry as THREE.PlaneGeometry;
|
|
74
146
|
const params = plane.parameters as { width: number; height: number };
|
|
75
|
-
expect(params.width / params.height).toBeCloseTo(
|
|
147
|
+
expect(params.width / params.height).toBeCloseTo(image.width / image.height, 5);
|
|
76
148
|
|
|
77
149
|
// The text plane never widens the body's pointer target.
|
|
78
150
|
const hits: THREE.Intersection[] = [];
|
|
@@ -80,6 +152,52 @@ describe("makeFleetHologram", () => {
|
|
|
80
152
|
expect(hits).toHaveLength(0);
|
|
81
153
|
});
|
|
82
154
|
|
|
155
|
+
it("widens the tile for a long label instead of clipping or condensing", () => {
|
|
156
|
+
const label = "n".repeat(70);
|
|
157
|
+
const { fillText, font } = mockCanvas();
|
|
158
|
+
const hologram = makeFleetHologram(
|
|
159
|
+
{ ...INPUT, label, detail: "" },
|
|
160
|
+
new THREE.Color(),
|
|
161
|
+
false,
|
|
162
|
+
);
|
|
163
|
+
const image = hologram.hudTexture!.image as HTMLCanvasElement;
|
|
164
|
+
// 70 glyphs at 46px is the largest uniform size whose natural width
|
|
165
|
+
// fits the 1504px text box; the tile adds the 96px seam gap.
|
|
166
|
+
expect(font()).toBe(`600 46px ${HIKARI_FONT_SANS}`);
|
|
167
|
+
expect(image.width).toBe(1545);
|
|
168
|
+
const rendered = fillText.mock.calls[0]?.[0] as string;
|
|
169
|
+
expect(rendered).toBe(label);
|
|
170
|
+
expect(fillText.mock.calls[0]).toHaveLength(3);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("ellipsizes when even 40px cannot fit the label", () => {
|
|
174
|
+
const label = "n".repeat(120);
|
|
175
|
+
const { fillText, font } = mockCanvas();
|
|
176
|
+
const hologram = makeFleetHologram(
|
|
177
|
+
{ ...INPUT, label, detail: "" },
|
|
178
|
+
new THREE.Color(),
|
|
179
|
+
false,
|
|
180
|
+
);
|
|
181
|
+
const image = hologram.hudTexture!.image as HTMLCanvasElement;
|
|
182
|
+
expect(font()).toBe(`600 40px ${HIKARI_FONT_SANS}`);
|
|
183
|
+
expect(image.width).toBe(1590);
|
|
184
|
+
const rendered = fillText.mock.calls[0]?.[0] as string;
|
|
185
|
+
expect(rendered.endsWith("…")).toBe(true);
|
|
186
|
+
expect(rendered.length).toBeLessThan(label.length);
|
|
187
|
+
expect(fillText.mock.calls[0]).toHaveLength(3);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("uses a host-provided --font-sans stack for the canvas text", () => {
|
|
191
|
+
document.documentElement.style.setProperty("--font-sans", '"Host Sans", sans-serif');
|
|
192
|
+
try {
|
|
193
|
+
const { font } = mockCanvas();
|
|
194
|
+
makeFleetHologram(INPUT, new THREE.Color(), false);
|
|
195
|
+
expect(font()).toBe('600 64px "Host Sans", sans-serif');
|
|
196
|
+
} finally {
|
|
197
|
+
document.documentElement.style.removeProperty("--font-sans");
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
83
201
|
it("gives main cores denser dial content than node cores", () => {
|
|
84
202
|
mockCanvas();
|
|
85
203
|
const hub = makeFleetHologram({ ...INPUT, role: "star" }, new THREE.Color(), false);
|
|
@@ -311,10 +429,11 @@ describe("makeFleetHologram", () => {
|
|
|
311
429
|
expect(hologram.selectionRing.quaternion.angleTo(camera.quaternion)).toBeCloseTo(0, 6);
|
|
312
430
|
// …the dial's ticks sweep…
|
|
313
431
|
expect(hologram.frontSpinner.rotation.z).toBeCloseTo(0.55, 6);
|
|
314
|
-
// …and the
|
|
315
|
-
|
|
432
|
+
// …and the measured HUD line stays put at the dial's centre: its
|
|
433
|
+
// band already fits the label, so a scroll would only slice it.
|
|
434
|
+
expect(hologram.hudTexture?.offset.x).toBe(0);
|
|
316
435
|
poseFleetHologram(hologram, 10, 20, camera.quaternion);
|
|
317
|
-
expect(hologram.hudTexture?.offset.x).
|
|
436
|
+
expect(hologram.hudTexture?.offset.x).toBe(0);
|
|
318
437
|
});
|
|
319
438
|
});
|
|
320
439
|
|
|
@@ -323,7 +442,7 @@ describe("makeSatelliteHologram", () => {
|
|
|
323
442
|
const satellite = makeSatelliteHologram(0.2, new THREE.Color(0x9fb8dd), false);
|
|
324
443
|
|
|
325
444
|
// Endpoint tier: three crossed support dials plus a three-ring
|
|
326
|
-
// spindle — the gateway's language, minimal content, no
|
|
445
|
+
// spindle — the gateway's language, minimal content, no HUD line.
|
|
327
446
|
expect(satellite.getObjectsByProperty("name", "fleet-hologram-face-dial"))
|
|
328
447
|
.toHaveLength(3);
|
|
329
448
|
expect(satellite.getObjectsByProperty("name", "fleet-hologram-ring-spindle-circle"))
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* holographicFleetBody — one node's hologram: a spherical shell built from
|
|
3
3
|
* six overlapping face dials plus a fat-centre ring spindle, with ONE
|
|
4
|
-
* detailed camera-facing dial at the front carrying the real-data
|
|
4
|
+
* detailed camera-facing dial at the front carrying the real-data HUD line.
|
|
5
5
|
*
|
|
6
6
|
* User direction (2026-10-31): the body must read as a ball of circles
|
|
7
7
|
* from every side — no polyhedron, no solid sphere, no shared wireframe,
|
|
@@ -10,15 +10,23 @@
|
|
|
10
10
|
* get a miniature crossed-dial shell with no text. Every decorative line
|
|
11
11
|
* stays out of raycast picking; the invisible centre proxy remains the
|
|
12
12
|
* node's pointer target.
|
|
13
|
+
*
|
|
14
|
+
* User direction (2026-11-05): the HUD line inside the ball is set in the
|
|
15
|
+
* host's sans UI stack at natural glyph advances — never condensed by
|
|
16
|
+
* canvas — and carries descriptive data only (no liveness wording such as
|
|
17
|
+
* "Online"; that stays on the chip and the selection card). The band is
|
|
18
|
+
* measured from the line and sits still, so nothing is sliced at a wrap
|
|
19
|
+
* seam either.
|
|
13
20
|
*/
|
|
14
21
|
|
|
15
22
|
import * as THREE from "three";
|
|
16
23
|
|
|
24
|
+
import { HIKARI_FONT_SANS } from "../theme/fontContext";
|
|
17
25
|
import type { FleetBodySpec } from "./holographicFleetSpec";
|
|
18
26
|
|
|
19
27
|
export type FleetHologramInput = Pick<
|
|
20
28
|
FleetBodySpec,
|
|
21
|
-
"id" | "label" | "
|
|
29
|
+
"id" | "label" | "detail" | "role" | "radius"
|
|
22
30
|
>;
|
|
23
31
|
|
|
24
32
|
export interface FleetHologram {
|
|
@@ -27,26 +35,115 @@ export interface FleetHologram {
|
|
|
27
35
|
mesh: THREE.Mesh;
|
|
28
36
|
/** Six faint overlapping face dials: the spherical shell. */
|
|
29
37
|
shell: THREE.Group;
|
|
30
|
-
/** The detailed camera-facing dial that carries the real-data
|
|
38
|
+
/** The detailed camera-facing dial that carries the real-data HUD line. */
|
|
31
39
|
frontDial: THREE.Group;
|
|
32
40
|
/** Sweeping layer inside the front dial (ticks and arcs rotate here). */
|
|
33
41
|
frontSpinner: THREE.Group;
|
|
34
42
|
/** Circular focus mark, shown while the body is selected. */
|
|
35
43
|
selectionRing: THREE.Group;
|
|
36
|
-
/** The real-data
|
|
44
|
+
/** The real-data HUD plane at the front dial's centre. */
|
|
37
45
|
hudPanel: THREE.Mesh | null;
|
|
38
46
|
/** Null only when the host cannot create a 2D canvas (SSR/test DOM). */
|
|
39
47
|
hudTexture: THREE.CanvasTexture | null;
|
|
40
48
|
}
|
|
41
49
|
|
|
42
|
-
/** The single source of truth for the dial
|
|
50
|
+
/** The single source of truth for the dial HUD line's content.
|
|
51
|
+
* Liveness (`status`) is deliberately absent (user direction 2026-11-05):
|
|
52
|
+
* the ball carries the node's descriptive data, while liveness already
|
|
53
|
+
* has a home on the chip and the selection card. */
|
|
43
54
|
export function hologramHudText(input: FleetHologramInput): string {
|
|
44
|
-
return [input.label, input.
|
|
55
|
+
return [input.label, input.detail]
|
|
45
56
|
.map((part) => part.trim())
|
|
46
57
|
.filter((part) => part.length > 0)
|
|
47
58
|
.join(" · ") || input.id;
|
|
48
59
|
}
|
|
49
60
|
|
|
61
|
+
/** HUD tile geometry. The width is measured from the live line: a long
|
|
62
|
+
* label widens the strip instead of condensing the glyphs. */
|
|
63
|
+
const HUD_TILE_HEIGHT = 96;
|
|
64
|
+
/** The plane's scale reference: a 1024px tile maps to a 1.14×radius band,
|
|
65
|
+
* so the glyph height on the ball stays constant as the tile grows. */
|
|
66
|
+
const HUD_REFERENCE_WIDTH = 1024;
|
|
67
|
+
const HUD_MIN_TILE_WIDTH = 640;
|
|
68
|
+
const HUD_MAX_TILE_WIDTH = 1600;
|
|
69
|
+
const HUD_FONT_MAX_PX = 64;
|
|
70
|
+
const HUD_FONT_MIN_PX = 40;
|
|
71
|
+
const HUD_FONT_STEP_PX = 2;
|
|
72
|
+
const HUD_ITEM_GAP_PX = 96;
|
|
73
|
+
|
|
74
|
+
/** The host applies the canonical UI stack as `--font-sans` during
|
|
75
|
+
* bootstrap; reading it keeps canvas text in the same family as the DOM
|
|
76
|
+
* around it (and honours a host font override). The exported stack is
|
|
77
|
+
* only the fallback for SSR / test DOMs without computed styles. */
|
|
78
|
+
function hudFontFamily(): string {
|
|
79
|
+
if (typeof document !== "undefined") {
|
|
80
|
+
try {
|
|
81
|
+
const declared = getComputedStyle(document.documentElement)
|
|
82
|
+
.getPropertyValue("--font-sans")
|
|
83
|
+
.trim();
|
|
84
|
+
if (declared && !/[;{}]/.test(declared) && !/var\(/i.test(declared)) return declared;
|
|
85
|
+
} catch {
|
|
86
|
+
// No computed-style implementation: fall through to the canonical stack.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return HIKARI_FONT_SANS;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function hudFont(fontPx: number, family: string): string {
|
|
93
|
+
return `600 ${fontPx}px ${family}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Cut the line at a code-point boundary when even the smallest HUD size
|
|
97
|
+
* cannot hold it, so an emoji or astral character is never split into a
|
|
98
|
+
* lone surrogate. Never pass the result to a `maxWidth` argument: this
|
|
99
|
+
* function shortens the text, it does not scale glyphs horizontally. */
|
|
100
|
+
function ellipsizeHudText(
|
|
101
|
+
ctx: CanvasRenderingContext2D,
|
|
102
|
+
text: string,
|
|
103
|
+
maxWidth: number,
|
|
104
|
+
): string {
|
|
105
|
+
if (ctx.measureText(text).width <= maxWidth) return text;
|
|
106
|
+
const characters = Array.from(text);
|
|
107
|
+
let low = 0;
|
|
108
|
+
let high = characters.length;
|
|
109
|
+
while (low < high) {
|
|
110
|
+
const mid = Math.ceil((low + high) / 2);
|
|
111
|
+
const candidate = `${characters.slice(0, mid).join("")}…`;
|
|
112
|
+
if (ctx.measureText(candidate).width <= maxWidth) low = mid;
|
|
113
|
+
else high = mid - 1;
|
|
114
|
+
}
|
|
115
|
+
return `${characters.slice(0, low).join("").trimEnd()}…`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
interface HudTextLayout {
|
|
119
|
+
fontPx: number;
|
|
120
|
+
text: string;
|
|
121
|
+
width: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Choose the largest uniform font size that fits the tile, then fall back
|
|
125
|
+
* to a code-point-boundary ellipsis. Uniform scaling preserves every
|
|
126
|
+
* glyph's aspect; only the horizontal `maxWidth` squeeze is banned. */
|
|
127
|
+
function layoutHudText(
|
|
128
|
+
ctx: CanvasRenderingContext2D,
|
|
129
|
+
line: string,
|
|
130
|
+
family: string,
|
|
131
|
+
): HudTextLayout {
|
|
132
|
+
const maxTextWidth = HUD_MAX_TILE_WIDTH - HUD_ITEM_GAP_PX;
|
|
133
|
+
for (let px = HUD_FONT_MAX_PX; px >= HUD_FONT_MIN_PX; px -= HUD_FONT_STEP_PX) {
|
|
134
|
+
ctx.font = hudFont(px, family);
|
|
135
|
+
const width = ctx.measureText(line).width;
|
|
136
|
+
if (width <= maxTextWidth) return { fontPx: px, text: line, width };
|
|
137
|
+
}
|
|
138
|
+
ctx.font = hudFont(HUD_FONT_MIN_PX, family);
|
|
139
|
+
const text = ellipsizeHudText(ctx, line, maxTextWidth);
|
|
140
|
+
return {
|
|
141
|
+
fontPx: HUD_FONT_MIN_PX,
|
|
142
|
+
text,
|
|
143
|
+
width: ctx.measureText(text).width,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
50
147
|
/** Decorative geometry must not become a broad, invisible pointer target. */
|
|
51
148
|
function unpickable<T extends THREE.Object3D>(object: T): T {
|
|
52
149
|
object.raycast = () => undefined;
|
|
@@ -278,49 +375,58 @@ function makeRingSpindle(
|
|
|
278
375
|
|
|
279
376
|
function makeHudTexture(input: FleetHologramInput): THREE.CanvasTexture | null {
|
|
280
377
|
const canvas = document.createElement("canvas");
|
|
281
|
-
canvas.width = 1024;
|
|
282
|
-
canvas.height = 96;
|
|
283
378
|
const ctx = canvas.getContext("2d");
|
|
284
379
|
if (!ctx) return null;
|
|
285
380
|
|
|
381
|
+
const family = hudFontFamily();
|
|
286
382
|
const line = hologramHudText(input);
|
|
287
|
-
ctx
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
383
|
+
const layout = layoutHudText(ctx, line, family);
|
|
384
|
+
const tileWidth = Math.min(
|
|
385
|
+
HUD_MAX_TILE_WIDTH,
|
|
386
|
+
Math.max(HUD_MIN_TILE_WIDTH, Math.ceil(layout.width + HUD_ITEM_GAP_PX)),
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
// Setting the canvas dimensions resets every 2D context state, so the
|
|
390
|
+
// font and paint state go on AFTER the final size is known.
|
|
391
|
+
canvas.width = tileWidth;
|
|
392
|
+
canvas.height = HUD_TILE_HEIGHT;
|
|
393
|
+
ctx.font = hudFont(layout.fontPx, family);
|
|
292
394
|
ctx.textBaseline = "middle";
|
|
395
|
+
ctx.textAlign = "left";
|
|
293
396
|
ctx.fillStyle = "#ffffff";
|
|
294
397
|
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
398
|
+
// One natural-width line, centred in a tile that is exactly the band's
|
|
399
|
+
// width. The band sits still (no offset scroll), so the line is never
|
|
400
|
+
// sliced at a texture seam. There is deliberately NO `maxWidth`
|
|
401
|
+
// argument — that is the canvas feature which condensed long labels
|
|
402
|
+
// and made glyph advances unequal (user direction 2026-11-05).
|
|
403
|
+
ctx.fillText(
|
|
404
|
+
layout.text,
|
|
405
|
+
Math.max(0, (tileWidth - layout.width) / 2),
|
|
406
|
+
HUD_TILE_HEIGHT / 2,
|
|
407
|
+
);
|
|
305
408
|
|
|
306
409
|
const texture = new THREE.CanvasTexture(canvas);
|
|
307
410
|
texture.name = "fleet-hologram-hud-texture";
|
|
308
411
|
texture.colorSpace = THREE.SRGBColorSpace;
|
|
309
|
-
texture.wrapS = THREE.RepeatWrapping;
|
|
310
412
|
return texture;
|
|
311
413
|
}
|
|
312
414
|
|
|
313
|
-
/** The real-data
|
|
314
|
-
* plane's aspect is locked to the texture's
|
|
315
|
-
*
|
|
316
|
-
|
|
415
|
+
/** The real-data HUD plane, pinned to the front dial's centre. The
|
|
416
|
+
* plane's aspect is locked to the texture tile's own aspect — the tile
|
|
417
|
+
* width is measured from the live label, so the band grows with long
|
|
418
|
+
* names and glyphs are never compressed horizontally. */
|
|
419
|
+
function makeHudPlane(
|
|
317
420
|
bodyRadius: number,
|
|
318
421
|
color: THREE.Color,
|
|
319
422
|
offline: boolean,
|
|
320
423
|
texture: THREE.CanvasTexture,
|
|
321
424
|
): THREE.Mesh {
|
|
322
|
-
const
|
|
323
|
-
const
|
|
425
|
+
const image = texture.image as HTMLCanvasElement | undefined;
|
|
426
|
+
const tileWidth = image?.width ?? HUD_REFERENCE_WIDTH;
|
|
427
|
+
const tileHeight = image?.height ?? HUD_TILE_HEIGHT;
|
|
428
|
+
const width = bodyRadius * 1.14 * (tileWidth / HUD_REFERENCE_WIDTH);
|
|
429
|
+
const height = width * (tileHeight / tileWidth);
|
|
324
430
|
const mesh = unpickable(new THREE.Mesh(
|
|
325
431
|
new THREE.PlaneGeometry(width, height),
|
|
326
432
|
new THREE.MeshBasicMaterial({
|
|
@@ -405,7 +511,7 @@ export function makeFleetHologram(
|
|
|
405
511
|
const hudTexture = makeHudTexture(input);
|
|
406
512
|
let hudPanel: THREE.Mesh | null = null;
|
|
407
513
|
if (hudTexture) {
|
|
408
|
-
hudPanel =
|
|
514
|
+
hudPanel = makeHudPlane(input.radius, color, offline, hudTexture);
|
|
409
515
|
hudPanel.position.z = input.radius * 0.02;
|
|
410
516
|
frontDial.add(hudPanel);
|
|
411
517
|
}
|
|
@@ -458,8 +564,9 @@ export function makeSatelliteHologram(
|
|
|
458
564
|
}
|
|
459
565
|
|
|
460
566
|
/** Independent motion: the shell turns slowly, the front dial and the
|
|
461
|
-
* selection ring face the camera, the dial's ticks sweep
|
|
462
|
-
*
|
|
567
|
+
* selection ring face the camera, and the dial's ticks sweep. The HUD
|
|
568
|
+
* line itself sits still at the disc's centre: its band is measured from
|
|
569
|
+
* the label, so a scroll would only slice it at the texture seam. */
|
|
463
570
|
export function poseFleetHologram(
|
|
464
571
|
hologram: FleetHologram,
|
|
465
572
|
elapsed: number,
|
|
@@ -473,7 +580,4 @@ export function poseFleetHologram(
|
|
|
473
580
|
hologram.frontDial.quaternion.copy(cameraQuaternion);
|
|
474
581
|
hologram.selectionRing.quaternion.copy(cameraQuaternion);
|
|
475
582
|
}
|
|
476
|
-
if (hologram.hudTexture) {
|
|
477
|
-
hologram.hudTexture.offset.x = (elapsed * 0.018) % 1;
|
|
478
|
-
}
|
|
479
583
|
}
|
|
@@ -143,7 +143,7 @@ export function buildFleetScene(
|
|
|
143
143
|
return;
|
|
144
144
|
}
|
|
145
145
|
// Everything else this builder puts in the scene that owns GPU
|
|
146
|
-
// resources is a Mesh (pick proxies, dial cores,
|
|
146
|
+
// resources is a Mesh (pick proxies, dial cores, HUD planes),
|
|
147
147
|
// a Line (dial rings, spindle circles, orbit rings) or Points —
|
|
148
148
|
// all three carry `geometry` + `material`.
|
|
149
149
|
if (
|