@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
|
@@ -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">
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
673
|
-
|
|
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
|
-
|
|
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
|
});
|