@celestia-island/hikari 0.55.256 → 0.55.258

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.256",
3
+ "version": "0.55.258",
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",
@@ -80,6 +80,25 @@ describe("HkDonutChart", () => {
80
80
  expect(empty.querySelector(".hk-donut--empty")).toBeTruthy();
81
81
  });
82
82
 
83
+ it("renders the legendIcon slot's glyph in place of the text label", () => {
84
+ const el = mount(
85
+ <HkDonutChart
86
+ entries={ENTRIES}
87
+ v-slots={{
88
+ legendIcon: (slice: { key: string }) => <b data-flag={slice.key}>flag</b>,
89
+ }}
90
+ />,
91
+ );
92
+ const rows = [...el.querySelectorAll(".hk-donut__legend-row")];
93
+ expect(rows.map((r) => r.querySelector("[data-flag]")?.getAttribute("data-flag"))).toEqual([
94
+ "dd",
95
+ "ca",
96
+ "bb",
97
+ ]);
98
+ // the plain-text labels are gone; the color dots stay
99
+ expect(rows.every((r) => r.querySelector(".hk-donut__legend-dot"))).toBe(true);
100
+ });
101
+
83
102
  it("carries the chart-level aria label", () => {
84
103
  const el = mount(<HkDonutChart entries={ENTRIES} ariaLabel="ship mix" />);
85
104
  expect(el.querySelector(".hk-donut")?.getAttribute("aria-label")).toBe("ship mix");
@@ -29,7 +29,9 @@ function defaultFill(i: number): string {
29
29
  * o'clock start, clockwise), legend rows that stack in fixed-height
30
30
  * columns with percents shown inline, and paired hover focus —
31
31
  * hovering a slice or its legend row lifts the pair and dims the
32
- * block's siblings without rescaling any shape.
32
+ * block's siblings without rescaling any shape. A scoped `legendIcon`
33
+ * slot (slice => node) swaps a legend row's text label for a glyph —
34
+ * nation flags, tier marks — keeping the color dot and the row hint.
33
35
  *
34
36
  * Entries may carry an explicit `color`; absent entries ride the
35
37
  * default primary ladder above. Hints ride the consumer's tooltip
@@ -63,7 +65,7 @@ export default defineComponent({
63
65
  emits: {
64
66
  select: (key: string) => typeof key === "string",
65
67
  },
66
- setup(props, { emit }) {
68
+ setup(props, { emit, slots }) {
67
69
  const hovered = ref<string | null>(null);
68
70
 
69
71
  const slices = computed<HkDonutSlice[]>(() =>
@@ -129,7 +131,14 @@ export default defineComponent({
129
131
  onClick={() => emit("select", s.key)}
130
132
  >
131
133
  <span class="hk-donut__legend-dot" style={{ background: s.fill }} />
132
- <span class="hk-donut__legend-label">{s.label}</span>
134
+ <span class="hk-donut__legend-label">
135
+ {/* legendIcon slot: an optional per-slice glyph
136
+ * (flags, tier marks) rendered IN PLACE of the
137
+ * text label — the color dot stays, and the row's
138
+ * data-hint carries the full slice hint either
139
+ * way (wowsp's NationFlag contract). */}
140
+ {slots.legendIcon?.(s) ?? s.label}
141
+ </span>
133
142
  <span class="hk-donut__legend-pct">{s.percentInt}%</span>
134
143
  </div>
135
144
  ))}
@@ -3,6 +3,8 @@ import * as THREE from "three";
3
3
 
4
4
  import { fakeAs } from "../test-utils/fakes";
5
5
  import {
6
+ extractHologramFaces,
7
+ faceInradius,
6
8
  hologramHudText,
7
9
  makeFleetHologram,
8
10
  poseFleetHologram,
@@ -86,4 +88,77 @@ describe("makeFleetHologram", () => {
86
88
  expect(hub.mesh.geometry).toBeInstanceOf(THREE.IcosahedronGeometry);
87
89
  expect(planet.mesh.geometry).toBeInstanceOf(THREE.DodecahedronGeometry);
88
90
  });
91
+
92
+ it("gives every face its own detached inset ring, with no shared vertices", () => {
93
+ mockCanvas();
94
+ for (const [role, expectedFaces] of [["star", 20], ["planet", 12]] as const) {
95
+ const hologram = makeFleetHologram(
96
+ { ...INPUT, role },
97
+ new THREE.Color(),
98
+ false,
99
+ );
100
+ const rings = hologram.mesh.getObjectsByProperty(
101
+ "name",
102
+ "fleet-hologram-face-ring",
103
+ );
104
+ expect(rings, `${role} face rings`).toHaveLength(expectedFaces);
105
+ expect(
106
+ hologram.mesh.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
107
+ `${role} shared wireframe`,
108
+ ).toHaveLength(0);
109
+
110
+ const vertexKeys = new Set<string>();
111
+ let vertices = 0;
112
+ for (const ring of rings) {
113
+ expect(ring).toBeInstanceOf(THREE.LineLoop);
114
+ const positions = (ring as THREE.LineLoop).geometry.getAttribute("position");
115
+ expect(positions.count).toBe(32);
116
+ for (let i = 0; i < positions.count; i += 1) {
117
+ vertexKeys.add(
118
+ `${positions.getX(i).toFixed(5)}:${positions.getY(i).toFixed(5)}:${positions.getZ(i).toFixed(5)}`,
119
+ );
120
+ vertices += 1;
121
+ }
122
+ }
123
+ // Every ring has its own circumference; no ring shares an edge or
124
+ // vertex with a neighbour, so the old connected cage cannot reappear.
125
+ expect(vertexKeys.size).toBe(vertices);
126
+
127
+ // Each ring must stay well INSIDE its own face: map it back to the
128
+ // recovered face polygon, then pin both the 62% radius ratio and the
129
+ // remaining margin. This makes an overflow regression impossible to
130
+ // hide behind the count/name assertions above.
131
+ const faces = extractHologramFaces(hologram.mesh.geometry);
132
+ expect(faces).toHaveLength(expectedFaces);
133
+ const usedFaces = new Set<number>();
134
+ for (const ring of rings) {
135
+ expect(ring.raycast).not.toBe(THREE.LineLoop.prototype.raycast);
136
+ const positions = (ring as THREE.LineLoop).geometry.getAttribute("position");
137
+ const ringCenter = new THREE.Vector3();
138
+ const points: THREE.Vector3[] = [];
139
+ for (let i = 0; i < positions.count; i += 1) {
140
+ const point = new THREE.Vector3(
141
+ positions.getX(i),
142
+ positions.getY(i),
143
+ positions.getZ(i),
144
+ );
145
+ points.push(point);
146
+ ringCenter.add(point);
147
+ }
148
+ ringCenter.multiplyScalar(1 / positions.count);
149
+ const faceIndex = faces.findIndex((face, index) =>
150
+ !usedFaces.has(index) && ringCenter.distanceTo(face.center) < 0.03);
151
+ expect(faceIndex, `${role} ring maps to one face`).toBeGreaterThanOrEqual(0);
152
+ usedFaces.add(faceIndex);
153
+ const face = faces[faceIndex]!;
154
+ const inradius = faceInradius(face);
155
+ const ringRadius = Math.max(...points.map((point) => point.distanceTo(ringCenter)));
156
+ expect(ringRadius / inradius).toBeCloseTo(0.62, 5);
157
+ for (const point of points) {
158
+ expect(point.distanceTo(face.center)).toBeLessThan(inradius);
159
+ }
160
+ }
161
+ expect(usedFaces.size).toBe(expectedFaces);
162
+ }
163
+ });
89
164
  });
@@ -3,10 +3,11 @@
3
3
  * data-bound cylindrical HUD band.
4
4
  *
5
5
  * The visual language is deliberately restrained: a coarse translucent
6
- * polyhedron, two rings for a standard node (three for a hub), one bright
7
- * arc per ring, and a slow ticker made ONLY from real caller-supplied
8
- * fields. Decorative lines and the band never join raycast picking; the
9
- * centre mesh remains the node's pointer and selection target.
6
+ * polyhedron whose FACES each carry an independent in-plane ring (no shared
7
+ * wireframe edges), two orbit rings for a standard node (three for a hub),
8
+ * one bright arc per orbit ring, and a slow ticker made ONLY from real
9
+ * caller-supplied fields. Decorative lines and the band never join raycast
10
+ * picking; the centre mesh remains the node's pointer and selection target.
10
11
  */
11
12
 
12
13
  import * as THREE from "three";
@@ -76,6 +77,125 @@ function unpickable<T extends THREE.Object3D>(object: T): T {
76
77
  return object;
77
78
  }
78
79
 
80
+ /** One flat polygon face recovered from the polyhedron's triangles.
81
+ * Exported for the geometry contract test; not part of the public barrel. */
82
+ export interface HologramFace {
83
+ center: THREE.Vector3;
84
+ normal: THREE.Vector3;
85
+ vertices: THREE.Vector3[];
86
+ }
87
+
88
+ /** Recover the polyhedron's true faces (a dodecahedron face is three
89
+ * coplanar triangles; a pentagon must become ONE ring, not three). Group by
90
+ * normal direction with a dot threshold: Three.js' projected pentagon
91
+ * fragments are nearly coplanar, while adjacent dodecahedron faces differ
92
+ * by tens of degrees. */
93
+ export function extractHologramFaces(geometry: THREE.BufferGeometry): HologramFace[] {
94
+ const position = geometry.getAttribute("position");
95
+ const groups: Array<{ normal: THREE.Vector3; vertices: Map<string, THREE.Vector3> }> = [];
96
+ for (let i = 0; i < position.count; i += 3) {
97
+ const a = new THREE.Vector3(position.getX(i), position.getY(i), position.getZ(i));
98
+ const b = new THREE.Vector3(position.getX(i + 1), position.getY(i + 1), position.getZ(i + 1));
99
+ const c = new THREE.Vector3(position.getX(i + 2), position.getY(i + 2), position.getZ(i + 2));
100
+ const normal = b.clone().sub(a).cross(c.clone().sub(a)).normalize();
101
+ const triangleCenter = a.clone().add(b).add(c).multiplyScalar(1 / 3);
102
+ if (normal.dot(triangleCenter) < 0) normal.negate();
103
+ let group = groups.find((candidate) => candidate.normal.dot(normal) > 0.999);
104
+ if (!group) {
105
+ group = { normal, vertices: new Map() };
106
+ groups.push(group);
107
+ }
108
+ for (const vertex of [a, b, c]) {
109
+ const vertexKey = [vertex.x, vertex.y, vertex.z]
110
+ .map((value) => value.toFixed(5))
111
+ .join(":");
112
+ group.vertices.set(vertexKey, vertex);
113
+ }
114
+ }
115
+
116
+ return groups.map((group) => {
117
+ const vertices = [...group.vertices.values()];
118
+ const center = vertices
119
+ .reduce((sum, vertex) => sum.add(vertex), new THREE.Vector3())
120
+ .multiplyScalar(1 / vertices.length);
121
+ return {
122
+ center,
123
+ normal: group.normal.clone().dot(center) < 0
124
+ ? group.normal.clone().negate()
125
+ : group.normal.clone(),
126
+ vertices,
127
+ };
128
+ });
129
+ }
130
+
131
+ /** Inradius of the face polygon — the largest inset circle that still
132
+ * leaves a visible gap between neighbouring face rings. Exported for the
133
+ * geometry contract test; not part of the public barrel. */
134
+ export function faceInradius(face: HologramFace): number {
135
+ const u = face.vertices[0]!.clone().sub(face.center).normalize();
136
+ const v = face.normal.clone().cross(u).normalize();
137
+ const ordered = [...face.vertices].sort((a, b) => {
138
+ const angle = (point: THREE.Vector3): number => Math.atan2(
139
+ point.clone().sub(face.center).dot(v),
140
+ point.clone().sub(face.center).dot(u),
141
+ );
142
+ return angle(a) - angle(b);
143
+ });
144
+ let inradius = Number.POSITIVE_INFINITY;
145
+ for (let i = 0; i < ordered.length; i += 1) {
146
+ const a = ordered[i]!;
147
+ const b = ordered[(i + 1) % ordered.length]!;
148
+ const edge = b.clone().sub(a);
149
+ const area2 = edge.clone().cross(face.center.clone().sub(a)).length();
150
+ if (edge.lengthSq() > 1e-10) inradius = Math.min(inradius, area2 / edge.length());
151
+ }
152
+ return Number.isFinite(inradius) ? inradius : 0;
153
+ }
154
+
155
+ /** Each face gets its own detached ring. No shared edges, no connected
156
+ * wireframe cage — adjacent rings visibly stop short of every face border. */
157
+ function makeFaceRings(
158
+ geometry: THREE.BufferGeometry,
159
+ bodyRadius: number,
160
+ color: THREE.Color,
161
+ offline: boolean,
162
+ ): THREE.LineLoop[] {
163
+ const lift = bodyRadius * 0.01;
164
+ const segments = 32;
165
+ const rings: THREE.LineLoop[] = [];
166
+ for (const face of extractHologramFaces(geometry)) {
167
+ const radius = Math.max(0.012, faceInradius(face) * 0.62);
168
+ const u = face.vertices[0]!.clone().sub(face.center).normalize();
169
+ const v = face.normal.clone().cross(u).normalize();
170
+ const points: number[] = [];
171
+ for (let i = 0; i < segments; i += 1) {
172
+ const angle = (i / segments) * Math.PI * 2;
173
+ const point = face.center
174
+ .clone()
175
+ .addScaledVector(face.normal, lift)
176
+ .addScaledVector(u, Math.cos(angle) * radius)
177
+ .addScaledVector(v, Math.sin(angle) * radius);
178
+ points.push(point.x, point.y, point.z);
179
+ }
180
+ const ring = unpickable(new THREE.LineLoop(
181
+ new THREE.BufferGeometry().setAttribute(
182
+ "position",
183
+ new THREE.Float32BufferAttribute(points, 3),
184
+ ),
185
+ new THREE.LineBasicMaterial({
186
+ color,
187
+ transparent: true,
188
+ opacity: offline ? 0.16 : 0.62,
189
+ depthWrite: false,
190
+ blending: THREE.AdditiveBlending,
191
+ }),
192
+ ));
193
+ ring.name = "fleet-hologram-face-ring";
194
+ rings.push(ring);
195
+ }
196
+ return rings;
197
+ }
198
+
79
199
  function makeRing(
80
200
  radius: number,
81
201
  index: number,
@@ -212,18 +332,9 @@ export function makeFleetHologram(
212
332
  mesh.name = "fleet-hologram-polyhedron";
213
333
  mesh.rotation.set(0.2, 0, 0.1);
214
334
 
215
- const edges = unpickable(new THREE.LineSegments(
216
- new THREE.EdgesGeometry(geometry, 1),
217
- new THREE.LineBasicMaterial({
218
- color: edgeColor,
219
- transparent: true,
220
- opacity: offline ? 0.26 : 0.78,
221
- depthWrite: false,
222
- blending: THREE.AdditiveBlending,
223
- }),
224
- ));
225
- edges.name = "fleet-hologram-polyhedron-edges";
226
- mesh.add(edges);
335
+ for (const ring of makeFaceRings(geometry, input.radius, edgeColor, offline)) {
336
+ mesh.add(ring);
337
+ }
227
338
  group.add(mesh);
228
339
 
229
340
  const rings = HOLOGRAM_RING_SCALES[input.role].map((scale, index) => {
@@ -669,10 +669,18 @@ describe("buildFleetScene body dressing", () => {
669
669
  expect(ring.getObjectByName("fleet-hologram-ring-base")).toBeDefined();
670
670
  expect(ring.getObjectByName("fleet-hologram-ring-arc")).toBeDefined();
671
671
  }
672
- const edges = bodyMesh(engine, id).getObjectByName(
673
- "fleet-hologram-polyhedron-edges",
672
+ const faceRings = root.getObjectsByProperty("name", "fleet-hologram-face-ring");
673
+ expect(faceRings, `${id} detached face-ring count`).toHaveLength(
674
+ id === "hub-a" ? 20 : 12,
674
675
  );
675
- expect(edges, `${id} polyhedron edges`).toBeInstanceOf(THREE.LineSegments);
676
+ for (const faceRing of faceRings) {
677
+ expect(faceRing).toBeInstanceOf(THREE.LineLoop);
678
+ expect(faceRing).not.toBeInstanceOf(THREE.LineSegments);
679
+ }
680
+ expect(
681
+ root.getObjectsByProperty("name", "fleet-hologram-polyhedron-edges"),
682
+ "the shared wireframe cage is gone",
683
+ ).toHaveLength(0);
676
684
  }
677
685
  handle.dispose();
678
686
  });