@signal9/era-ui 3.6.0 → 3.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.
- package/dist/era-ui.css +1 -1
- package/dist/generated-docs/llms-full.txt +29 -0
- package/dist/generated-docs/llms.txt +1 -1
- package/dist/generated-docs/logo.md +24 -0
- package/dist/generated-docs/manifest.json +11 -0
- package/dist/styles/index.css +13 -1
- package/dist/ui/index.d.ts +2 -0
- package/dist/ui/index.js +1 -0
- package/dist/ui/logo/index.d.ts +3 -0
- package/dist/ui/logo/index.js +2 -0
- package/dist/ui/logo/logo.svelte +744 -0
- package/dist/ui/logo/logo.svelte.d.ts +181 -0
- package/package.json +1 -1
|
@@ -0,0 +1,744 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SIGNALNINE dish — the logo, in one self-contained file.
|
|
4
|
+
*
|
|
5
|
+
* Zero imports and zero dependencies, deliberately: this file is the mark's
|
|
6
|
+
* portable master copy, so it stays droppable into any Svelte 5 project
|
|
7
|
+
* unchanged. It is the one component in the library that does NOT consume
|
|
8
|
+
* --era-* tokens — a logo has fixed brand geometry and colour, and must
|
|
9
|
+
* render identically whatever density, surface, or theme surrounds it.
|
|
10
|
+
* Do not "fix" that by tokenising it.
|
|
11
|
+
*
|
|
12
|
+
* A nonagon satellite dish modeled as a true closed shell (inner bowl, outer
|
|
13
|
+
* underside cone, rim wall), orthographically projected to plain SVG polygons
|
|
14
|
+
* with painter depth-sorting, backface culling, and Lambert shading in oklch.
|
|
15
|
+
* No rendering library — the math below is the product.
|
|
16
|
+
*
|
|
17
|
+
* The render math and physics tables are exported from this module block, so
|
|
18
|
+
* tooling (labs, exporters, tests) can reuse them without duplicating
|
|
19
|
+
* geometry:
|
|
20
|
+
* import { Logo, renderLogoMarkup, PAL } from './index.js';
|
|
21
|
+
*
|
|
22
|
+
* Colors, geometry constants, and defaults are final brand decisions.
|
|
23
|
+
*
|
|
24
|
+
* Ported from blog.signalnine.dev (src/lib/SignalnineDish.svelte); era-ui is
|
|
25
|
+
* now the canonical home. Public types/functions were renamed Dish* -> Logo*;
|
|
26
|
+
* the geometry and physics are untouched.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type LogoPalette = 'amber' | 'chrome' | 'steel';
|
|
30
|
+
export type LogoState =
|
|
31
|
+
'manual' | 'idle' | 'loading' | 'search' | 'transmit' | 'success' | 'error' | 'sleep';
|
|
32
|
+
export type HueOverride = { hD: number; hL: number; C: number };
|
|
33
|
+
export type Ring = { r: number; rise: number; a: number; w: number };
|
|
34
|
+
|
|
35
|
+
export interface LogoParams {
|
|
36
|
+
/** 0 flat plate → 100 deepest dish (mouth always full width). */
|
|
37
|
+
depth?: number;
|
|
38
|
+
/** Core-inlay dilate, 40–240 (%). */
|
|
39
|
+
inset?: number;
|
|
40
|
+
/** View elevation, 0 flat 2D face-on → 100 edge-on/underside. */
|
|
41
|
+
tilt?: number;
|
|
42
|
+
/** Screen-plane rotation, degrees. */
|
|
43
|
+
aim?: number;
|
|
44
|
+
/** Key-light orbit around the dish axis, degrees. */
|
|
45
|
+
light?: number;
|
|
46
|
+
palette?: LogoPalette;
|
|
47
|
+
/** Global luminance multiplier (sleep dims to 0.5). */
|
|
48
|
+
dim?: number;
|
|
49
|
+
/** Rotor spin, degrees (loading preset). */
|
|
50
|
+
spin?: number;
|
|
51
|
+
/** Core opacity multiplier (error blink). */
|
|
52
|
+
feedBlink?: number;
|
|
53
|
+
/** Core radius multiplier (transmit pulse). */
|
|
54
|
+
feedPulse?: number;
|
|
55
|
+
/** Rising transmit/success rings. */
|
|
56
|
+
rings?: Ring[] | null;
|
|
57
|
+
/** State recolor (error red, success green). */
|
|
58
|
+
hueOv?: HueOverride | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const LOGO_STATES: LogoState[] = [
|
|
62
|
+
'manual',
|
|
63
|
+
'idle',
|
|
64
|
+
'loading',
|
|
65
|
+
'search',
|
|
66
|
+
'transmit',
|
|
67
|
+
'success',
|
|
68
|
+
'error',
|
|
69
|
+
'sleep'
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
const D2R = Math.PI / 180;
|
|
73
|
+
const R = 80;
|
|
74
|
+
|
|
75
|
+
export const wrap = (d: number) => ((((d + 180) % 360) + 360) % 360) - 180;
|
|
76
|
+
export const clamp = (v: number, a: number, b: number) => Math.max(a, Math.min(b, v));
|
|
77
|
+
export const norm360 = (v: number) => ((v % 360) + 360) % 360;
|
|
78
|
+
|
|
79
|
+
// extreme dynamic range: L spans deep shadow → near-white; hue warms in shadow
|
|
80
|
+
// and pales toward the highlight; chroma peaks in the midtones (bell), like
|
|
81
|
+
// real metal.
|
|
82
|
+
export const PAL: Record<
|
|
83
|
+
LogoPalette,
|
|
84
|
+
{
|
|
85
|
+
Cm: number;
|
|
86
|
+
hD: number;
|
|
87
|
+
hL: number;
|
|
88
|
+
loI: number;
|
|
89
|
+
hiI: number;
|
|
90
|
+
loO: number;
|
|
91
|
+
hiO: number;
|
|
92
|
+
dot: string;
|
|
93
|
+
tile: string;
|
|
94
|
+
}
|
|
95
|
+
> = {
|
|
96
|
+
amber: {
|
|
97
|
+
Cm: 0.15,
|
|
98
|
+
// Burned amber. The shadow end sits at scorched orange-red (38°) and the
|
|
99
|
+
// HIGHLIGHT deliberately stops at 70° / L 0.86 — pushing it to white or
|
|
100
|
+
// past 80° is what made the mark read as pale yellow instead of hot
|
|
101
|
+
// metal. Verified in-gamut across the whole ramp, so nothing posterizes
|
|
102
|
+
// on the way to the highlight.
|
|
103
|
+
hD: 38,
|
|
104
|
+
hL: 70,
|
|
105
|
+
loI: 0.24,
|
|
106
|
+
hiI: 0.86,
|
|
107
|
+
loO: 0.18,
|
|
108
|
+
hiO: 0.36,
|
|
109
|
+
dot: 'oklch(0.93 0.08 72)',
|
|
110
|
+
tile: '#1c1206'
|
|
111
|
+
},
|
|
112
|
+
chrome: {
|
|
113
|
+
Cm: 0.09,
|
|
114
|
+
hD: 258,
|
|
115
|
+
hL: 208,
|
|
116
|
+
loI: 0.25,
|
|
117
|
+
hiI: 0.975,
|
|
118
|
+
loO: 0.22,
|
|
119
|
+
hiO: 0.4,
|
|
120
|
+
dot: 'oklch(0.985 0.008 215)',
|
|
121
|
+
tile: '#0c141b'
|
|
122
|
+
},
|
|
123
|
+
steel: {
|
|
124
|
+
Cm: 0.035,
|
|
125
|
+
hD: 252,
|
|
126
|
+
hL: 240,
|
|
127
|
+
loI: 0.27,
|
|
128
|
+
hiI: 0.94,
|
|
129
|
+
loO: 0.24,
|
|
130
|
+
hiO: 0.38,
|
|
131
|
+
dot: 'oklch(0.98 0.006 245)',
|
|
132
|
+
tile: '#101418'
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
export const PALETTES = Object.keys(PAL) as LogoPalette[];
|
|
136
|
+
|
|
137
|
+
function faceColor(
|
|
138
|
+
t: number,
|
|
139
|
+
P: (typeof PAL)[LogoPalette],
|
|
140
|
+
dim: number,
|
|
141
|
+
hueOv: HueOverride | null,
|
|
142
|
+
back: boolean
|
|
143
|
+
) {
|
|
144
|
+
const x = Math.max(0, Math.min(1, t));
|
|
145
|
+
const smooth = x * x * (3 - 2 * x);
|
|
146
|
+
// Two curves on purpose. LUMINANCE blends smoothstep toward linear, which
|
|
147
|
+
// spaces the facets evenly instead of crushing the shadow end. HUE and
|
|
148
|
+
// CHROMA stay on the original smoothstep: they decide how amber the metal
|
|
149
|
+
// reads, and running them off the lifted curve drags every facet toward the
|
|
150
|
+
// pale end of the ramp — the mark goes yellow instead of amber.
|
|
151
|
+
const sL = 0.45 * smooth + 0.55 * x;
|
|
152
|
+
const lo = back ? P.loO : P.loI;
|
|
153
|
+
const hi = back ? P.hiO : P.hiI;
|
|
154
|
+
const L = (lo + (hi - lo) * sL) * dim;
|
|
155
|
+
const hD = hueOv ? hueOv.hD : P.hD;
|
|
156
|
+
const hL = hueOv ? hueOv.hL : P.hL;
|
|
157
|
+
const C = (hueOv ? hueOv.C : P.Cm) * (0.72 + 1.12 * smooth * (1 - smooth)); // chroma bell
|
|
158
|
+
return `oklch(${L.toFixed(3)} ${C.toFixed(3)} ${(hD + (hL - hD) * smooth).toFixed(1)})`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------- vec helpers ----------
|
|
162
|
+
type V3 = [number, number, number];
|
|
163
|
+
const sub = (a: V3, b: V3): V3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
|
164
|
+
const cross = (a: V3, b: V3): V3 => [
|
|
165
|
+
a[1] * b[2] - a[2] * b[1],
|
|
166
|
+
a[2] * b[0] - a[0] * b[2],
|
|
167
|
+
a[0] * b[1] - a[1] * b[0]
|
|
168
|
+
];
|
|
169
|
+
const dot = (a: V3, b: V3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
|
170
|
+
const norm = (a: V3): V3 => {
|
|
171
|
+
const l = Math.hypot(a[0], a[1], a[2]) || 1;
|
|
172
|
+
return [a[0] / l, a[1] / l, a[2] / l];
|
|
173
|
+
};
|
|
174
|
+
const ptsAttr = (pts: V3[]) => pts.map((p) => p[0].toFixed(2) + ',' + p[1].toFixed(2)).join(' ');
|
|
175
|
+
|
|
176
|
+
// ---------- model: dish shell in model space (axis = +Y up, apex at origin) ----------
|
|
177
|
+
// Single-phase DEPTH: the mouth stays full width while the bowl deepens.
|
|
178
|
+
// 0% = flat plate, 100% = deepest dish. (No closed-bud regime — the core
|
|
179
|
+
// inlay can never be swallowed by the fold.)
|
|
180
|
+
const A_DISH = Math.atan2(55, R); // ~34.5°: deepest dish wall angle
|
|
181
|
+
|
|
182
|
+
// True closed shell: inner bowl + real underside cone + rim band. No
|
|
183
|
+
// screen-space tricks — every viewing angle renders the actual solid, so it
|
|
184
|
+
// sits on any background.
|
|
185
|
+
function buildModel(depthT: number, spinDeg: number) {
|
|
186
|
+
const h = R * Math.tan(depthT * A_DISH);
|
|
187
|
+
const t = 4.5 + 5 * depthT; // wall thickness
|
|
188
|
+
const verts: V3[] = [[0, 0, 0]]; // 0 = inner apex; 1..9 inner rim
|
|
189
|
+
for (let k = 0; k < 9; k++) {
|
|
190
|
+
const th = (-90 + k * 40 + spinDeg) * D2R;
|
|
191
|
+
verts.push([R * Math.cos(th), h, R * Math.sin(th)]);
|
|
192
|
+
}
|
|
193
|
+
verts.push([0, -t, 0]); // 10 = outer apex; 11..19 outer rim
|
|
194
|
+
for (let k = 0; k < 9; k++) {
|
|
195
|
+
const v = verts[1 + k];
|
|
196
|
+
verts.push([v[0], v[1] - t, v[2]]);
|
|
197
|
+
}
|
|
198
|
+
const faces: { ix: number[]; kind: 'in' | 'out' | 'band' }[] = [];
|
|
199
|
+
for (let k = 0; k < 9; k++) {
|
|
200
|
+
const j = (k + 1) % 9;
|
|
201
|
+
faces.push({ ix: [0, 1 + k, 1 + j], kind: 'in' }); // bowl gore
|
|
202
|
+
faces.push({ ix: [10, 11 + j, 11 + k], kind: 'out' }); // underside gore
|
|
203
|
+
faces.push({ ix: [1 + k, 1 + j, 11 + j, 11 + k], kind: 'band' }); // rim edge wall
|
|
204
|
+
}
|
|
205
|
+
return { verts, faces, h };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------- projection ----------
|
|
209
|
+
// rho=90°: axis at viewer (flat 2D nonagon). 0°: edge-on. <0: underside shows.
|
|
210
|
+
function makeProjector(rho: number) {
|
|
211
|
+
const c = Math.cos(rho),
|
|
212
|
+
s = Math.sin(rho);
|
|
213
|
+
return (p: V3): V3 => [p[0], -(p[1] * c - p[2] * s), p[1] * s + p[2] * c];
|
|
214
|
+
}
|
|
215
|
+
const FILL = 0.1; // head-on sheen weight
|
|
216
|
+
const AMBIENT = 0.13; // bounce fill: keeps the away-facing facets readable
|
|
217
|
+
const TILT_SPAN = 106; // deg swept from flat (90°) to underside (-16°)
|
|
218
|
+
|
|
219
|
+
/** oklch → sRGB hex, for portable export. */
|
|
220
|
+
export function oklchToHex(Lc: number, C: number, H: number) {
|
|
221
|
+
const hr = (H * Math.PI) / 180,
|
|
222
|
+
a = C * Math.cos(hr),
|
|
223
|
+
b = C * Math.sin(hr);
|
|
224
|
+
const l_ = Lc + 0.3963377774 * a + 0.2158037573 * b;
|
|
225
|
+
const m_ = Lc - 0.1055613458 * a - 0.0638541728 * b;
|
|
226
|
+
const s_ = Lc - 0.0894841775 * a - 1.291485548 * b;
|
|
227
|
+
const l = l_ ** 3,
|
|
228
|
+
m = m_ ** 3,
|
|
229
|
+
s = s_ ** 3;
|
|
230
|
+
const r = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
|
|
231
|
+
const g = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
|
|
232
|
+
const bl = -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s;
|
|
233
|
+
const gam = (c: number) => {
|
|
234
|
+
c = Math.max(0, Math.min(1, c));
|
|
235
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
236
|
+
};
|
|
237
|
+
const to = (v: number) =>
|
|
238
|
+
Math.round(gam(v) * 255)
|
|
239
|
+
.toString(16)
|
|
240
|
+
.padStart(2, '0');
|
|
241
|
+
return '#' + to(r) + to(g) + to(bl);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One frame of the dish, as inner SVG markup for a -100 -100 200 200 canvas. */
|
|
245
|
+
export function renderLogoMarkup(params: LogoParams = {}): string {
|
|
246
|
+
const {
|
|
247
|
+
depth = 52,
|
|
248
|
+
inset = 100,
|
|
249
|
+
tilt = 55,
|
|
250
|
+
aim = 0,
|
|
251
|
+
light = REST_LIGHT,
|
|
252
|
+
palette = 'amber',
|
|
253
|
+
dim = 1,
|
|
254
|
+
spin = 0,
|
|
255
|
+
feedBlink = 1,
|
|
256
|
+
feedPulse = 1,
|
|
257
|
+
rings = null,
|
|
258
|
+
hueOv = null
|
|
259
|
+
} = params;
|
|
260
|
+
const P = PAL[palette] ?? PAL.amber;
|
|
261
|
+
|
|
262
|
+
const rr = (90 - (tilt / 100) * TILT_SPAN) * D2R;
|
|
263
|
+
const M = buildModel(depth / 100, spin);
|
|
264
|
+
const pr = makeProjector(rr);
|
|
265
|
+
const PV = M.verts.map(pr); // project every vertex ONCE
|
|
266
|
+
// key light ORBITS the dish axis (model space) so sliding it rotates the
|
|
267
|
+
// bright petal evenly around the ring, independent of the view tilt.
|
|
268
|
+
const phi = light * D2R;
|
|
269
|
+
const Lkey = norm([Math.cos(phi), 0.55, Math.sin(phi)]);
|
|
270
|
+
|
|
271
|
+
// shade + cull + depth-sort the shell (painter). Inner bowl gets full
|
|
272
|
+
// Lambert; underside and rim band stay flat dark tones.
|
|
273
|
+
const underFill = faceColor(0.16, P, dim, hueOv, true);
|
|
274
|
+
const bandFill = faceColor(0.3, P, dim, hueOv, true);
|
|
275
|
+
const polys: { z: number; d: string; fill: string }[] = [];
|
|
276
|
+
for (const f of M.faces) {
|
|
277
|
+
const pts = f.ix.map((i) => M.verts[i]);
|
|
278
|
+
let n = norm(cross(sub(pts[1], pts[0]), sub(pts[2], pts[0])));
|
|
279
|
+
const orient: V3 =
|
|
280
|
+
f.kind === 'in'
|
|
281
|
+
? [0, 1, 0]
|
|
282
|
+
: f.kind === 'out'
|
|
283
|
+
? [0, -1, 0]
|
|
284
|
+
: [(pts[0][0] + pts[1][0]) / 2, 0, (pts[0][2] + pts[1][2]) / 2];
|
|
285
|
+
if (dot(n, orient) < 0) n = n.map((v) => -v) as V3; // explicit outward orientation
|
|
286
|
+
const ns = pr(n);
|
|
287
|
+
if (ns[2] <= 0.015) continue; // backface cull — the shell is closed
|
|
288
|
+
const sp = f.ix.map((i) => PV[i]);
|
|
289
|
+
let fill;
|
|
290
|
+
if (f.kind === 'in') {
|
|
291
|
+
const lit = Math.max(0, dot(n, Lkey)) + Math.max(0, ns[2]) * FILL;
|
|
292
|
+
fill = faceColor(Math.min(1, AMBIENT + (1 - AMBIENT) * lit), P, dim, hueOv, false);
|
|
293
|
+
} else fill = f.kind === 'out' ? underFill : bandFill;
|
|
294
|
+
polys.push({ z: sp.reduce((a, p) => a + p[2], 0) / sp.length, d: ptsAttr(sp), fill });
|
|
295
|
+
}
|
|
296
|
+
polys.sort((p, q) => p.z - q.z);
|
|
297
|
+
|
|
298
|
+
// vertical recenter over the whole projected solid
|
|
299
|
+
let miny = 1e9,
|
|
300
|
+
maxy = -1e9;
|
|
301
|
+
for (const p of PV) {
|
|
302
|
+
if (p[1] < miny) miny = p[1];
|
|
303
|
+
if (p[1] > maxy) maxy = p[1];
|
|
304
|
+
}
|
|
305
|
+
const dy = -(miny + maxy) / 2;
|
|
306
|
+
|
|
307
|
+
// aim: rotate in the screen plane — exact for a 9-fold-symmetric solid
|
|
308
|
+
let s = `<g><g transform="rotate(${aim.toFixed(1)}) translate(0,${dy.toFixed(2)})">`;
|
|
309
|
+
// stroke = own fill at hairline width: kills antialiasing seams without
|
|
310
|
+
// visible separators between facets
|
|
311
|
+
for (const p of polys)
|
|
312
|
+
s += `<polygon points="${p.d}" fill="${p.fill}" stroke="${p.fill}" stroke-width="0.5" stroke-linejoin="miter"/>`;
|
|
313
|
+
|
|
314
|
+
// CORE INLAY: dark nonagon socket + bright nonagon core on the bowl
|
|
315
|
+
// surface. Visible exactly when the sightline over the near rim reaches
|
|
316
|
+
// the apex (true geometry, not a hack): tan(view elevation) must exceed
|
|
317
|
+
// the wall slope h/R.
|
|
318
|
+
const axisZ = pr([0, 1, 0])[2];
|
|
319
|
+
const vis = M.h < 0.01 ? 1 : Math.max(0, Math.min(1, ((Math.tan(rr) * R) / M.h - 1) * 2.5));
|
|
320
|
+
const K = axisZ > 0 ? Math.min(1, axisZ * 1.6) * vis : 0;
|
|
321
|
+
if (K > 0.02) {
|
|
322
|
+
const ring = (t: number) =>
|
|
323
|
+
M.verts
|
|
324
|
+
.slice(1, 10)
|
|
325
|
+
.map((v) => pr([v[0] * t, v[1] * t, v[2] * t]))
|
|
326
|
+
.map((p) => p[0].toFixed(2) + ',' + p[1].toFixed(2))
|
|
327
|
+
.join(' ');
|
|
328
|
+
const hD = hueOv ? hueOv.hD : P.hD,
|
|
329
|
+
Cm = hueOv ? hueOv.C : P.Cm;
|
|
330
|
+
const dil = inset / 100;
|
|
331
|
+
const tS = Math.min(0.88, 0.21 * dil),
|
|
332
|
+
tC = Math.min(0.7, 0.115 * dil);
|
|
333
|
+
s +=
|
|
334
|
+
`<polygon points="${ring(tS)}" fill="oklch(${(0.14 * dim).toFixed(3)} ${(Cm * 0.45).toFixed(3)} ${hD})" opacity="${K.toFixed(2)}"/>` +
|
|
335
|
+
`<polygon points="${ring(tC * feedPulse)}" fill="${P.dot}" opacity="${(K * feedBlink).toFixed(2)}"/>`;
|
|
336
|
+
}
|
|
337
|
+
if (rings)
|
|
338
|
+
for (const ring of rings) {
|
|
339
|
+
let pts = '';
|
|
340
|
+
for (let i = 0; i <= 36; i++) {
|
|
341
|
+
const th = (i / 36) * 2 * Math.PI;
|
|
342
|
+
const p = pr([ring.r * Math.cos(th), M.h + ring.rise, ring.r * Math.sin(th)]);
|
|
343
|
+
pts += p[0].toFixed(1) + ',' + p[1].toFixed(1) + ' ';
|
|
344
|
+
}
|
|
345
|
+
s += `<polyline points="${pts.trim()}" fill="none" stroke="${P.dot}" stroke-width="${ring.w.toFixed(2)}" opacity="${ring.a.toFixed(2)}" stroke-linejoin="round"/>`;
|
|
346
|
+
}
|
|
347
|
+
return s + '</g></g>';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Standalone portable SVG of a pose — oklch converted to sRGB hex. */
|
|
351
|
+
export function logoSvgDocument(params: LogoParams = {}, size = 400): string {
|
|
352
|
+
const inner = renderLogoMarkup(params).replace(
|
|
353
|
+
/oklch\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s*\)/g,
|
|
354
|
+
(_m, l, c, h) => oklchToHex(+l, +c, +h)
|
|
355
|
+
);
|
|
356
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-100 -100 200 200" width="${size}" height="${size}">${inner}</svg>`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ---------- pedestal physics ----------
|
|
360
|
+
// Every degree of freedom is a damped torsion spring, as if the dish were a
|
|
361
|
+
// mass on a real gimbal mount. Presets set TARGETS (and fire impulses); the
|
|
362
|
+
// springs do the moving. Direct input bypasses them entirely.
|
|
363
|
+
export const SPR = {
|
|
364
|
+
depth: { k: 70, c: 12 },
|
|
365
|
+
inset: { k: 80, c: 13 },
|
|
366
|
+
tilt: { k: 34, c: 7 },
|
|
367
|
+
aim: { k: 26, c: 5.2 },
|
|
368
|
+
light: { k: 90, c: 17 }
|
|
369
|
+
} as const;
|
|
370
|
+
export type Dof = keyof typeof SPR;
|
|
371
|
+
export type LogoSim = Record<Dof, { v: number; vel: number }> & {
|
|
372
|
+
/** Where a spinning light has committed to stop. Always AHEAD of the
|
|
373
|
+
* current angle, so the sweep never reverses to get home. */
|
|
374
|
+
light: { v: number; vel: number; park?: number | null };
|
|
375
|
+
};
|
|
376
|
+
export type Fx = {
|
|
377
|
+
spinV: number;
|
|
378
|
+
dim: number;
|
|
379
|
+
feedBlink: number;
|
|
380
|
+
feedPulse: number;
|
|
381
|
+
rings: Ring[] | null;
|
|
382
|
+
hueOv: HueOverride | null;
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
export function createSim(p: Required<Pick<LogoParams, Dof>>): LogoSim {
|
|
386
|
+
return {
|
|
387
|
+
depth: { v: p.depth, vel: 0 },
|
|
388
|
+
inset: { v: p.inset, vel: 0 },
|
|
389
|
+
tilt: { v: p.tilt, vel: 0 },
|
|
390
|
+
aim: { v: p.aim, vel: 0 },
|
|
391
|
+
light: { v: p.light, vel: 0 }
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Orbit speed above which the light is treated as spinning and must finish
|
|
396
|
+
* its rotation rather than be sprung to the nearest angle. */
|
|
397
|
+
const SPIN_CARRY = 60; // deg/s
|
|
398
|
+
/** How fast the key light sweeps while loading — about 1.2 turns/sec. */
|
|
399
|
+
const SWEEP_RATE = 440; // deg/s
|
|
400
|
+
/** Where the key light parks at rest. Every preset targets this (or an offset
|
|
401
|
+
* from it), so moving it re-lights the mark everywhere at once. */
|
|
402
|
+
export const REST_LIGHT = 250; // deg
|
|
403
|
+
|
|
404
|
+
/** Fixed physics step. Long frames are sub-stepped rather than integrated in
|
|
405
|
+
* one huge leap — a stalled frame (e.g. the main thread finishing a
|
|
406
|
+
* navigation) would otherwise blow the spring up and snap the pose. */
|
|
407
|
+
export const FIXED_STEP = 1 / 120;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Advance every spring one step toward its target.
|
|
411
|
+
*
|
|
412
|
+
* `coastLight` cuts the light's spring drive while it is still orbiting fast,
|
|
413
|
+
* leaving only drag — the sweep spins down like a motor losing power instead
|
|
414
|
+
* of being yanked to the nearest rest angle (which reverses it) or whipped to
|
|
415
|
+
* one a full turn ahead (which speeds it up). Once it drops below SPIN_CARRY
|
|
416
|
+
* the spring takes over and parks it.
|
|
417
|
+
*/
|
|
418
|
+
export function integrate(
|
|
419
|
+
sim: LogoSim,
|
|
420
|
+
tgt: Record<Dof, number>,
|
|
421
|
+
dt: number,
|
|
422
|
+
coastLight = false
|
|
423
|
+
) {
|
|
424
|
+
if (!coastLight) sim.light.park = null; // drive restored — abandon any parking plan
|
|
425
|
+
for (const key of Object.keys(SPR) as Dof[]) {
|
|
426
|
+
const s = sim[key];
|
|
427
|
+
const p = SPR[key];
|
|
428
|
+
// A spinning light always FINISHES ITS ROTATION: it commits to the next
|
|
429
|
+
// arrival of the rest angle that lies ahead, then decelerates to land on
|
|
430
|
+
// it with ~zero speed. Never reverses, never speeds up. If that arrival
|
|
431
|
+
// is too close to stop gracefully, it goes around once more.
|
|
432
|
+
// A spinning light FINISHES ITS ROTATION at full speed: on losing drive it
|
|
433
|
+
// commits to the next pass of the rest angle ahead of it and holds the
|
|
434
|
+
// sweep rate the whole way — no easing, no reversal — then stops dead on
|
|
435
|
+
// its mark. Landing exactly on home means the halt shows as motion
|
|
436
|
+
// ending, not as the mark jumping.
|
|
437
|
+
if (key === 'light' && coastLight) {
|
|
438
|
+
const L = sim.light;
|
|
439
|
+
if (L.park != null || L.vel > SPIN_CARRY) {
|
|
440
|
+
if (L.park == null) L.park = L.v + norm360(tgt.light - L.v); // next pass of home
|
|
441
|
+
const step = L.vel * dt; // velocity untouched: constant cruise
|
|
442
|
+
if (L.park - L.v > step) {
|
|
443
|
+
L.v += step;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
L.v = L.park; // arrived
|
|
447
|
+
L.vel = 0;
|
|
448
|
+
L.park = null;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
let d = tgt[key] - s.v;
|
|
452
|
+
if (key === 'aim' || key === 'light') d = wrap(d);
|
|
453
|
+
s.vel += (p.k * d - p.c * s.vel) * dt;
|
|
454
|
+
s.v += s.vel * dt;
|
|
455
|
+
}
|
|
456
|
+
sim.depth.v = clamp(sim.depth.v, 0, 100);
|
|
457
|
+
sim.tilt.v = clamp(sim.tilt.v, 0, 100);
|
|
458
|
+
sim.inset.v = clamp(sim.inset.v, 40, 240);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Per-state spring targets and visual effects. Mutates `sim` for impulses.
|
|
463
|
+
*
|
|
464
|
+
* Tilt targets are expressed RELATIVE to `baseTilt` (the mark's resting
|
|
465
|
+
* elevation), so a mark parked at a different tilt keeps its silhouette
|
|
466
|
+
* through every preset instead of springing back to the stock 55. At the
|
|
467
|
+
* default 55 the numbers are identical to the original absolute table.
|
|
468
|
+
*/
|
|
469
|
+
export function stateFrame(
|
|
470
|
+
st: LogoState,
|
|
471
|
+
t: number,
|
|
472
|
+
scl: number,
|
|
473
|
+
sim: LogoSim,
|
|
474
|
+
jolt: { last: number; sign: number },
|
|
475
|
+
baseTilt = 55
|
|
476
|
+
): { tgt: Record<Dof, number>; fx: Fx } {
|
|
477
|
+
const fx: Fx = { spinV: 0, dim: 1, feedBlink: 1, feedPulse: 1, rings: null, hueOv: null };
|
|
478
|
+
let tgt: Record<Dof, number> = {
|
|
479
|
+
depth: 52,
|
|
480
|
+
tilt: baseTilt,
|
|
481
|
+
aim: 0,
|
|
482
|
+
light: REST_LIGHT,
|
|
483
|
+
inset: scl
|
|
484
|
+
};
|
|
485
|
+
switch (st) {
|
|
486
|
+
case 'idle':
|
|
487
|
+
tgt = {
|
|
488
|
+
depth: 52 + 4 * Math.sin(t * 0.9),
|
|
489
|
+
tilt: baseTilt,
|
|
490
|
+
aim: 6 * Math.sin(t * 0.45),
|
|
491
|
+
light: REST_LIGHT + 15 * Math.sin(t * 0.3),
|
|
492
|
+
inset: scl
|
|
493
|
+
};
|
|
494
|
+
break;
|
|
495
|
+
case 'loading':
|
|
496
|
+
// Pure light orbit: the solid holds its resting pose and only the key
|
|
497
|
+
// light sweeps around the axis, so the mark reads as a spinner without
|
|
498
|
+
// the shape itself thrashing. No depth bob, no rotor spin.
|
|
499
|
+
tgt = { depth: 52, tilt: baseTilt, aim: 0, light: REST_LIGHT + t * SWEEP_RATE, inset: scl };
|
|
500
|
+
break;
|
|
501
|
+
case 'search':
|
|
502
|
+
// slew between hard targets — the spring gives momentum + overshoot
|
|
503
|
+
tgt = {
|
|
504
|
+
depth: 57,
|
|
505
|
+
tilt: baseTilt + 6 * Math.sin(t * 0.8),
|
|
506
|
+
aim: Math.floor(t / 2.4) % 2 ? 55 : -55,
|
|
507
|
+
light: REST_LIGHT,
|
|
508
|
+
inset: scl
|
|
509
|
+
};
|
|
510
|
+
break;
|
|
511
|
+
case 'transmit': {
|
|
512
|
+
tgt = { depth: 98, tilt: baseTilt + 19, aim: 0, light: REST_LIGHT, inset: scl }; // deepest collector
|
|
513
|
+
fx.rings = [0, 1, 2].map((i) => {
|
|
514
|
+
const ph = (t * 0.9 + i / 3) % 1;
|
|
515
|
+
return { r: 14 + ph * 95, rise: ph * 26, a: (1 - ph) * 0.85, w: 2.6 - 1.8 * ph };
|
|
516
|
+
});
|
|
517
|
+
fx.feedPulse = 1 + 0.5 * Math.sin(t * 6);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
case 'success': {
|
|
521
|
+
tgt = { depth: 52, tilt: baseTilt + 5, aim: 0, light: REST_LIGHT, inset: scl };
|
|
522
|
+
const ph = (t * 0.4) % 1;
|
|
523
|
+
fx.hueOv = { hD: 138, hL: 165, C: 0.12 };
|
|
524
|
+
fx.feedPulse = 1.15 + 0.15 * Math.sin(t * 3);
|
|
525
|
+
fx.rings = [{ r: 14 + ph * 100, rise: ph * 18, a: (1 - ph) * 0.5, w: 2 - 1.2 * ph }];
|
|
526
|
+
break;
|
|
527
|
+
}
|
|
528
|
+
case 'error':
|
|
529
|
+
tgt = { depth: 100, tilt: baseTilt, aim: 0, light: REST_LIGHT, inset: scl }; // slams to full depth
|
|
530
|
+
fx.hueOv = { hD: 16, hL: 34, C: 0.18 };
|
|
531
|
+
fx.feedBlink = Math.sin(t * 10) > 0 ? 1 : 0.15;
|
|
532
|
+
if (t - jolt.last > 2.0) {
|
|
533
|
+
// physical jolt: velocity impulse
|
|
534
|
+
jolt.last = t;
|
|
535
|
+
jolt.sign = -jolt.sign;
|
|
536
|
+
sim.aim.vel += jolt.sign * 150;
|
|
537
|
+
sim.tilt.vel += 35;
|
|
538
|
+
}
|
|
539
|
+
break;
|
|
540
|
+
case 'sleep':
|
|
541
|
+
tgt = {
|
|
542
|
+
depth: 71 + 4 * Math.sin(t * 0.5),
|
|
543
|
+
tilt: baseTilt - 5,
|
|
544
|
+
aim: 150,
|
|
545
|
+
light: REST_LIGHT - 10,
|
|
546
|
+
inset: scl
|
|
547
|
+
}; // droops on its mount
|
|
548
|
+
fx.dim = 0.5;
|
|
549
|
+
fx.feedBlink = 0.25 + 0.1 * Math.sin(t * 0.7);
|
|
550
|
+
break;
|
|
551
|
+
}
|
|
552
|
+
return { tgt, fx };
|
|
553
|
+
}
|
|
554
|
+
</script>
|
|
555
|
+
|
|
556
|
+
<script lang="ts">
|
|
557
|
+
let {
|
|
558
|
+
depth = 52,
|
|
559
|
+
tilt = 55,
|
|
560
|
+
aim = 0,
|
|
561
|
+
light = REST_LIGHT,
|
|
562
|
+
inset = 100,
|
|
563
|
+
palette = 'amber',
|
|
564
|
+
state: stateProp = 'manual',
|
|
565
|
+
size = 272,
|
|
566
|
+
pressable = false,
|
|
567
|
+
class: className
|
|
568
|
+
}: {
|
|
569
|
+
depth?: number;
|
|
570
|
+
tilt?: number;
|
|
571
|
+
aim?: number;
|
|
572
|
+
light?: number;
|
|
573
|
+
inset?: number;
|
|
574
|
+
palette?: LogoPalette;
|
|
575
|
+
/** 'manual' renders a static pose; any other state animates its preset. */
|
|
576
|
+
state?: LogoState;
|
|
577
|
+
size?: number;
|
|
578
|
+
/** Deepen the bowl while held, then spring back on release. */
|
|
579
|
+
pressable?: boolean;
|
|
580
|
+
class?: string;
|
|
581
|
+
} = $props();
|
|
582
|
+
|
|
583
|
+
let reduced = $state(false);
|
|
584
|
+
// Reduced motion forces the static mark.
|
|
585
|
+
$effect(() => {
|
|
586
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
587
|
+
reduced = mq.matches;
|
|
588
|
+
const on = (e: MediaQueryListEvent) => (reduced = e.matches);
|
|
589
|
+
mq.addEventListener('change', on);
|
|
590
|
+
return () => mq.removeEventListener('change', on);
|
|
591
|
+
});
|
|
592
|
+
const eff: LogoState = $derived(reduced ? 'manual' : stateProp);
|
|
593
|
+
|
|
594
|
+
// ---------- press spring ----------
|
|
595
|
+
// Hold and the bowl deepens; release and it springs back with a little
|
|
596
|
+
// overshoot. Its own short-lived rAF, because a `manual` mark has no loop.
|
|
597
|
+
const PRESS_SPR = { k: 600, c: 30 }; // stiffer => quicker press, same feel
|
|
598
|
+
const PRESS_DEPTH = 70; // extra depth (0–100 scale) at full press
|
|
599
|
+
let pressV = $state(0);
|
|
600
|
+
let pressVel = 0;
|
|
601
|
+
let pressing = false;
|
|
602
|
+
let pressRaf = 0;
|
|
603
|
+
|
|
604
|
+
function pressLoop() {
|
|
605
|
+
if (pressRaf) return; // already settling
|
|
606
|
+
let last = performance.now();
|
|
607
|
+
const step = (now: number) => {
|
|
608
|
+
const dt = Math.min(0.05, (now - last) / 1000) || 0.016;
|
|
609
|
+
last = now;
|
|
610
|
+
const target = pressing ? 1 : 0;
|
|
611
|
+
pressVel += (PRESS_SPR.k * (target - pressV) - PRESS_SPR.c * pressVel) * dt;
|
|
612
|
+
pressV += pressVel * dt;
|
|
613
|
+
// snap to exact rest once the residual is imperceptible
|
|
614
|
+
if (!pressing && Math.abs(pressV) < 0.004 && Math.abs(pressVel) < 0.06) {
|
|
615
|
+
pressV = 0;
|
|
616
|
+
pressVel = 0;
|
|
617
|
+
pressRaf = 0;
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
pressRaf = requestAnimationFrame(step);
|
|
621
|
+
};
|
|
622
|
+
pressRaf = requestAnimationFrame(step);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** Pointer handling attached imperatively, so this decorative SVG doesn't
|
|
626
|
+
* need an interactive ARIA role — whatever link or button wraps the mark
|
|
627
|
+
* stays the real affordance. */
|
|
628
|
+
function press(node: Element) {
|
|
629
|
+
if (!pressable) return;
|
|
630
|
+
const down = () => {
|
|
631
|
+
pressing = true;
|
|
632
|
+
pressLoop();
|
|
633
|
+
};
|
|
634
|
+
const up = () => {
|
|
635
|
+
if (!pressing) return;
|
|
636
|
+
pressing = false;
|
|
637
|
+
pressLoop();
|
|
638
|
+
};
|
|
639
|
+
node.addEventListener('pointerdown', down);
|
|
640
|
+
node.addEventListener('pointercancel', up);
|
|
641
|
+
window.addEventListener('pointerup', up); // release anywhere
|
|
642
|
+
return {
|
|
643
|
+
destroy() {
|
|
644
|
+
node.removeEventListener('pointerdown', down);
|
|
645
|
+
node.removeEventListener('pointercancel', up);
|
|
646
|
+
window.removeEventListener('pointerup', up);
|
|
647
|
+
cancelAnimationFrame(pressRaf);
|
|
648
|
+
pressRaf = 0;
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Static pose — a pure derived, so manual renders cost zero rAF.
|
|
654
|
+
const staticMarkup = $derived(
|
|
655
|
+
renderLogoMarkup({
|
|
656
|
+
depth: clamp(depth + pressV * PRESS_DEPTH, 0, 100),
|
|
657
|
+
inset,
|
|
658
|
+
tilt,
|
|
659
|
+
aim,
|
|
660
|
+
light,
|
|
661
|
+
palette
|
|
662
|
+
})
|
|
663
|
+
);
|
|
664
|
+
// Preset frames land here; seeded with a static frame so SSR paints a real
|
|
665
|
+
// mark before hydration.
|
|
666
|
+
// svelte-ignore state_referenced_locally
|
|
667
|
+
let animMarkup = $state(renderLogoMarkup({ depth, inset, tilt, aim, light, palette }));
|
|
668
|
+
const markup = $derived(eff === 'manual' ? staticMarkup : animMarkup);
|
|
669
|
+
|
|
670
|
+
// ---------- preset loop ----------
|
|
671
|
+
// The simulation lives at component scope, NOT inside the loop effect, so a
|
|
672
|
+
// state change only swaps the spring TARGETS — position and velocity carry
|
|
673
|
+
// over. Re-seeding per state would throw both away and snap the pose (a
|
|
674
|
+
// retargeted spring must preserve position and velocity to stay continuous).
|
|
675
|
+
// svelte-ignore state_referenced_locally
|
|
676
|
+
const sim = createSim({ depth, inset, tilt, aim, light });
|
|
677
|
+
const jolt = { last: -9, sign: 1 };
|
|
678
|
+
let spinVel = 0;
|
|
679
|
+
let spinA = 0;
|
|
680
|
+
|
|
681
|
+
// Runs ONLY for preset states; a static mark costs zero CPU. Client-only
|
|
682
|
+
// ($effect never runs during SSR).
|
|
683
|
+
$effect(() => {
|
|
684
|
+
const st = eff;
|
|
685
|
+
if (st === 'manual') return;
|
|
686
|
+
let raf = 0;
|
|
687
|
+
const t0 = performance.now();
|
|
688
|
+
let lastNow = t0;
|
|
689
|
+
let acc = 0;
|
|
690
|
+
let frameLast = t0;
|
|
691
|
+
const loop = (now: number) => {
|
|
692
|
+
// Cap the catch-up window, then burn it down in FIXED_STEP slices: a
|
|
693
|
+
// stalled frame advances the springs by many small steps instead of one
|
|
694
|
+
// explosive one, so the pose stays continuous across a jank spike.
|
|
695
|
+
acc += Math.min(0.25, (now - lastNow) / 1000) || FIXED_STEP;
|
|
696
|
+
lastNow = now;
|
|
697
|
+
const t = (now - t0) / 1000;
|
|
698
|
+
const { tgt, fx } = stateFrame(st, t, sim.inset.v, sim, jolt, tilt);
|
|
699
|
+
for (let i = 0; acc >= FIXED_STEP && i < 30; i++) {
|
|
700
|
+
integrate(sim, tgt, FIXED_STEP, st !== 'loading');
|
|
701
|
+
acc -= FIXED_STEP;
|
|
702
|
+
}
|
|
703
|
+
const frameDt = Math.min(0.05, (now - frameLast) / 1000) || FIXED_STEP;
|
|
704
|
+
frameLast = now;
|
|
705
|
+
spinVel += (fx.spinV - spinVel) * Math.min(1, frameDt * 2.5); // rotor inertia
|
|
706
|
+
spinA = (spinA + spinVel * frameDt) % 360;
|
|
707
|
+
animMarkup = renderLogoMarkup({
|
|
708
|
+
depth: clamp(sim.depth.v + pressV * PRESS_DEPTH, 0, 100),
|
|
709
|
+
inset: sim.inset.v,
|
|
710
|
+
tilt: sim.tilt.v,
|
|
711
|
+
aim: wrap(sim.aim.v),
|
|
712
|
+
light: norm360(sim.light.v),
|
|
713
|
+
palette,
|
|
714
|
+
spin: spinA,
|
|
715
|
+
dim: fx.dim,
|
|
716
|
+
feedBlink: fx.feedBlink,
|
|
717
|
+
feedPulse: fx.feedPulse,
|
|
718
|
+
rings: fx.rings,
|
|
719
|
+
hueOv: fx.hueOv
|
|
720
|
+
});
|
|
721
|
+
raf = requestAnimationFrame(loop);
|
|
722
|
+
};
|
|
723
|
+
raf = requestAnimationFrame(loop);
|
|
724
|
+
return () => cancelAnimationFrame(raf);
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
/** Standalone SVG of the pose as currently rendered. */
|
|
728
|
+
export function exportSVG(): string {
|
|
729
|
+
return logoSvgDocument({ depth, inset, tilt, aim, light, palette });
|
|
730
|
+
}
|
|
731
|
+
</script>
|
|
732
|
+
|
|
733
|
+
<!-- eslint-disable svelte/no-at-html-tags -- markup comes from our own pure
|
|
734
|
+
render math over numeric parameters; never user-controlled input. -->
|
|
735
|
+
<svg
|
|
736
|
+
use:press
|
|
737
|
+
viewBox="-100 -100 200 200"
|
|
738
|
+
width={size}
|
|
739
|
+
height={size}
|
|
740
|
+
shape-rendering="geometricPrecision"
|
|
741
|
+
role="img"
|
|
742
|
+
aria-label="signalnine"
|
|
743
|
+
class={className}>{@html markup}</svg
|
|
744
|
+
>
|