@johnmorrisdotca/wakusei-planets 0.5.1 → 0.7.0

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.
@@ -0,0 +1,545 @@
1
+ import { BODIES } from "../facts.js";
2
+ import { createGate, frameCap } from "../gate.js";
3
+ import { setThreeLoader, takeRenderer } from "../scene/shared.js";
4
+ import { TEXTURE_BASE, TEXTURE_FAR_BASE } from "../element.js";
5
+ import { backgroundCss, drawShapes, lineScene, paintBackground, shapesOf, shapesSvg, starfield } from "./overlay.js";
6
+ import { decodeScene, defaultPortrait, encodeScene, fromScreen, hitTest, resolve, toScreen } from "./model.js";
7
+ import { poseOf } from "./pose.js";
8
+ import { createPortraitItem } from "./scene3d.js";
9
+ export { setThreeLoader };
10
+ const TEMPLATE = `<style>
11
+ :host { display: block; width: var(--wakusei-size, 720px); max-width: 100%; aspect-ratio: var(--wakusei-ratio, 16 / 9); position: relative; overflow: hidden; contain: layout style; touch-action: none; user-select: none; }
12
+ #bg, #stage, #over { position: absolute; inset: 0; width: 100%; height: 100%; }
13
+ #stage svg { display: block; width: 100%; height: 100%; }
14
+ #over { pointer-events: none; overflow: visible; }
15
+ </style><div id="bg"></div><div id="stage"></div><svg id="over" aria-hidden="true"></svg>`;
16
+ const num = (value, fallback) => {
17
+ const n = value === null || value.trim() === "" ? NaN : Number(value);
18
+ return Number.isFinite(n) ? n : fallback;
19
+ };
20
+ /**
21
+ * The `<wakusei-portrait>` custom element: a scene of bodies arranged on one picture.
22
+ *
23
+ * Attributes: `scene` (the scene as the portrait builder writes it in its address), `look` (`line`, `realistic` or `painted`: replaces the scene's), `size` (pixels across), `lang` (`en` or `ja`: the names),
24
+ * `live` (`always`, `hover` or `off`: when it takes the page's one WebGL context), `texture-base` and `texture-far-base` (where the surface pictures are).
25
+ *
26
+ * @example
27
+ * ```ts no-run
28
+ * import { WakuseiPortrait } from "@johnmorrisdotca/wakusei-planets/portrait";
29
+ *
30
+ * const view = document.createElement("wakusei-portrait") as WakuseiPortrait;
31
+ * view.setAttribute("scene", "arrange:arc~look:painted~sun_0_0_0.3_0~earth_0_0_0.3_0~moon_0_0_0.3_0");
32
+ * document.body.append(view);
33
+ * ```
34
+ */
35
+ export class WakuseiPortrait extends HTMLElement {
36
+ static observedAttributes = ["scene", "look", "size", "lang", "live", "texture-base", "texture-far-base"];
37
+ #root;
38
+ #stage;
39
+ #bg;
40
+ #over;
41
+ #portrait = defaultPortrait();
42
+ #fromAttributes = true;
43
+ #resolved = { placed: [], camera: { x: 0, y: 0, h: 1 } };
44
+ #state = "line";
45
+ #renderer;
46
+ #release;
47
+ #view;
48
+ #viewKey = "";
49
+ #stilled = false;
50
+ #wantLive = false;
51
+ #visible = typeof IntersectionObserver !== "function";
52
+ #io;
53
+ #tick;
54
+ #gate;
55
+ #queued = false;
56
+ #startedAt = 0;
57
+ #reduce = false;
58
+ #edit = {};
59
+ #overKey = "";
60
+ #lineKey = "";
61
+ #hover = () => void this.goLive();
62
+ #onContext = (state) => {
63
+ if (state === "unavailable")
64
+ this.#set("unavailable");
65
+ else if (state === "lost")
66
+ this.#set("lost");
67
+ else if (this.#view?.live)
68
+ this.#set("live");
69
+ };
70
+ #onHidden = () => {
71
+ if (document.visibilityState === "hidden")
72
+ this.#stopTick();
73
+ else
74
+ this.#resume();
75
+ };
76
+ /** The scene now. Setting it hands the element a scene of your own, as the builder does; it is drawn at once and at most thirty times a second after. */
77
+ get portrait() {
78
+ return this.#portrait;
79
+ }
80
+ set portrait(value) {
81
+ this.#portrait = value;
82
+ this.#fromAttributes = false;
83
+ this.#changed();
84
+ }
85
+ /** The chosen body's index and the guides to draw over the picture (the builder's editing). */
86
+ set editing(value) {
87
+ this.#edit = value;
88
+ this.#paintOver();
89
+ }
90
+ get state() {
91
+ return this.#state;
92
+ }
93
+ /** Numbers for a test or a status line. */
94
+ get stats() {
95
+ return { state: this.#state, bodies: this.#resolved.placed.length, renderer: this.#renderer?.stats };
96
+ }
97
+ /** Where everything is on the page now: the picture's size in pixels, pixels to a scene unit, and the bodies (back to front). */
98
+ get metrics() {
99
+ const { width, height } = this.#size();
100
+ return { width, height, scale: toScreen(this.#resolved.camera, width, height).scale, camera: this.#resolved.camera, placed: this.#resolved.placed };
101
+ }
102
+ /** The body under a point of the page (client coordinates): its index in the list, or -1. `slop` is the smallest radius, in pixels, a body is hit within. */
103
+ pick(clientX, clientY, slop = 10) {
104
+ const at = this.worldAt(clientX, clientY);
105
+ const { scale } = toScreen(this.#resolved.camera, this.#size().width, this.#size().height);
106
+ return hitTest(this.#resolved.placed, at.x, at.y, slop / scale);
107
+ }
108
+ /** A point of the page (client coordinates) in the scene's units. */
109
+ worldAt(clientX, clientY) {
110
+ const box = this.getBoundingClientRect();
111
+ const { width, height } = this.#size();
112
+ return fromScreen(this.#resolved.camera, width, height, ((clientX - box.left) / Math.max(1, box.width)) * width, ((clientY - box.top) / Math.max(1, box.height)) * height);
113
+ }
114
+ connectedCallback() {
115
+ this.#root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
116
+ this.#root.innerHTML = TEMPLATE;
117
+ this.#stage = this.#root.getElementById("stage");
118
+ this.#bg = this.#root.getElementById("bg");
119
+ this.#over = this.#root.getElementById("over");
120
+ this.#startedAt = performance.now();
121
+ this.#reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
122
+ if (!this.hasAttribute("role"))
123
+ this.setAttribute("role", "img");
124
+ if (!this.hasAttribute("data-state"))
125
+ this.setAttribute("data-state", this.#state);
126
+ this.#gate = createGate(1000 / frameCap(30, { deviceMemory: navigator.deviceMemory, hardwareConcurrency: navigator.hardwareConcurrency }), () => this.#frame());
127
+ if (typeof IntersectionObserver === "function") {
128
+ this.#io = new IntersectionObserver((entries) => {
129
+ const last = entries[entries.length - 1];
130
+ if (last === undefined)
131
+ return;
132
+ this.#visible = last.isIntersecting;
133
+ if (this.#visible) {
134
+ this.#still();
135
+ this.#resume();
136
+ if (this.#portrait.look !== "line" && this.#view?.live !== true && this.#priority())
137
+ void this.goLive();
138
+ }
139
+ else
140
+ this.#stopTick();
141
+ });
142
+ this.#io.observe(this);
143
+ }
144
+ document.addEventListener("visibilitychange", this.#onHidden);
145
+ this.#applyHover();
146
+ this.#readAttributes();
147
+ this.#queue();
148
+ }
149
+ disconnectedCallback() {
150
+ this.#stopTick();
151
+ this.#io?.disconnect();
152
+ document.removeEventListener("visibilitychange", this.#onHidden);
153
+ this.removeEventListener("pointerenter", this.#hover);
154
+ this.removeEventListener("focusin", this.#hover);
155
+ this.removeEventListener("pointerdown", this.#hover);
156
+ this.#view?.dispose();
157
+ this.#view = undefined;
158
+ this.#viewKey = "";
159
+ this.#release?.();
160
+ this.#release = undefined;
161
+ this.#renderer = undefined;
162
+ }
163
+ attributeChangedCallback(name) {
164
+ if (!this.isConnected)
165
+ return;
166
+ if (name === "live")
167
+ this.#applyHover();
168
+ if (name === "scene" || name === "look")
169
+ this.#readAttributes();
170
+ this.#queue();
171
+ }
172
+ /** Take the page's one WebGL context and draw the scene on it. Resolves true when it is live; a line scene has nothing to take and resolves false. */
173
+ async goLive() {
174
+ if (this.#portrait.look === "line")
175
+ return false;
176
+ this.#wantLive = true;
177
+ await this.#ensureView();
178
+ const ok = (await this.#view?.focus()) === true;
179
+ if (ok) {
180
+ this.#set("live");
181
+ this.#sync();
182
+ this.#resume();
183
+ }
184
+ else if (this.#state !== "unavailable" && this.#state !== "lost")
185
+ this.#set("still");
186
+ return ok;
187
+ }
188
+ /** Give the WebGL context back: the picture stays as a still. */
189
+ rest() {
190
+ this.#wantLive = false;
191
+ this.#stopTick();
192
+ if (this.#view === undefined)
193
+ return;
194
+ this.#view.blur();
195
+ if (this.#portrait.look !== "line" && this.#state !== "unavailable") {
196
+ this.#set("still");
197
+ this.#rekey();
198
+ }
199
+ }
200
+ /** The picture as a PNG: the background, the bodies and the names, at the size it is on the page times `scale` (the canvas of the bodies is at most the page's pixel ratio, 2). */
201
+ async toPng(scale = 1) {
202
+ const { width, height } = this.#size();
203
+ const canvas = document.createElement("canvas");
204
+ const ratio = Math.max(1, Math.min(4, scale));
205
+ canvas.width = Math.round(width * ratio);
206
+ canvas.height = Math.round(height * ratio);
207
+ const ctx = canvas.getContext("2d");
208
+ paintBackground(ctx, this.#portrait.bg, canvas.width, canvas.height);
209
+ if (this.#portrait.look === "line") {
210
+ const image = await this.#svgImage(this.#lineDocument(width, height, false), canvas.width, canvas.height);
211
+ ctx.drawImage(image, 0, 0);
212
+ }
213
+ else {
214
+ await this.#drawCanvasOnto(ctx, canvas.width, canvas.height);
215
+ drawShapes(ctx, shapesOf(this.#portrait, this.#resolved, width, height, this.#lang()), canvas.width / width);
216
+ }
217
+ return new Promise((resolveBlob, reject) => canvas.toBlob((blob) => (blob === null ? reject(new Error("the picture could not be made")) : resolveBlob(blob)), "image/png"));
218
+ }
219
+ /** The picture as SVG. The line look is all vector. The shaded looks hold the bodies as a picture inside the SVG, with the background, the orbits and the names as vector. */
220
+ async toSvg() {
221
+ const { width, height } = this.#size();
222
+ if (this.#portrait.look === "line")
223
+ return this.#lineDocument(width, height, true);
224
+ const href = await this.#glDataUrl();
225
+ return this.#wrapSvg(width, height, `<image x="0" y="0" width="${width}" height="${height}" href="${href}"/>`);
226
+ }
227
+ // --- the scene ----------------------------------------------------------------------------------------------------------------------------------------
228
+ #readAttributes() {
229
+ if (!this.#fromAttributes)
230
+ return;
231
+ const text = this.getAttribute("scene");
232
+ const p = text === null ? defaultPortrait() : decodeScene(text);
233
+ const look = this.getAttribute("look");
234
+ if (look === "line" || look === "realistic" || look === "painted")
235
+ p.look = look;
236
+ this.#portrait = p;
237
+ }
238
+ #lang() {
239
+ return this.getAttribute("lang") === "ja" ? "ja" : "en";
240
+ }
241
+ #size() {
242
+ const width = Math.max(1, Math.round(this.clientWidth || num(this.getAttribute("size"), 720)));
243
+ const height = Math.max(1, Math.round(this.clientHeight || width / this.#portrait.aspect));
244
+ return { width, height };
245
+ }
246
+ #date() {
247
+ const ms = this.#portrait.date === undefined ? NaN : Date.parse(this.#portrait.date);
248
+ return Number.isFinite(ms) ? ms : undefined;
249
+ }
250
+ get #turning() {
251
+ return !this.#reduce && this.#portrait.items.some((i) => i.turn !== 0);
252
+ }
253
+ #seconds() {
254
+ return this.#turning ? (performance.now() - this.#startedAt) / 1000 : 0;
255
+ }
256
+ #draw() {
257
+ const p = this.#portrait;
258
+ return { placed: this.#resolved.placed, camera: this.#resolved.camera, light: p.light, look: p.look === "painted" ? "painted" : "realistic", ms: this.#date(), seconds: this.#seconds(), shadows: p.shadows };
259
+ }
260
+ #changed() {
261
+ if (!this.isConnected)
262
+ return;
263
+ if (this.#view?.live === true && !this.#queued) {
264
+ this.#resolved = resolve(this.#portrait);
265
+ this.#style();
266
+ this.#gate?.request();
267
+ }
268
+ else
269
+ this.#queue();
270
+ }
271
+ #style() {
272
+ const p = this.#portrait;
273
+ this.style.setProperty("--wakusei-ratio", String(p.aspect));
274
+ if (this.hasAttribute("size"))
275
+ this.style.setProperty("--wakusei-size", `${num(this.getAttribute("size"), 720)}px`);
276
+ this.#bg.style.background = backgroundCss(p.bg);
277
+ this.#paintOver();
278
+ }
279
+ #paintOver() {
280
+ if (this.#over === undefined)
281
+ return;
282
+ const { width, height } = this.#size();
283
+ const shapes = shapesOf(this.#portrait, this.#resolved, width, height, this.#lang(), this.#edit);
284
+ const markup = shapesSvg(shapes);
285
+ const key = `${width}x${height}|${markup}`;
286
+ if (key === this.#overKey)
287
+ return;
288
+ this.#overKey = key;
289
+ this.#over.setAttribute("viewBox", `0 0 ${width} ${height}`);
290
+ this.#over.innerHTML = markup;
291
+ }
292
+ // --- drawing ------------------------------------------------------------------------------------------------------------------------------------------
293
+ #queue() {
294
+ if (this.#queued)
295
+ return;
296
+ this.#queued = true;
297
+ queueMicrotask(() => {
298
+ this.#queued = false;
299
+ if (this.isConnected)
300
+ void this.#refresh();
301
+ });
302
+ }
303
+ async #refresh() {
304
+ if (!this.isConnected)
305
+ return;
306
+ const p = this.#portrait;
307
+ this.#resolved = resolve(p);
308
+ this.#style();
309
+ this.setAttribute("aria-label", this.#label());
310
+ const wants3d = p.look !== "line" && this.#state !== "unavailable" && p.items.length > 0;
311
+ if (!wants3d) {
312
+ if (this.#view !== undefined) {
313
+ this.#view.dispose();
314
+ this.#view = undefined;
315
+ this.#viewKey = "";
316
+ this.#release?.();
317
+ this.#release = undefined;
318
+ this.#renderer = undefined;
319
+ }
320
+ this.#drawLine();
321
+ if (p.look === "line" || p.items.length === 0)
322
+ this.#set("line");
323
+ return;
324
+ }
325
+ if (this.#stage.querySelector("svg") !== null && this.#view === undefined)
326
+ this.#stage.innerHTML = "";
327
+ this.#lineKey = "";
328
+ if (this.#view === undefined || this.#viewKey === "")
329
+ await this.#ensureView();
330
+ if (this.#view?.live !== true && this.#wantLive) {
331
+ void this.goLive();
332
+ return;
333
+ }
334
+ if (this.#view?.live === true) {
335
+ this.#sync();
336
+ this.#view.invalidate();
337
+ }
338
+ else {
339
+ this.#rekey();
340
+ if (this.#wantLive || this.#priority())
341
+ void this.goLive();
342
+ }
343
+ }
344
+ #label() {
345
+ const names = this.#portrait.items.map((i) => BODIES[i.body].name[this.#lang()]);
346
+ return this.#lang() === "ja" ? `惑星の肖像: ${names.join("、")}` : `Portrait: ${names.join(", ")}`;
347
+ }
348
+ #priority() {
349
+ return (this.getAttribute("live") ?? "always") === "always" && this.#visible;
350
+ }
351
+ #applyHover() {
352
+ this.removeEventListener("pointerenter", this.#hover);
353
+ this.removeEventListener("focusin", this.#hover);
354
+ this.removeEventListener("pointerdown", this.#hover);
355
+ if ((this.getAttribute("live") ?? "always") === "hover") {
356
+ this.addEventListener("pointerenter", this.#hover);
357
+ this.addEventListener("focusin", this.#hover);
358
+ this.addEventListener("pointerdown", this.#hover);
359
+ }
360
+ }
361
+ #poses() {
362
+ const seconds = this.#seconds();
363
+ return this.#resolved.placed.map((q) => poseOf(q, this.#date(), seconds, this.#portrait.light));
364
+ }
365
+ #lineBody(width, height) {
366
+ return lineScene(this.#portrait, this.#resolved, width, height, this.#poses(), this.#lang());
367
+ }
368
+ #drawLine() {
369
+ const { width, height } = this.#size();
370
+ const markup = this.#lineBody(width, height);
371
+ const key = `${width}x${height}|${markup.length}|${this.#portrait.light.az}`;
372
+ if (this.#portrait.look !== "line") {
373
+ if (this.#stage.firstChild !== null && this.#lineKey !== "")
374
+ this.#stage.innerHTML = "";
375
+ this.#lineKey = "";
376
+ return;
377
+ }
378
+ if (key === this.#lineKey && !this.#turning)
379
+ return;
380
+ this.#lineKey = key;
381
+ this.#stage.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet" role="presentation">${markup}</svg>`;
382
+ }
383
+ #key() {
384
+ const { width } = this.#size();
385
+ return `${encodeScene(this.#portrait)}|${width}`;
386
+ }
387
+ async #ensureView() {
388
+ if (this.#renderer === undefined) {
389
+ const taken = takeRenderer(this.#onContext);
390
+ this.#renderer = taken.renderer;
391
+ this.#release = taken.release;
392
+ }
393
+ if (this.#view === undefined)
394
+ this.#makeView();
395
+ }
396
+ #makeView() {
397
+ this.#viewKey = this.#key();
398
+ this.#stilled = false;
399
+ this.#stage.innerHTML = "";
400
+ const base = () => {
401
+ const own = this.getAttribute("texture-base");
402
+ return own === null || own === "" ? TEXTURE_BASE : own.endsWith("/") ? own : `${own}/`;
403
+ };
404
+ const item = createPortraitItem({
405
+ key: this.#viewKey,
406
+ state: () => this.#draw(),
407
+ textureBase: base,
408
+ farBase: () => {
409
+ const own = this.getAttribute("texture-far-base");
410
+ if (own !== null && own !== "")
411
+ return own.endsWith("/") ? own : `${own}/`;
412
+ return this.hasAttribute("texture-base") ? base() : TEXTURE_FAR_BASE;
413
+ },
414
+ redraw: () => this.#view?.invalidate(),
415
+ });
416
+ this.#view = this.#renderer.view(this.#stage, item);
417
+ this.#set("loading");
418
+ if (this.#visible)
419
+ this.#still();
420
+ }
421
+ #still() {
422
+ if (this.#stilled || this.#view === undefined)
423
+ return;
424
+ this.#stilled = true;
425
+ void this.#view.showStill().then(() => {
426
+ if (this.#state === "loading")
427
+ this.#set("still");
428
+ });
429
+ }
430
+ #rekey() {
431
+ if (this.#renderer === undefined || this.#view === undefined || this.#view.live)
432
+ return;
433
+ if (this.#key() === this.#viewKey)
434
+ return;
435
+ this.#view.dispose();
436
+ this.#view = undefined;
437
+ this.#makeView();
438
+ }
439
+ #sync() {
440
+ const scene = this.#view?.scene;
441
+ if (scene === undefined || typeof scene.apply !== "function")
442
+ return;
443
+ scene.apply(this.#draw());
444
+ }
445
+ // --- moving -------------------------------------------------------------------------------------------------------------------------------------------
446
+ #frame() {
447
+ this.#resolved = resolve(this.#portrait);
448
+ this.#paintOver();
449
+ if (this.#portrait.look === "line") {
450
+ this.#drawLine();
451
+ if (this.#turning && this.#visible && document.visibilityState !== "hidden")
452
+ this.#tick = setTimeout(() => this.#gate?.request(), 100);
453
+ return;
454
+ }
455
+ if (this.#view?.live !== true || !this.#visible || document.visibilityState === "hidden")
456
+ return;
457
+ this.#sync();
458
+ this.#view.invalidate();
459
+ if (this.#turning)
460
+ this.#tick = setTimeout(() => this.#gate?.request(), 0);
461
+ }
462
+ #resume() {
463
+ this.#stopTick();
464
+ if (this.#turning && this.#visible && document.visibilityState !== "hidden" && (this.#view?.live === true || this.#portrait.look === "line"))
465
+ this.#gate?.request();
466
+ }
467
+ #stopTick() {
468
+ clearTimeout(this.#tick);
469
+ this.#tick = undefined;
470
+ this.#gate?.cancel();
471
+ }
472
+ #set(state) {
473
+ if (state === this.#state)
474
+ return;
475
+ this.#state = state;
476
+ this.setAttribute("data-state", state);
477
+ this.dispatchEvent(new CustomEvent("wakusei-state", { detail: { state } }));
478
+ if (state === "unavailable")
479
+ this.#drawLine();
480
+ }
481
+ // --- exporting ----------------------------------------------------------------------------------------------------------------------------------------
482
+ async #drawCanvasOnto(ctx, width, height) {
483
+ await this.#capturePixels((c) => ctx.drawImage(c, 0, 0, width, height));
484
+ }
485
+ async #glDataUrl() {
486
+ let url = "";
487
+ await this.#capturePixels((c) => {
488
+ url = c.toDataURL("image/png");
489
+ });
490
+ return url;
491
+ }
492
+ async #capturePixels(use) {
493
+ if (this.#view?.live !== true && !(await this.goLive()))
494
+ throw new Error("the 3D picture is not available here");
495
+ await new Promise((done, reject) => {
496
+ const view = this.#view;
497
+ const scene = view?.scene;
498
+ const canvas = this.#stage.querySelector("canvas");
499
+ if (view === undefined || scene?.afterDraw === undefined || canvas === null) {
500
+ reject(new Error("the 3D picture is not available here"));
501
+ return;
502
+ }
503
+ const timeout = setTimeout(() => reject(new Error("the picture was not drawn")), 8000);
504
+ scene.afterDraw(() => {
505
+ clearTimeout(timeout);
506
+ try {
507
+ use(canvas);
508
+ done();
509
+ }
510
+ catch (error) {
511
+ reject(error instanceof Error ? error : new Error("the picture could not be read"));
512
+ }
513
+ });
514
+ this.#sync();
515
+ view.invalidate();
516
+ });
517
+ }
518
+ #svgImage(svg, width, height) {
519
+ return new Promise((resolveImage, reject) => {
520
+ const image = new Image(width, height);
521
+ image.onload = () => resolveImage(image);
522
+ image.onerror = () => reject(new Error("the SVG could not be drawn"));
523
+ image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
524
+ });
525
+ }
526
+ #lineDocument(width, height, withBackground) {
527
+ const body = this.#lineBody(width, height);
528
+ return this.#wrapSvg(width, height, body, withBackground);
529
+ }
530
+ #wrapSvg(width, height, bodies, withBackground = true) {
531
+ const p = this.#portrait;
532
+ const stars = starfield();
533
+ let back = "";
534
+ if (withBackground && p.bg === "black")
535
+ back = `<rect width="${width}" height="${height}" fill="#000"/>`;
536
+ else if (withBackground && p.bg === "gradient")
537
+ back = `<defs><radialGradient id="sky" cx="30%" cy="20%" r="110%"><stop offset="0" stop-color="#1c3358"/><stop offset="0.55" stop-color="#0d1a33"/><stop offset="1" stop-color="#050914"/></radialGradient></defs><rect width="${width}" height="${height}" fill="url(#sky)"/>`;
538
+ else if (withBackground && p.bg === "stars")
539
+ back = `<defs><radialGradient id="sky" cx="50%" cy="40%" r="80%"><stop offset="0" stop-color="#07101f"/><stop offset="1" stop-color="#000"/></radialGradient></defs><rect width="${width}" height="${height}" fill="url(#sky)"/>${stars.map((s) => `<circle cx="${(s.x * width).toFixed(1)}" cy="${(s.y * height).toFixed(1)}" r="${Math.max(0.5, s.r * 1000).toFixed(2)}" fill="#fff" fill-opacity="${s.a.toFixed(2)}"/>`).join("")}`;
540
+ const over = shapesSvg(shapesOf(p, this.#resolved, width, height, this.#lang()));
541
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-label="${this.#label().replace(/&/g, "&amp;").replace(/"/g, "&quot;")}">${back}${bodies}${over}</svg>`;
542
+ }
543
+ }
544
+ if (typeof customElements !== "undefined" && !customElements.get("wakusei-portrait"))
545
+ customElements.define("wakusei-portrait", WakuseiPortrait);