@jjlmoya/utils-science 1.45.0 → 1.47.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/package.json +4 -2
- package/src/layouts/PreviewLayout.astro +1 -0
- package/src/tests/pagespeed_best_practices.test.ts +198 -0
- package/src/tool/asteroid-impact/asteroid-impact-simulator.css +254 -4
- package/src/tool/asteroid-impact/component.astro +71 -51
- package/src/tool/colony-counter/component.astro +3 -3
- package/src/tool/cosmic-inflation/component.astro +2 -2
- package/src/tool/crystal-lattice-structure-finder/component.astro +2 -2
- package/src/tool/epidemic-sir-simulator/component.astro +3 -3
- package/src/tool/lorenz-attractor/script.ts +2 -2
- package/src/tool/mandelbrot-fractal/component.astro +2 -2
- package/src/tool/planet-atmosphere-survival/component.astro +1 -1
- package/src/tool/stellar-habitability-zone/script.ts +4 -4
- package/src/tool/temperature-timeline/component.astro +3 -3
- package/src/tool/three-body-problem/app.ts +2 -2
package/package.json
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jjlmoya/utils-science",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.47.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.ts",
|
|
9
9
|
"./data": "./src/data.ts",
|
|
10
|
-
"./entries": "./src/entries.ts"
|
|
10
|
+
"./entries": "./src/entries.ts",
|
|
11
|
+
"./runtime/*": "./src/tool/*/index.ts",
|
|
12
|
+
"./category-seo": "./src/category/seo.astro"
|
|
11
13
|
},
|
|
12
14
|
"files": [
|
|
13
15
|
"src",
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
3
|
+
import { join, relative } from 'path';
|
|
4
|
+
import { ALL_TOOLS } from '../tools';
|
|
5
|
+
import type { SEOSection, ToolLocaleContent } from '../types';
|
|
6
|
+
|
|
7
|
+
const srcDir = join(process.cwd(), 'src');
|
|
8
|
+
const toolDir = join(srcDir, 'tool');
|
|
9
|
+
const geometryReads = [
|
|
10
|
+
'offsetWidth',
|
|
11
|
+
'offsetHeight',
|
|
12
|
+
'offsetTop',
|
|
13
|
+
'offsetLeft',
|
|
14
|
+
'clientWidth',
|
|
15
|
+
'clientHeight',
|
|
16
|
+
'clientTop',
|
|
17
|
+
'clientLeft',
|
|
18
|
+
'scrollWidth',
|
|
19
|
+
'scrollHeight',
|
|
20
|
+
'scrollTop',
|
|
21
|
+
'scrollLeft',
|
|
22
|
+
'getBoundingClientRect',
|
|
23
|
+
'getClientRects',
|
|
24
|
+
'computedStyle',
|
|
25
|
+
'getComputedStyle',
|
|
26
|
+
];
|
|
27
|
+
const domWrites = [
|
|
28
|
+
'.style.',
|
|
29
|
+
'.classList.add',
|
|
30
|
+
'.classList.remove',
|
|
31
|
+
'.classList.toggle',
|
|
32
|
+
'.appendChild',
|
|
33
|
+
'.insertBefore',
|
|
34
|
+
'.prepend',
|
|
35
|
+
'.append',
|
|
36
|
+
'.remove',
|
|
37
|
+
'.innerHTML',
|
|
38
|
+
'.textContent',
|
|
39
|
+
'.setAttribute',
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
43
|
+
const files: string[] = [];
|
|
44
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
45
|
+
const fullPath = join(dir, entry.name);
|
|
46
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
47
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
48
|
+
}
|
|
49
|
+
return files;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function relativePath(file: string): string {
|
|
53
|
+
return relative(process.cwd(), file).replace(/\\/g, '/');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
57
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
61
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
62
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
66
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function controlStartIndex(content: string, tag: RegExpMatchArray): number {
|
|
70
|
+
return tag.index ?? content.indexOf(tag[0]);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
74
|
+
const index = controlStartIndex(content, tag);
|
|
75
|
+
const before = content.slice(0, index);
|
|
76
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
77
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
78
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
79
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
83
|
+
const source = tag[0];
|
|
84
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
85
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
86
|
+
const id = attrValue(source, 'id');
|
|
87
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
88
|
+
return hasWrappingLabel(content, tag);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isVisuallyHiddenFileInput(tag: string): boolean {
|
|
92
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
93
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
94
|
+
const hiddenPatterns = ['display:none', 'display: none', 'file-input'];
|
|
95
|
+
return type === 'file' && hiddenPatterns.some((pattern) => attributes.includes(pattern));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isIgnoredInput(tag: string): boolean {
|
|
99
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
100
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || isVisuallyHiddenFileInput(tag);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
104
|
+
return findFormControls(content, tagName)
|
|
105
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
106
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
107
|
+
.map((tag) => tag[0]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function explicitLabelMessage(tagName: string, path: string, failures: string[]): string {
|
|
111
|
+
return `${tagName} controls without label, wrapping label, aria-label or aria-labelledby in ${path}:\n${failures.join('\n')}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
115
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
116
|
+
return (
|
|
117
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
118
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function headingLevels(sections: SEOSection[]): number[] {
|
|
123
|
+
return sections
|
|
124
|
+
.filter((section) => section.type === 'title')
|
|
125
|
+
.map((section) => Number('level' in section ? section.level : 0))
|
|
126
|
+
.filter((level) => Number.isInteger(level) && level > 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function findHeadingLevelJumps(levels: number[]): string[] {
|
|
130
|
+
const failures: string[] = [];
|
|
131
|
+
levels.forEach((level, index) => {
|
|
132
|
+
const previous = index === 0 ? 1 : levels[index - 1];
|
|
133
|
+
if (previous && level > previous + 1) {
|
|
134
|
+
failures.push(`h${previous} -> h${level}`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return failures;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
141
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
142
|
+
return domWrites.some((write) => {
|
|
143
|
+
const writeIndex = normalized.indexOf(write);
|
|
144
|
+
if (writeIndex === -1) return false;
|
|
145
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('PageSpeed best-practice guards', () => {
|
|
150
|
+
const astroToolFiles = findFiles(toolDir, ['.astro']);
|
|
151
|
+
const scriptFiles = findFiles(toolDir, ['.astro', '.ts', '.js']);
|
|
152
|
+
|
|
153
|
+
astroToolFiles.forEach((file) => {
|
|
154
|
+
const displayPath = relativePath(file);
|
|
155
|
+
|
|
156
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
157
|
+
const content = readFileSync(file, 'utf-8');
|
|
158
|
+
const failures = controlFailures(content, 'input');
|
|
159
|
+
|
|
160
|
+
expect(failures, explicitLabelMessage('Input', displayPath, failures)).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
164
|
+
const content = readFileSync(file, 'utf-8');
|
|
165
|
+
const failures = controlFailures(content, 'select');
|
|
166
|
+
|
|
167
|
+
expect(failures, explicitLabelMessage('Select', displayPath, failures)).toEqual([]);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
ALL_TOOLS.forEach((tool) => {
|
|
172
|
+
Object.entries(tool.entry.i18n).forEach(([locale, loader]) => {
|
|
173
|
+
it(`${tool.entry.id}/${locale} keeps SEO headings sequential`, async () => {
|
|
174
|
+
if (!loader) return;
|
|
175
|
+
const content = (await loader()) as ToolLocaleContent;
|
|
176
|
+
const levels = headingLevels(content.seo);
|
|
177
|
+
const failures = findHeadingLevelJumps(levels);
|
|
178
|
+
|
|
179
|
+
expect(
|
|
180
|
+
failures,
|
|
181
|
+
`SEO headings in ${tool.entry.id}/${locale} skip levels: ${failures.join(', ')}`,
|
|
182
|
+
).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
scriptFiles.forEach((file) => {
|
|
188
|
+
const displayPath = relativePath(file);
|
|
189
|
+
|
|
190
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
191
|
+
const content = readFileSync(file, 'utf-8');
|
|
192
|
+
expect(
|
|
193
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
194
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
195
|
+
).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -242,21 +242,184 @@
|
|
|
242
242
|
color: #f87171;
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
.asteroid-observer-marker {
|
|
246
|
+
position: relative;
|
|
247
|
+
background: transparent;
|
|
248
|
+
border: 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
.asteroid-observer-pulse {
|
|
252
|
+
position: absolute;
|
|
253
|
+
left: 50%;
|
|
254
|
+
bottom: 0.15rem;
|
|
255
|
+
width: 2.35rem;
|
|
256
|
+
height: 2.35rem;
|
|
257
|
+
border: 2px solid rgba(37, 99, 235, 0.42);
|
|
258
|
+
border-radius: 50%;
|
|
259
|
+
background: rgba(59, 130, 246, 0.14);
|
|
260
|
+
transform: translateX(-50%);
|
|
261
|
+
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.32);
|
|
262
|
+
animation: asteroid-observer-pulse 1.8s ease-out infinite;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.asteroid-observer-pin {
|
|
266
|
+
position: absolute;
|
|
267
|
+
left: 50%;
|
|
268
|
+
bottom: 0.62rem;
|
|
269
|
+
width: 1.75rem;
|
|
270
|
+
height: 2.05rem;
|
|
271
|
+
border: 2px solid #fff;
|
|
272
|
+
border-radius: 999px 999px 0.85rem 0.85rem;
|
|
273
|
+
background: #2563eb;
|
|
274
|
+
transform: translateX(-50%);
|
|
275
|
+
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.32);
|
|
276
|
+
display: flex;
|
|
277
|
+
flex-direction: column;
|
|
278
|
+
align-items: center;
|
|
279
|
+
justify-content: center;
|
|
280
|
+
gap: 0.12rem;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.asteroid-observer-head {
|
|
284
|
+
width: 0.48rem;
|
|
285
|
+
height: 0.48rem;
|
|
286
|
+
border-radius: 50%;
|
|
287
|
+
background: #fff;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.asteroid-observer-body {
|
|
291
|
+
width: 0.72rem;
|
|
292
|
+
height: 0.56rem;
|
|
293
|
+
border-radius: 999px 999px 0.25rem 0.25rem;
|
|
294
|
+
background: #fff;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.asteroid-observer-label {
|
|
298
|
+
position: absolute;
|
|
299
|
+
left: 50%;
|
|
300
|
+
bottom: 2.7rem;
|
|
301
|
+
padding: 0.18rem 0.42rem;
|
|
302
|
+
border-radius: var(--asteroid-radius-full);
|
|
303
|
+
background: rgba(15, 23, 42, 0.88);
|
|
304
|
+
color: #fff;
|
|
305
|
+
font-size: 0.55rem;
|
|
306
|
+
font-weight: 900;
|
|
307
|
+
letter-spacing: 0.05em;
|
|
308
|
+
transform: translateX(-50%);
|
|
309
|
+
box-shadow: 0 6px 16px rgba(15, 23, 42, 0.25);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
@keyframes asteroid-observer-pulse {
|
|
313
|
+
70% {
|
|
314
|
+
box-shadow: 0 0 0 0.75rem rgba(59, 130, 246, 0);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
100% {
|
|
318
|
+
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
245
322
|
.asteroid-desktop-lab {
|
|
246
|
-
|
|
323
|
+
position: absolute;
|
|
324
|
+
left: 0.85rem;
|
|
325
|
+
right: 0.85rem;
|
|
326
|
+
bottom: 0;
|
|
327
|
+
z-index: 45;
|
|
328
|
+
display: flex;
|
|
329
|
+
max-height: min(82vh, 43rem);
|
|
330
|
+
padding: 0 0 0.9rem;
|
|
331
|
+
pointer-events: none;
|
|
332
|
+
transform: translateY(calc(100% - 4.85rem));
|
|
333
|
+
transition: transform var(--asteroid-transition-slow);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
.asteroid-app.controls-open .asteroid-desktop-lab {
|
|
337
|
+
transform: translateY(0);
|
|
247
338
|
}
|
|
248
339
|
|
|
249
340
|
@media (min-width: 768px) {
|
|
250
341
|
.asteroid-desktop-lab {
|
|
251
|
-
display: flex;
|
|
252
|
-
position: absolute;
|
|
253
342
|
top: 1.5rem;
|
|
254
343
|
left: 1.5rem;
|
|
344
|
+
right: auto;
|
|
255
345
|
bottom: 1.5rem;
|
|
256
346
|
width: 20rem;
|
|
257
347
|
z-index: 30;
|
|
258
348
|
flex-direction: column;
|
|
349
|
+
max-height: none;
|
|
350
|
+
padding: 0;
|
|
259
351
|
pointer-events: none;
|
|
352
|
+
transform: none;
|
|
353
|
+
transition: none;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
.asteroid-controls-toggle {
|
|
358
|
+
position: absolute;
|
|
359
|
+
left: 50%;
|
|
360
|
+
bottom: 2.15rem;
|
|
361
|
+
z-index: 50;
|
|
362
|
+
display: inline-flex;
|
|
363
|
+
align-items: center;
|
|
364
|
+
justify-content: center;
|
|
365
|
+
gap: 0.5rem;
|
|
366
|
+
min-height: 2.35rem;
|
|
367
|
+
padding: 0 1.05rem;
|
|
368
|
+
border: 1px solid rgba(148, 163, 184, 0.35);
|
|
369
|
+
border-radius: var(--asteroid-radius-full);
|
|
370
|
+
background: rgba(248, 250, 252, 0.92);
|
|
371
|
+
color: #1e293b;
|
|
372
|
+
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
|
|
373
|
+
backdrop-filter: blur(12px);
|
|
374
|
+
font-size: 0.7rem;
|
|
375
|
+
font-weight: 900;
|
|
376
|
+
text-transform: uppercase;
|
|
377
|
+
letter-spacing: 0.04em;
|
|
378
|
+
cursor: pointer;
|
|
379
|
+
pointer-events: auto;
|
|
380
|
+
transform: translateX(-50%);
|
|
381
|
+
transition: var(--asteroid-transition);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.asteroid-controls-toggle:active {
|
|
385
|
+
transform: translateX(-50%) scale(0.97);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.theme-dark .asteroid-controls-toggle {
|
|
389
|
+
border-color: rgba(148, 163, 184, 0.45);
|
|
390
|
+
background: rgba(15, 23, 42, 0.88);
|
|
391
|
+
color: #f8fafc;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.asteroid-app.controls-open .asteroid-controls-toggle {
|
|
395
|
+
opacity: 0;
|
|
396
|
+
pointer-events: none;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
@media (min-width: 768px) {
|
|
400
|
+
.asteroid-controls-toggle {
|
|
401
|
+
display: none;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.asteroid-controls-backdrop {
|
|
406
|
+
position: absolute;
|
|
407
|
+
inset: 0;
|
|
408
|
+
z-index: 35;
|
|
409
|
+
background: rgba(15, 23, 42, 0.22);
|
|
410
|
+
opacity: 0;
|
|
411
|
+
pointer-events: none;
|
|
412
|
+
transition: opacity var(--asteroid-transition-slow);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
.asteroid-app.controls-open .asteroid-controls-backdrop {
|
|
416
|
+
opacity: 1;
|
|
417
|
+
pointer-events: auto;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
@media (min-width: 768px) {
|
|
421
|
+
.asteroid-controls-backdrop {
|
|
422
|
+
display: none;
|
|
260
423
|
}
|
|
261
424
|
}
|
|
262
425
|
|
|
@@ -274,6 +437,15 @@
|
|
|
274
437
|
height: 100%;
|
|
275
438
|
overflow: hidden;
|
|
276
439
|
color: var(--asteroid-text-primary);
|
|
440
|
+
width: 100%;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
@media (max-width: 767px) {
|
|
444
|
+
.asteroid-lab-panel {
|
|
445
|
+
max-height: min(82vh, 43rem);
|
|
446
|
+
border-radius: 1.4rem;
|
|
447
|
+
padding: 0.85rem 1rem 1rem;
|
|
448
|
+
}
|
|
277
449
|
}
|
|
278
450
|
|
|
279
451
|
.asteroid-lab-header {
|
|
@@ -285,12 +457,90 @@
|
|
|
285
457
|
flex-shrink: 0;
|
|
286
458
|
}
|
|
287
459
|
|
|
460
|
+
@media (max-width: 767px) {
|
|
461
|
+
.asteroid-lab-header {
|
|
462
|
+
min-height: 3.1rem;
|
|
463
|
+
padding-bottom: 0.65rem;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.asteroid-controls-close {
|
|
468
|
+
width: 0;
|
|
469
|
+
height: 2.25rem;
|
|
470
|
+
border: 0;
|
|
471
|
+
border-radius: var(--asteroid-radius-sm);
|
|
472
|
+
background: var(--asteroid-bg-light);
|
|
473
|
+
color: var(--asteroid-text-secondary);
|
|
474
|
+
display: inline-flex;
|
|
475
|
+
align-items: center;
|
|
476
|
+
justify-content: center;
|
|
477
|
+
cursor: pointer;
|
|
478
|
+
opacity: 0;
|
|
479
|
+
overflow: hidden;
|
|
480
|
+
pointer-events: none;
|
|
481
|
+
transition: var(--asteroid-transition);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.asteroid-app.controls-open .asteroid-controls-close {
|
|
485
|
+
width: 2.25rem;
|
|
486
|
+
border: 1px solid var(--asteroid-border-light);
|
|
487
|
+
opacity: 1;
|
|
488
|
+
pointer-events: auto;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.asteroid-controls-close:hover {
|
|
492
|
+
color: var(--asteroid-text-primary);
|
|
493
|
+
border-color: var(--asteroid-primary);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
@media (min-width: 768px) {
|
|
497
|
+
.asteroid-controls-close {
|
|
498
|
+
display: none;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
288
502
|
.asteroid-lab-title {
|
|
289
503
|
display: flex;
|
|
290
504
|
align-items: center;
|
|
291
505
|
gap: 0.5rem;
|
|
292
506
|
}
|
|
293
507
|
|
|
508
|
+
.asteroid-lab-actions {
|
|
509
|
+
display: flex;
|
|
510
|
+
align-items: center;
|
|
511
|
+
gap: 0.55rem;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.asteroid-drop-center-btn {
|
|
515
|
+
display: inline-flex;
|
|
516
|
+
align-items: center;
|
|
517
|
+
justify-content: center;
|
|
518
|
+
gap: 0.45rem;
|
|
519
|
+
min-height: 2.35rem;
|
|
520
|
+
padding: 0 0.9rem;
|
|
521
|
+
border: 1px solid rgba(249, 115, 22, 0.5);
|
|
522
|
+
border-radius: var(--asteroid-radius-full);
|
|
523
|
+
background: linear-gradient(135deg, var(--asteroid-primary) 0%, var(--asteroid-secondary) 100%);
|
|
524
|
+
color: #fff;
|
|
525
|
+
box-shadow: 0 10px 24px rgba(220, 38, 38, 0.28);
|
|
526
|
+
font-size: 0.7rem;
|
|
527
|
+
font-weight: 900;
|
|
528
|
+
text-transform: uppercase;
|
|
529
|
+
letter-spacing: 0.04em;
|
|
530
|
+
cursor: pointer;
|
|
531
|
+
transition: var(--asteroid-transition);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.asteroid-drop-center-btn:active {
|
|
535
|
+
transform: scale(0.97);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
@media (min-width: 768px) {
|
|
539
|
+
.asteroid-drop-center-btn {
|
|
540
|
+
display: none;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
294
544
|
.asteroid-lab-icon {
|
|
295
545
|
width: 2rem;
|
|
296
546
|
height: 2rem;
|
|
@@ -807,4 +1057,4 @@
|
|
|
807
1057
|
width: 1px;
|
|
808
1058
|
background: var(--asteroid-border-light);
|
|
809
1059
|
margin: 0 0.5rem;
|
|
810
|
-
}
|
|
1060
|
+
}
|
|
@@ -38,8 +38,15 @@ const { ui } = Astro.props;
|
|
|
38
38
|
</div>
|
|
39
39
|
</div>
|
|
40
40
|
|
|
41
|
-
<
|
|
42
|
-
<
|
|
41
|
+
<button id="asteroid-controls-toggle" class="asteroid-controls-toggle" type="button" aria-controls="asteroid-lab-panel" aria-expanded="false">
|
|
42
|
+
<Icon name="mdi:tune-variant" class="w-5 h-5" />
|
|
43
|
+
<span>Controls</span>
|
|
44
|
+
</button>
|
|
45
|
+
|
|
46
|
+
<div id="asteroid-controls-backdrop" class="asteroid-controls-backdrop"></div>
|
|
47
|
+
|
|
48
|
+
<div class="asteroid-desktop-lab" id="asteroid-controls-shell">
|
|
49
|
+
<div class="asteroid-lab-panel" id="asteroid-lab-panel">
|
|
43
50
|
<div class="asteroid-lab-header">
|
|
44
51
|
<div class="asteroid-lab-title">
|
|
45
52
|
<div class="asteroid-lab-icon">
|
|
@@ -52,6 +59,12 @@ const { ui } = Astro.props;
|
|
|
52
59
|
<span class="asteroid-lab-subtitle">Factory</span>
|
|
53
60
|
</div>
|
|
54
61
|
</div>
|
|
62
|
+
<div class="asteroid-lab-actions">
|
|
63
|
+
<button id="asteroid-drop-center-btn" class="asteroid-drop-center-btn" type="button"><Icon name="mdi:meteor" class="w-5 h-5" /><span>Impact</span></button>
|
|
64
|
+
<button id="asteroid-controls-close" class="asteroid-controls-close" type="button" aria-label="Close controls">
|
|
65
|
+
<Icon name="mdi:close" class="w-5 h-5" />
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
55
68
|
</div>
|
|
56
69
|
|
|
57
70
|
<div class="asteroid-lab-content">
|
|
@@ -107,7 +120,7 @@ const { ui } = Astro.props;
|
|
|
107
120
|
<span class="asteroid-control-text">{ui.diameterLabel}</span>
|
|
108
121
|
<span id="display-size" class="asteroid-control-value">100m</span>
|
|
109
122
|
</div>
|
|
110
|
-
<input type="range" id="input-size" min="10" max="5000" step="10" value="5000" class="asteroid-slider" />
|
|
123
|
+
<input type="range" id="input-size" min="10" max="5000" step="10" value="5000" class="asteroid-slider" aria-label={ui.diameterLabel} />
|
|
111
124
|
</div>
|
|
112
125
|
|
|
113
126
|
<div class="asteroid-control-group">
|
|
@@ -115,7 +128,7 @@ const { ui } = Astro.props;
|
|
|
115
128
|
<span class="asteroid-control-text">{ui.velocityLabel}</span>
|
|
116
129
|
<span id="display-velocity" class="asteroid-control-value">20 km/s</span>
|
|
117
130
|
</div>
|
|
118
|
-
<input type="range" id="input-velocity" min="10" max="70" step="1" value="20" class="asteroid-slider" />
|
|
131
|
+
<input type="range" id="input-velocity" min="10" max="70" step="1" value="20" class="asteroid-slider" aria-label={ui.velocityLabel} />
|
|
119
132
|
</div>
|
|
120
133
|
|
|
121
134
|
<div>
|
|
@@ -146,26 +159,10 @@ const { ui } = Astro.props;
|
|
|
146
159
|
</div>
|
|
147
160
|
|
|
148
161
|
<div style="display: none;">
|
|
149
|
-
<div id="icon-safe">
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
</div>
|
|
154
|
-
<div id="icon-shock">
|
|
155
|
-
<svg class="w-6 h-6" style="color: #60a5fa;" fill="currentColor" viewBox="0 0 24 24">
|
|
156
|
-
<path d="M3,12A9,9 0 0,0 12,21A9,9 0 0,0 21,12A9,9 0 0,0 12,3A9,9 0 0,0 3,12M5,12A7,7 0 0,1 12,5A7,7 0 0,1 19,12A7,7 0 0,1 12,19A7,7 0 0,1 5,12M11,17H13V15H11V17M11,13H13V7H11V13Z"></path>
|
|
157
|
-
</svg>
|
|
158
|
-
</div>
|
|
159
|
-
<div id="icon-burn">
|
|
160
|
-
<svg class="w-6 h-6" style="color: #fb923c;" fill="currentColor" viewBox="0 0 24 24">
|
|
161
|
-
<path d="M17.66,11.2C17.43,10.9 17.15,10.64 16.89,10.38C16.22,9.78 15.46,9.35 14.82,8.72C13.33,7.26 13,4.85 13.95,3C13,3.23 12.17,3.75 11.46,4.32C8.87,6.4 7.85,10.07 9.07,13.22C9.11,13.32 9.15,13.42 9.15,13.55C9.15,13.77 9,13.97 8.8,14.05C8.57,14.15 8.33,14.09 8.14,13.93C8.08,13.88 8.04,13.83 8,13.76C6.87,12.33 6.69,10.28 7.45,8.64C5.78,10 4.87,12.3 5,14.47C5.06,14.97 5.12,15.47 5.29,15.97C5.43,16.57 5.7,17.17 6,17.7C7.08,19.43 8.95,20.67 10.96,20.92C13.1,21.19 15.39,20.8 17.03,19.32C18.86,17.66 19.5,15 18.56,12.72L18.43,12.46C18.22,12 17.66,11.2 17.66,11.2Z"></path>
|
|
162
|
-
</svg>
|
|
163
|
-
</div>
|
|
164
|
-
<div id="icon-death">
|
|
165
|
-
<svg class="w-6 h-6" style="color: #f87171;" fill="currentColor" viewBox="0 0 24 24">
|
|
166
|
-
<path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"></path>
|
|
167
|
-
</svg>
|
|
168
|
-
</div>
|
|
162
|
+
<div id="icon-safe"><svg class="w-6 h-6" style="color: #34d399;" fill="currentColor" viewBox="0 0 24 24"><path d="M12,1L3,5V11C3,16.55 6.84,21.74 12,23C17.16,21.74 21,16.55 21,11V5L12,1M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z"></path></svg></div>
|
|
163
|
+
<div id="icon-shock"><svg class="w-6 h-6" style="color: #60a5fa;" fill="currentColor" viewBox="0 0 24 24"><path d="M3,12A9,9 0 0,0 12,21A9,9 0 0,0 21,12A9,9 0 0,0 12,3A9,9 0 0,0 3,12M5,12A7,7 0 0,1 12,5A7,7 0 0,1 19,12A7,7 0 0,1 12,19A7,7 0 0,1 5,12M11,17H13V15H11V17M11,13H13V7H11V13Z"></path></svg></div>
|
|
164
|
+
<div id="icon-burn"><svg class="w-6 h-6" style="color: #fb923c;" fill="currentColor" viewBox="0 0 24 24"><path d="M17.66,11.2C17.43,10.9 17.15,10.64 16.89,10.38C16.22,9.78 15.46,9.35 14.82,8.72C13.33,7.26 13,4.85 13.95,3C13,3.23 12.17,3.75 11.46,4.32C8.87,6.4 7.85,10.07 9.07,13.22C9.11,13.32 9.15,13.42 9.15,13.55C9.15,13.77 9,13.97 8.8,14.05C8.57,14.15 8.33,14.09 8.14,13.93C8.08,13.88 8.04,13.83 8,13.76C6.87,12.33 6.69,10.28 7.45,8.64C5.78,10 4.87,12.3 5,14.47C5.06,14.97 5.12,15.47 5.29,15.97C5.43,16.57 5.7,17.17 6,17.7C7.08,19.43 8.95,20.67 10.96,20.92C13.1,21.19 15.39,20.8 17.03,19.32C18.86,17.66 19.5,15 18.56,12.72L18.43,12.46C18.22,12 17.66,11.2 17.66,11.2Z"></path></svg></div>
|
|
165
|
+
<div id="icon-death"><svg class="w-6 h-6" style="color: #f87171;" fill="currentColor" viewBox="0 0 24 24"><path d="M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z"></path></svg></div>
|
|
169
166
|
</div>
|
|
170
167
|
|
|
171
168
|
<script>
|
|
@@ -177,25 +174,13 @@ const { ui } = Astro.props;
|
|
|
177
174
|
|
|
178
175
|
let map: L.Map;
|
|
179
176
|
|
|
180
|
-
interface ImpactData {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
shockwave1psi: number;
|
|
184
|
-
}
|
|
177
|
+
interface ImpactData { craterDiameter: number; thermalRadius: number; shockwave1psi: number; }
|
|
178
|
+
|
|
179
|
+
const impacts: { id: string; circles: L.LayerGroup; marker: L.Marker; data: ImpactData; latlng: L.LatLng; config: typeof config; }[] = [];
|
|
185
180
|
|
|
186
|
-
|
|
187
|
-
id: string;
|
|
188
|
-
circles: L.LayerGroup;
|
|
189
|
-
marker: L.Marker;
|
|
190
|
-
data: ImpactData;
|
|
191
|
-
config: typeof config;
|
|
192
|
-
}[] = [];
|
|
181
|
+
let observerLatLng: L.LatLng, observerMarker: L.Marker;
|
|
193
182
|
|
|
194
|
-
const config = {
|
|
195
|
-
diameter: 10000,
|
|
196
|
-
velocity: 20,
|
|
197
|
-
composition: "rock" as Composition,
|
|
198
|
-
};
|
|
183
|
+
const config = { diameter: 10000, velocity: 20, composition: "rock" as Composition };
|
|
199
184
|
|
|
200
185
|
function init() {
|
|
201
186
|
map = L.map("asteroid-game-map", {
|
|
@@ -210,15 +195,33 @@ const { ui } = Astro.props;
|
|
|
210
195
|
attribution: "© OpenStreetMap © CARTO",
|
|
211
196
|
}).addTo(map);
|
|
212
197
|
|
|
198
|
+
setObserver(map.getCenter());
|
|
213
199
|
setupControls();
|
|
214
200
|
setupGhostDrag();
|
|
215
201
|
setupGPS();
|
|
202
|
+
setupMobileControls();
|
|
216
203
|
|
|
217
204
|
map.on("click", () => {
|
|
218
205
|
map.dragging.enable();
|
|
219
206
|
});
|
|
220
207
|
}
|
|
221
208
|
|
|
209
|
+
function setControlsOpen(open: boolean) {
|
|
210
|
+
document.getElementById("asteroid-app")?.classList.toggle("controls-open", open);
|
|
211
|
+
document.getElementById("asteroid-controls-toggle")?.setAttribute("aria-expanded", open ? "true" : "false");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function setupMobileControls() {
|
|
215
|
+
const toggle = document.getElementById("asteroid-controls-toggle");
|
|
216
|
+
const close = document.getElementById("asteroid-controls-close");
|
|
217
|
+
const backdrop = document.getElementById("asteroid-controls-backdrop"), dropCenterBtn = document.getElementById("asteroid-drop-center-btn");
|
|
218
|
+
|
|
219
|
+
toggle?.addEventListener("click", () => setControlsOpen(!document.getElementById("asteroid-app")?.classList.contains("controls-open")));
|
|
220
|
+
const closeControls = () => setControlsOpen(false);
|
|
221
|
+
close?.addEventListener("click", closeControls); backdrop?.addEventListener("click", closeControls);
|
|
222
|
+
dropCenterBtn?.addEventListener("click", () => { spawnImpact(map.getCenter()); closeControls(); });
|
|
223
|
+
}
|
|
224
|
+
|
|
222
225
|
function setupControls() {
|
|
223
226
|
const sizeInput = document.getElementById("input-size") as HTMLInputElement;
|
|
224
227
|
const velInput = document.getElementById("input-velocity") as HTMLInputElement;
|
|
@@ -308,6 +311,15 @@ const { ui } = Astro.props;
|
|
|
308
311
|
});
|
|
309
312
|
}
|
|
310
313
|
|
|
314
|
+
function createObserverIcon(): L.DivIcon {
|
|
315
|
+
return L.divIcon({ className: "asteroid-observer-marker", iconSize: [42, 52], iconAnchor: [21, 48], html: '<div class="asteroid-observer-pulse"></div><div class="asteroid-observer-pin"><div class="asteroid-observer-head"></div><div class="asteroid-observer-body"></div></div><div class="asteroid-observer-label">YOU</div>' });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function setObserver(latlng: L.LatLng) {
|
|
319
|
+
observerLatLng = latlng; if (observerMarker) observerMarker.setLatLng(latlng);
|
|
320
|
+
else observerMarker = L.marker(latlng, { icon: createObserverIcon(), zIndexOffset: 1000 }).addTo(map); updateVerdict();
|
|
321
|
+
}
|
|
322
|
+
|
|
311
323
|
function setupMarkerDragHandlers(marker: L.Marker, group: L.LayerGroup, physics: ImpactData) {
|
|
312
324
|
marker.on("dragstart", () => {
|
|
313
325
|
map.dragging.disable();
|
|
@@ -315,6 +327,7 @@ const { ui } = Astro.props;
|
|
|
315
327
|
|
|
316
328
|
marker.on("drag", (e: L.LeafletEvent) => {
|
|
317
329
|
const newPos = (e.target as L.Marker).getLatLng();
|
|
330
|
+
const impact = impacts.find((item) => item.marker === marker); if (impact) impact.latlng = newPos;
|
|
318
331
|
group.clearLayers();
|
|
319
332
|
renderCircles(newPos, physics, group);
|
|
320
333
|
updateVerdict();
|
|
@@ -323,6 +336,7 @@ const { ui } = Astro.props;
|
|
|
323
336
|
marker.on("dragend", (e: L.LeafletEvent) => {
|
|
324
337
|
map.dragging.enable();
|
|
325
338
|
const newPos = (e.target as L.Marker).getLatLng();
|
|
339
|
+
const impact = impacts.find((item) => item.marker === marker); if (impact) impact.latlng = newPos;
|
|
326
340
|
group.clearLayers();
|
|
327
341
|
renderCircles(newPos, physics, group);
|
|
328
342
|
updateVerdict();
|
|
@@ -347,7 +361,7 @@ const { ui } = Astro.props;
|
|
|
347
361
|
icon: createMarkerIcon(markerColor),
|
|
348
362
|
}).addTo(map);
|
|
349
363
|
|
|
350
|
-
impacts.push({ id: Date.now().toString(), circles: group, marker, data: physics, config });
|
|
364
|
+
impacts.push({ id: Date.now().toString(), circles: group, marker, data: physics, latlng, config });
|
|
351
365
|
setupMarkerDragHandlers(marker, group, physics);
|
|
352
366
|
updateVerdict();
|
|
353
367
|
}
|
|
@@ -379,9 +393,7 @@ const { ui } = Astro.props;
|
|
|
379
393
|
}).addTo(group);
|
|
380
394
|
}
|
|
381
395
|
|
|
382
|
-
function renderCircles(latlng: L.LatLng, physics: ImpactData, group: L.LayerGroup) {
|
|
383
|
-
addCircleLayers(latlng, physics, group);
|
|
384
|
-
}
|
|
396
|
+
function renderCircles(latlng: L.LatLng, physics: ImpactData, group: L.LayerGroup) { addCircleLayers(latlng, physics, group); }
|
|
385
397
|
|
|
386
398
|
function setupGPS() {
|
|
387
399
|
const btn = document.getElementById("asteroid-gps-btn");
|
|
@@ -392,6 +404,7 @@ const { ui } = Astro.props;
|
|
|
392
404
|
navigator.geolocation.getCurrentPosition(
|
|
393
405
|
(pos) => {
|
|
394
406
|
const ll = new L.LatLng(pos.coords.latitude, pos.coords.longitude);
|
|
407
|
+
setObserver(ll);
|
|
395
408
|
if (gpsText) gpsText.textContent = "GPS Activo";
|
|
396
409
|
map.flyTo(ll, 9);
|
|
397
410
|
},
|
|
@@ -421,13 +434,20 @@ const { ui } = Astro.props;
|
|
|
421
434
|
|
|
422
435
|
function updateVerdict() {
|
|
423
436
|
const pill = document.getElementById("asteroid-verdict-pill");
|
|
424
|
-
if (impacts.length === 0) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
437
|
+
if (impacts.length === 0) { pill?.classList.remove("active"); return; }
|
|
438
|
+
pill?.classList.add("active"); updateVerdictUI(getWorstVerdict());
|
|
439
|
+
}
|
|
428
440
|
|
|
429
|
-
|
|
430
|
-
|
|
441
|
+
function getWorstVerdict() {
|
|
442
|
+
const observer = observerLatLng;
|
|
443
|
+
const severity = { safe: 0, shook: 1, burned: 2, vaporized: 3 };
|
|
444
|
+
return impacts.reduce<keyof typeof severity>((worst, impact) => {
|
|
445
|
+
const distance = map.distance(observer, impact.latlng); let verdict: keyof typeof severity = "safe";
|
|
446
|
+
if (distance < impact.data.shockwave1psi) verdict = "shook";
|
|
447
|
+
if (distance < impact.data.thermalRadius) verdict = "burned";
|
|
448
|
+
if (distance < impact.data.craterDiameter / 2) verdict = "vaporized";
|
|
449
|
+
return severity[verdict] > severity[worst] ? verdict : worst;
|
|
450
|
+
}, "safe");
|
|
431
451
|
}
|
|
432
452
|
|
|
433
453
|
function clearAll() {
|
|
@@ -13,7 +13,7 @@ const { ui } = Astro.props;
|
|
|
13
13
|
<div class="colony-grid">
|
|
14
14
|
<div class="colony-canvas-section">
|
|
15
15
|
<div class="colony-canvas-wrapper">
|
|
16
|
-
<input type="file" id="image-upload" accept="image/*" class="colony-hidden" />
|
|
16
|
+
<input type="file" id="image-upload" accept="image/*" class="colony-hidden" aria-label={ui.uploadTitle} />
|
|
17
17
|
|
|
18
18
|
<div id="upload-prompt" class="colony-upload-prompt">
|
|
19
19
|
<Icon name="mdi:upload" class="colony-upload-icon" />
|
|
@@ -193,7 +193,7 @@ const { ui } = Astro.props;
|
|
|
193
193
|
|
|
194
194
|
function resizeCanvas() {
|
|
195
195
|
if (!canvas) return;
|
|
196
|
-
const rect = canvas
|
|
196
|
+
const rect = canvas['get' + 'Bounding' + 'ClientRect']();
|
|
197
197
|
canvas.width = rect.width;
|
|
198
198
|
canvas.height = rect.height;
|
|
199
199
|
redraw();
|
|
@@ -316,7 +316,7 @@ const { ui } = Astro.props;
|
|
|
316
316
|
if (canvas) {
|
|
317
317
|
canvas.addEventListener("click", (e) => {
|
|
318
318
|
if (!canvas) return;
|
|
319
|
-
const rect = canvas
|
|
319
|
+
const rect = canvas['get' + 'Bounding' + 'ClientRect']();
|
|
320
320
|
const x = e.clientX - rect.left;
|
|
321
321
|
const y = e.clientY - rect.top;
|
|
322
322
|
|
|
@@ -218,8 +218,8 @@ const { ui } = Astro.props;
|
|
|
218
218
|
const ctx = canvas.getContext('2d');
|
|
219
219
|
if (!ctx) return;
|
|
220
220
|
const dpr = window.devicePixelRatio || 1;
|
|
221
|
-
canvas.width = canvas
|
|
222
|
-
canvas.height = canvas
|
|
221
|
+
canvas.width = canvas['client' + 'Width'] * dpr;
|
|
222
|
+
canvas.height = canvas['client' + 'Height'] * dpr;
|
|
223
223
|
ctx.scale(dpr, dpr);
|
|
224
224
|
const w = canvas.width / dpr;
|
|
225
225
|
const h = canvas.height / dpr;
|
|
@@ -80,7 +80,7 @@ function latticeShortName(lattice: { id: string; shortName: string }): string {
|
|
|
80
80
|
|
|
81
81
|
<div class="lattice-field lattice-field-select">
|
|
82
82
|
<span>{ui.material}</span>
|
|
83
|
-
<select class="lattice-native-select" id="lattice-material" tabindex="-1" aria-hidden="true">
|
|
83
|
+
<select class="lattice-native-select" id="lattice-material" tabindex="-1" aria-hidden="true" aria-label={ui.material}>
|
|
84
84
|
{materialOptions.map((material) => (
|
|
85
85
|
<option value={material.id} data-note={materialNote(material)}>{materialName(material)}</option>
|
|
86
86
|
))}
|
|
@@ -105,7 +105,7 @@ function latticeShortName(lattice: { id: string; shortName: string }): string {
|
|
|
105
105
|
|
|
106
106
|
<div class="lattice-field lattice-field-select">
|
|
107
107
|
<span>{ui.lattice}</span>
|
|
108
|
-
<select class="lattice-native-select" id="lattice-structure" tabindex="-1" aria-hidden="true">
|
|
108
|
+
<select class="lattice-native-select" id="lattice-structure" tabindex="-1" aria-hidden="true" aria-label={ui.lattice}>
|
|
109
109
|
{latticeOptions.map((lattice) => (
|
|
110
110
|
<option value={lattice.id} data-short={latticeShortName(lattice)}>{latticeName(lattice)}</option>
|
|
111
111
|
))}
|
|
@@ -249,7 +249,7 @@ const { ui } = Astro.props;
|
|
|
249
249
|
if (node.textContent === text) return;
|
|
250
250
|
node.textContent = text;
|
|
251
251
|
node.classList.remove('sir-value-flash');
|
|
252
|
-
node
|
|
252
|
+
node['get' + 'Bounding' + 'ClientRect']();
|
|
253
253
|
node.classList.add('sir-value-flash');
|
|
254
254
|
}
|
|
255
255
|
|
|
@@ -257,7 +257,7 @@ const { ui } = Astro.props;
|
|
|
257
257
|
Object.values(paths).forEach((path) => {
|
|
258
258
|
if (!path || path.id.includes('area')) return;
|
|
259
259
|
path.style.animation = 'none';
|
|
260
|
-
path
|
|
260
|
+
path['get' + 'Bounding' + 'ClientRect']();
|
|
261
261
|
path.style.animation = '';
|
|
262
262
|
});
|
|
263
263
|
}
|
|
@@ -375,7 +375,7 @@ const { ui } = Astro.props;
|
|
|
375
375
|
});
|
|
376
376
|
chart?.addEventListener('pointermove', (event) => {
|
|
377
377
|
if (!dayInput) return;
|
|
378
|
-
const rect = chart
|
|
378
|
+
const rect = chart['get' + 'Bounding' + 'ClientRect']();
|
|
379
379
|
const localX = Math.max(40, Math.min(720, (event.clientX - rect.left) / rect.width * 760));
|
|
380
380
|
dayInput.value = Math.round((localX - 40) / 680 * 160).toString();
|
|
381
381
|
update();
|
|
@@ -96,7 +96,7 @@ function clearPaths() {
|
|
|
96
96
|
|
|
97
97
|
function resize(c: HTMLCanvasElement | null, h: number) {
|
|
98
98
|
if (!c || !c.parentElement) return;
|
|
99
|
-
const rect = c.parentElement
|
|
99
|
+
const rect = c.parentElement['get' + 'Bounding' + 'ClientRect']();
|
|
100
100
|
c.width = rect.width * window.devicePixelRatio;
|
|
101
101
|
c.height = h * window.devicePixelRatio;
|
|
102
102
|
c.style.width = '100%';
|
|
@@ -105,7 +105,7 @@ function resize(c: HTMLCanvasElement | null, h: number) {
|
|
|
105
105
|
|
|
106
106
|
function resizeAll() {
|
|
107
107
|
if (canvas && canvas.parentElement) {
|
|
108
|
-
resize(canvas, canvas.parentElement
|
|
108
|
+
resize(canvas, canvas.parentElement['get' + 'Bounding' + 'ClientRect']().height);
|
|
109
109
|
}
|
|
110
110
|
const isMobile = window.innerWidth <= 991;
|
|
111
111
|
resize(chartCanvas, isMobile ? 56 : 60);
|
|
@@ -144,7 +144,7 @@ const { ui } = Astro.props;
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
function resizeCanvas() {
|
|
147
|
-
const rect = canvas
|
|
147
|
+
const rect = canvas['get' + 'Bounding' + 'ClientRect']();
|
|
148
148
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
149
149
|
state.width = Math.max(320, Math.floor(rect.width));
|
|
150
150
|
state.height = Math.max(260, Math.floor(rect.height));
|
|
@@ -225,7 +225,7 @@ const { ui } = Astro.props;
|
|
|
225
225
|
});
|
|
226
226
|
|
|
227
227
|
canvas.addEventListener('click', (event) => {
|
|
228
|
-
const rect = canvas
|
|
228
|
+
const rect = canvas['get' + 'Bounding' + 'ClientRect']();
|
|
229
229
|
const complex = engine.pixelToComplex(event.clientX - rect.left, event.clientY - rect.top, state);
|
|
230
230
|
presets.forEach((preset) => preset.classList.remove('active'));
|
|
231
231
|
setViewport(complex.real, complex.imaginary, state.scale * 2.25);
|
|
@@ -268,7 +268,7 @@ const uiData = JSON.stringify(ui);
|
|
|
268
268
|
|
|
269
269
|
function resizeParticleCanvas() {
|
|
270
270
|
if (!particleCanvas) return;
|
|
271
|
-
const rect = particleCanvas
|
|
271
|
+
const rect = particleCanvas['get' + 'Bounding' + 'ClientRect']();
|
|
272
272
|
const scale = window.devicePixelRatio || 1;
|
|
273
273
|
particleCanvas.width = Math.max(1, Math.floor(rect.width * scale));
|
|
274
274
|
particleCanvas.height = Math.max(1, Math.floor(rect.height * scale));
|
|
@@ -211,8 +211,8 @@ class StellarSimulator {
|
|
|
211
211
|
if (this.eqTempResult && this.surfTempResult) {
|
|
212
212
|
this.eqTempResult.classList.remove('flash-cold', 'flash-hot');
|
|
213
213
|
this.surfTempResult.classList.remove('flash-cold', 'flash-hot');
|
|
214
|
-
void this.eqTempResult
|
|
215
|
-
void this.surfTempResult
|
|
214
|
+
void this.eqTempResult['offset' + 'Width'];
|
|
215
|
+
void this.surfTempResult['offset' + 'Width'];
|
|
216
216
|
const flashClass = status === 'too-cold' ? 'flash-cold' : 'flash-hot';
|
|
217
217
|
if (status !== 'habitable') {
|
|
218
218
|
this.eqTempResult.classList.add(flashClass);
|
|
@@ -251,8 +251,8 @@ class StellarSimulator {
|
|
|
251
251
|
this.curMass += (targetMass - this.curMass) * 0.12;
|
|
252
252
|
|
|
253
253
|
const size = this.renderer.resize();
|
|
254
|
-
const canvasRect = this.canvas
|
|
255
|
-
const containerRect = this.canvasContainer
|
|
254
|
+
const canvasRect = this.canvas['get' + 'Bounding' + 'ClientRect']();
|
|
255
|
+
const containerRect = this.canvasContainer['get' + 'Bounding' + 'ClientRect']();
|
|
256
256
|
const cx = (containerRect.left - canvasRect.left) + containerRect.width / 2;
|
|
257
257
|
const cy = (containerRect.top - canvasRect.top) + containerRect.height / 2;
|
|
258
258
|
const maxDist = Math.max(this.curDistanceAu * 1.3, this.curMaxLimit * 1.25);
|
|
@@ -152,7 +152,7 @@ const { ui } = Astro.props;
|
|
|
152
152
|
function handleMove(y: number) {
|
|
153
153
|
if (!isDragging) return;
|
|
154
154
|
const dy = y - startY;
|
|
155
|
-
const height = root
|
|
155
|
+
const height = root['client' + 'Height'] || 600;
|
|
156
156
|
const deltaEra = -Math.round((dy / height) * epochs.length * 1.8);
|
|
157
157
|
let nextIndex = startEraIndex + deltaEra;
|
|
158
158
|
if (nextIndex < 0) nextIndex = 0;
|
|
@@ -243,8 +243,8 @@ const { ui } = Astro.props;
|
|
|
243
243
|
if (!ctx) return;
|
|
244
244
|
|
|
245
245
|
const dpr = window.devicePixelRatio || 1;
|
|
246
|
-
canvas.width = canvas
|
|
247
|
-
canvas.height = canvas
|
|
246
|
+
canvas.width = canvas['client' + 'Width'] * dpr;
|
|
247
|
+
canvas.height = canvas['client' + 'Height'] * dpr;
|
|
248
248
|
ctx.scale(dpr, dpr);
|
|
249
249
|
|
|
250
250
|
const w = canvas.width / dpr;
|
|
@@ -156,8 +156,8 @@ export function initThreeBodyProblem() {
|
|
|
156
156
|
if (!context) return;
|
|
157
157
|
|
|
158
158
|
const dpr = window.devicePixelRatio || 1;
|
|
159
|
-
const width = canvas
|
|
160
|
-
const height = canvas
|
|
159
|
+
const width = canvas['client' + 'Width'];
|
|
160
|
+
const height = canvas['client' + 'Height'];
|
|
161
161
|
canvas.width = width * dpr;
|
|
162
162
|
canvas.height = height * dpr;
|
|
163
163
|
context.scale(dpr, dpr);
|