eyeling 1.16.2 → 1.16.4

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.
Files changed (51) hide show
  1. package/HANDBOOK.md +4 -0
  2. package/README.md +0 -1
  3. package/examples/ershov-mixed-computation.n3 +106 -0
  4. package/examples/output/ershov-mixed-computation.n3 +15 -0
  5. package/eyeling.js +510 -263
  6. package/lib/cli.js +22 -12
  7. package/lib/engine.js +488 -251
  8. package/package.json +2 -3
  9. package/arctifacts/README.md +0 -59
  10. package/arctifacts/ackermann.html +0 -678
  11. package/arctifacts/auroracare.html +0 -1297
  12. package/arctifacts/bike-trip.html +0 -752
  13. package/arctifacts/binomial-theorem.html +0 -631
  14. package/arctifacts/bmi.html +0 -511
  15. package/arctifacts/building-performance.html +0 -750
  16. package/arctifacts/clinical-care.html +0 -726
  17. package/arctifacts/collatz.html +0 -403
  18. package/arctifacts/complex.html +0 -321
  19. package/arctifacts/control-system.html +0 -482
  20. package/arctifacts/delfour.html +0 -849
  21. package/arctifacts/earthquake-epicenter.html +0 -982
  22. package/arctifacts/eco-route.html +0 -662
  23. package/arctifacts/euclid-infinitude.html +0 -564
  24. package/arctifacts/euler-identity.html +0 -667
  25. package/arctifacts/exoplanet-transit.html +0 -1000
  26. package/arctifacts/faltings-theorem.html +0 -1046
  27. package/arctifacts/fibonacci.html +0 -299
  28. package/arctifacts/fundamental-theorem-arithmetic.html +0 -398
  29. package/arctifacts/godel-numbering.html +0 -743
  30. package/arctifacts/gps-bike.html +0 -759
  31. package/arctifacts/gps-clinical-bench.html +0 -792
  32. package/arctifacts/graph-french.html +0 -449
  33. package/arctifacts/grass-molecular.html +0 -592
  34. package/arctifacts/group-theory.html +0 -740
  35. package/arctifacts/health-info.html +0 -833
  36. package/arctifacts/kaprekar-constant.html +0 -576
  37. package/arctifacts/lee.html +0 -805
  38. package/arctifacts/linked-lists.html +0 -502
  39. package/arctifacts/lldm.html +0 -612
  40. package/arctifacts/matrix-multiplication.html +0 -502
  41. package/arctifacts/matrix.html +0 -651
  42. package/arctifacts/newton-raphson.html +0 -944
  43. package/arctifacts/peano-factorial.html +0 -456
  44. package/arctifacts/pi.html +0 -363
  45. package/arctifacts/polynomial.html +0 -646
  46. package/arctifacts/prime.html +0 -366
  47. package/arctifacts/pythagorean-theorem.html +0 -468
  48. package/arctifacts/rest-path.html +0 -469
  49. package/arctifacts/roots-of-unity.html +0 -363
  50. package/arctifacts/turing.html +0 -409
  51. package/arctifacts/wind-turbines.html +0 -726
@@ -1,982 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Earthquake Epicenter — ARC Science Explorer</title>
7
- <meta
8
- name="description"
9
- content="A mobile-first ARC-style science page that infers an earthquake epicenter from P- and S-wave arrivals, with Answer, Reason, and Check sections." />
10
- <style>
11
- :root {
12
- --bg: #f7fafc;
13
- --panel: #ffffff;
14
- --soft: #f3f8fc;
15
- --soft2: #eef6fb;
16
- --text: #102133;
17
- --muted: #5b7084;
18
- --border: #d8e5ef;
19
- --accent: #0ea5e9;
20
- --accent2: #2563eb;
21
- --gold: #f59e0b;
22
- --ok: #0f766e;
23
- --okbg: #ecfdf5;
24
- --warn: #b45309;
25
- --warnbg: #fff7ed;
26
- --bad: #b91c1c;
27
- --badbg: #fef2f2;
28
- --shadow: 0 14px 36px rgba(15, 23, 42, 0.08);
29
- --r: 22px;
30
- }
31
- * {
32
- box-sizing: border-box;
33
- }
34
- html,
35
- body {
36
- height: 100%;
37
- }
38
- body {
39
- margin: 0;
40
- font:
41
- 16px/1.55 Inter,
42
- ui-sans-serif,
43
- system-ui,
44
- -apple-system,
45
- Segoe UI,
46
- Roboto,
47
- sans-serif;
48
- color: var(--text);
49
- background:
50
- radial-gradient(900px 420px at 100% -10%, rgba(14, 165, 233, 0.09), transparent 60%),
51
- radial-gradient(850px 420px at -10% 0%, rgba(37, 99, 235, 0.06), transparent 60%), var(--bg);
52
- }
53
- .wrap {
54
- max-width: 920px;
55
- margin: 0 auto;
56
- padding: 16px 14px 80px;
57
- }
58
- .card {
59
- background: var(--panel);
60
- border: 1px solid var(--border);
61
- border-radius: var(--r);
62
- box-shadow: var(--shadow);
63
- }
64
- header.hero {
65
- padding: 24px 18px;
66
- margin-top: 8px;
67
- }
68
- .kicker {
69
- display: inline-flex;
70
- align-items: center;
71
- gap: 8px;
72
- padding: 7px 11px;
73
- border-radius: 999px;
74
- background: #e0f2fe;
75
- border: 1px solid #bae6fd;
76
- color: #0369a1;
77
- font-size: 12px;
78
- letter-spacing: 0.08em;
79
- text-transform: uppercase;
80
- font-weight: 800;
81
- }
82
- h1 {
83
- margin: 14px 0 10px;
84
- font-size: clamp(30px, 7vw, 50px);
85
- line-height: 1.02;
86
- letter-spacing: -0.04em;
87
- }
88
- h2 {
89
- margin: 0 0 8px;
90
- font-size: clamp(24px, 5.1vw, 34px);
91
- line-height: 1.08;
92
- letter-spacing: -0.03em;
93
- }
94
- h3 {
95
- margin: 0 0 8px;
96
- font-size: 20px;
97
- letter-spacing: -0.02em;
98
- }
99
- p {
100
- margin: 0 0 12px;
101
- }
102
- .lead {
103
- font-size: 18px;
104
- color: var(--muted);
105
- max-width: 40ch;
106
- }
107
- .formula {
108
- margin-top: 16px;
109
- padding: 14px 16px;
110
- border-radius: 18px;
111
- border: 1px solid #dbeafe;
112
- background: #f8fbff;
113
- color: #0c4a6e;
114
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
115
- overflow: auto;
116
- }
117
- .section {
118
- margin-top: 16px;
119
- padding: 18px;
120
- }
121
- .section-head {
122
- display: flex;
123
- align-items: center;
124
- gap: 10px;
125
- margin-bottom: 12px;
126
- }
127
- .badge {
128
- width: 34px;
129
- height: 34px;
130
- border-radius: 50%;
131
- display: inline-flex;
132
- align-items: center;
133
- justify-content: center;
134
- background: #e0f2fe;
135
- border: 1px solid #bae6fd;
136
- color: #0369a1;
137
- font-size: 14px;
138
- font-weight: 900;
139
- flex: 0 0 auto;
140
- }
141
- .sub {
142
- font-size: 14px;
143
- color: var(--muted);
144
- }
145
- .grid {
146
- display: grid;
147
- gap: 12px;
148
- }
149
- .controls {
150
- display: grid;
151
- gap: 10px;
152
- }
153
- .control {
154
- background: var(--soft);
155
- border: 1px solid var(--border);
156
- border-radius: 18px;
157
- padding: 12px 14px;
158
- }
159
- .control label {
160
- display: block;
161
- font-size: 12px;
162
- text-transform: uppercase;
163
- letter-spacing: 0.07em;
164
- color: var(--muted);
165
- font-weight: 800;
166
- margin-bottom: 8px;
167
- }
168
- .vnum {
169
- font-size: 22px;
170
- font-weight: 900;
171
- margin-bottom: 4px;
172
- }
173
- input[type='range'] {
174
- width: 100%;
175
- }
176
- select {
177
- width: 100%;
178
- border: 1px solid var(--border);
179
- border-radius: 12px;
180
- background: white;
181
- padding: 10px 12px;
182
- font: inherit;
183
- }
184
- .actions {
185
- display: flex;
186
- gap: 10px;
187
- flex-wrap: wrap;
188
- margin-top: 16px;
189
- }
190
- button {
191
- appearance: none;
192
- border: none;
193
- cursor: pointer;
194
- border-radius: 14px;
195
- padding: 12px 14px;
196
- font: inherit;
197
- font-weight: 800;
198
- background: var(--accent);
199
- color: white;
200
- box-shadow: 0 10px 20px rgba(14, 165, 233, 0.18);
201
- }
202
- button.secondary {
203
- background: white;
204
- color: var(--accent);
205
- border: 1px solid #cceaf9;
206
- box-shadow: none;
207
- }
208
- .metrics {
209
- display: grid;
210
- grid-template-columns: repeat(2, minmax(0, 1fr));
211
- gap: 10px;
212
- margin-top: 12px;
213
- }
214
- .metric {
215
- background: var(--soft);
216
- border: 1px solid var(--border);
217
- border-radius: 18px;
218
- padding: 12px 14px;
219
- min-height: 92px;
220
- }
221
- .metric .k {
222
- font-size: 12px;
223
- color: var(--muted);
224
- text-transform: uppercase;
225
- letter-spacing: 0.07em;
226
- }
227
- .metric .v {
228
- font-size: 22px;
229
- font-weight: 900;
230
- margin-top: 4px;
231
- word-break: break-word;
232
- }
233
- .mini {
234
- font-size: 13px;
235
- color: var(--muted);
236
- }
237
- .pill-row {
238
- display: flex;
239
- flex-wrap: wrap;
240
- gap: 8px;
241
- margin-top: 10px;
242
- }
243
- .pill {
244
- background: #eff6ff;
245
- border: 1px solid #dbeafe;
246
- color: #1d4ed8;
247
- padding: 7px 10px;
248
- border-radius: 999px;
249
- font-size: 13px;
250
- }
251
- .block {
252
- background: #fcfdff;
253
- border: 1px solid var(--border);
254
- border-radius: 18px;
255
- padding: 14px;
256
- }
257
- .notice {
258
- background: var(--warnbg);
259
- border: 1px solid #fed7aa;
260
- color: var(--warn);
261
- border-radius: 18px;
262
- padding: 12px 14px;
263
- margin-top: 14px;
264
- }
265
- .code {
266
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
267
- word-break: break-word;
268
- }
269
- canvas {
270
- display: block;
271
- width: 100%;
272
- border: 1px solid var(--border);
273
- border-radius: 18px;
274
- background: linear-gradient(180deg, #ffffff, #f8fbff);
275
- }
276
- .legend {
277
- display: flex;
278
- gap: 12px;
279
- flex-wrap: wrap;
280
- margin-top: 8px;
281
- color: var(--muted);
282
- font-size: 13px;
283
- }
284
- .legend span::before {
285
- content: '';
286
- display: inline-block;
287
- width: 10px;
288
- height: 10px;
289
- border-radius: 50%;
290
- margin-right: 6px;
291
- vertical-align: middle;
292
- }
293
- .legend .p::before {
294
- background: #0ea5e9;
295
- }
296
- .legend .s::before {
297
- background: #f59e0b;
298
- }
299
- .legend .fit::before {
300
- background: #2563eb;
301
- }
302
- .legend .event::before {
303
- background: #ef4444;
304
- }
305
- .status {
306
- display: inline-flex;
307
- align-items: center;
308
- gap: 8px;
309
- padding: 6px 10px;
310
- border-radius: 999px;
311
- font-size: 12px;
312
- font-weight: 900;
313
- }
314
- .pass {
315
- background: var(--okbg);
316
- color: var(--ok);
317
- border: 1px solid #bbf7d0;
318
- }
319
- .fail {
320
- background: var(--badbg);
321
- color: var(--bad);
322
- border: 1px solid #fecaca;
323
- }
324
- .table-wrap {
325
- overflow: auto;
326
- margin-top: 8px;
327
- }
328
- table {
329
- width: 100%;
330
- border-collapse: collapse;
331
- }
332
- th,
333
- td {
334
- text-align: left;
335
- vertical-align: top;
336
- padding: 9px 8px;
337
- border-bottom: 1px dashed var(--border);
338
- }
339
- th {
340
- font-size: 12px;
341
- text-transform: uppercase;
342
- letter-spacing: 0.07em;
343
- color: var(--muted);
344
- }
345
- .refs {
346
- display: flex;
347
- gap: 10px;
348
- flex-wrap: wrap;
349
- margin-top: 10px;
350
- }
351
- .refs a {
352
- text-decoration: none;
353
- color: #0369a1;
354
- background: #f8fbff;
355
- border: 1px solid #dbeafe;
356
- padding: 8px 10px;
357
- border-radius: 999px;
358
- font-size: 14px;
359
- }
360
- @media (min-width: 760px) {
361
- .controls {
362
- grid-template-columns: repeat(2, minmax(0, 1fr));
363
- }
364
- .metrics {
365
- grid-template-columns: repeat(4, minmax(0, 1fr));
366
- }
367
- }
368
- </style>
369
- </head>
370
- <body>
371
- <div class="wrap">
372
- <header class="card hero">
373
- <span class="kicker">Science · answer • reason • check</span>
374
- <h1>Earthquake Epicenter</h1>
375
- <p class="lead">
376
- Infer the hidden earthquake location from P- and S-wave arrival times at several stations, then verify the
377
- result by recomputing travel distances and timing consistency.
378
- </p>
379
- <div class="formula">
380
- Core idea:<br />
381
- S−P time grows with distance because P waves travel faster than S waves.<br />
382
- distance = (S−P) / (1/v<sub>S</sub> − 1/v<sub>P</sub>)<br />
383
- with three or more stations, distance circles can localize the epicenter.
384
- </div>
385
- <div class="actions">
386
- <button onclick="document.getElementById('answer').scrollIntoView({ behavior: 'smooth' })">
387
- Start exploring
388
- </button>
389
- <button class="secondary" onclick="runAll()">Recompute</button>
390
- </div>
391
- </header>
392
-
393
- <section class="card section" id="answer">
394
- <div class="section-head">
395
- <span class="badge">1</span>
396
- <div>
397
- <h2>Answer</h2>
398
- <div class="sub">The inferred earthquake for the current station data</div>
399
- </div>
400
- </div>
401
-
402
- <div class="controls">
403
- <div class="control">
404
- <label for="preset">Preset scenario</label>
405
- <select id="preset">
406
- <option value="coastal">Offshore event</option>
407
- <option value="inland">Inland event</option>
408
- <option value="northern">Northern event</option>
409
- <option value="southern">Southern event</option>
410
- <option value="custom">Custom values</option>
411
- </select>
412
- </div>
413
- <div class="control">
414
- <label for="noiseToggle">Timing realism</label>
415
- <select id="noiseToggle">
416
- <option value="off">Clean station picks</option>
417
- <option value="on">Add mild timing noise</option>
418
- </select>
419
- </div>
420
- <div class="control">
421
- <label for="vp">P-wave speed (km/s)</label>
422
- <div class="vnum" id="vpValue">6.0</div>
423
- <input id="vp" type="range" min="4.5" max="8.0" step="0.1" value="6.0" />
424
- </div>
425
- <div class="control">
426
- <label for="vs">S-wave speed (km/s)</label>
427
- <div class="vnum" id="vsValue">3.5</div>
428
- <input id="vs" type="range" min="2.5" max="5.0" step="0.1" value="3.5" />
429
- </div>
430
- <div class="control">
431
- <label for="originTime">Hidden origin time after recording starts (s)</label>
432
- <div class="vnum" id="originTimeValue">8.0</div>
433
- <input id="originTime" type="range" min="2.0" max="20.0" step="0.1" value="8.0" />
434
- </div>
435
- </div>
436
-
437
- <div class="metrics">
438
- <div class="metric">
439
- <div class="k">Estimated epicenter</div>
440
- <div class="v" id="ansXY">—</div>
441
- <div class="mini" id="ansCity">map coordinates in km</div>
442
- </div>
443
- <div class="metric">
444
- <div class="k">Estimated origin time</div>
445
- <div class="v" id="ansT0">—</div>
446
- <div class="mini">seconds after record start</div>
447
- </div>
448
- <div class="metric">
449
- <div class="k">Typical station distance</div>
450
- <div class="v" id="ansDist">—</div>
451
- <div class="mini">mean estimated radius</div>
452
- </div>
453
- <div class="metric">
454
- <div class="k">Fit residual</div>
455
- <div class="v" id="ansResidual">—</div>
456
- <div class="mini">root-mean-square circle mismatch</div>
457
- </div>
458
- </div>
459
-
460
- <div class="pill-row">
461
- <span class="pill" id="classPill">location quality —</span>
462
- <span class="pill" id="timingPill">timing spread —</span>
463
- <span class="pill" id="geometryPill">station geometry —</span>
464
- </div>
465
-
466
- <div class="notice" id="answerNotice">
467
- The answer is based on four stations, each contributing a distance estimate from its S−P gap.
468
- </div>
469
- </section>
470
-
471
- <section class="card section" id="reason">
472
- <div class="section-head">
473
- <span class="badge">2</span>
474
- <div>
475
- <h2>Reason</h2>
476
- <div class="sub">How the page turns arrival times into an epicenter</div>
477
- </div>
478
- </div>
479
-
480
- <div class="grid">
481
- <div class="block">
482
- <h3>Timing → distance</h3>
483
- <p>
484
- If the earthquake is at distance <span class="code">d</span>, then the P and S arrivals occur at
485
- <span class="code">t<sub>P</sub> = t<sub>0</sub> + d/v<sub>P</sub></span> and
486
- <span class="code">t<sub>S</sub> = t<sub>0</sub> + d/v<sub>S</sub></span
487
- >. Subtracting removes the unknown origin time.
488
- </p>
489
- <div class="formula">d = (t<sub>S</sub> − t<sub>P</sub>) / (1/v<sub>S</sub> − 1/v<sub>P</sub>)</div>
490
- <div class="mini" id="reasonDistance">—</div>
491
- </div>
492
-
493
- <div class="block">
494
- <h3>Several stations → one location</h3>
495
- <p>
496
- Each station defines a circle of possible epicenters. The earthquake should lie near the intersection of
497
- those circles. With noise, the circles usually do not meet perfectly, so the page finds the best fit.
498
- </p>
499
- <canvas id="mapCanvas"></canvas>
500
- <div class="legend">
501
- <span class="fit">distance circles and best-fit point</span
502
- ><span class="event">hidden event used to synthesize the data</span>
503
- </div>
504
- </div>
505
-
506
- <div class="block">
507
- <h3>Seismogram-style station traces</h3>
508
- <p class="mini">
509
- These traces are synthetic. They mark the observed P and S arrivals that the solver uses.
510
- </p>
511
- <canvas id="seismoCanvas"></canvas>
512
- <div class="legend"><span class="p">P arrival</span><span class="s">S arrival</span></div>
513
- </div>
514
-
515
- <div class="block">
516
- <h3>Origin time from P arrivals</h3>
517
- <p>
518
- Once the epicenter is estimated, each station gives an origin-time estimate
519
- <span class="code">t<sub>0</sub> ≈ t<sub>P</sub> − d/v<sub>P</sub></span
520
- >. A consistent solution should make those agree closely.
521
- </p>
522
- <div class="mini" id="reasonOrigin">—</div>
523
- </div>
524
- </div>
525
- </section>
526
-
527
- <section class="card section" id="check">
528
- <div class="section-head">
529
- <span class="badge">3</span>
530
- <div>
531
- <h2>Check</h2>
532
- <div class="sub">Independent consistency tests on the inferred event</div>
533
- </div>
534
- </div>
535
-
536
- <div class="metrics">
537
- <div class="metric">
538
- <div class="k">Distance formula</div>
539
- <div class="v"><span id="checkDistBadge" class="status">—</span></div>
540
- <div class="mini" id="checkDistText">—</div>
541
- </div>
542
- <div class="metric">
543
- <div class="k">Origin-time agreement</div>
544
- <div class="v"><span id="checkTimeBadge" class="status">—</span></div>
545
- <div class="mini" id="checkTimeText">—</div>
546
- </div>
547
- <div class="metric">
548
- <div class="k">Travel-time replay</div>
549
- <div class="v"><span id="checkReplayBadge" class="status">—</span></div>
550
- <div class="mini" id="checkReplayText">—</div>
551
- </div>
552
- <div class="metric">
553
- <div class="k">Recovery against hidden truth</div>
554
- <div class="v"><span id="checkTruthBadge" class="status">—</span></div>
555
- <div class="mini" id="checkTruthText">—</div>
556
- </div>
557
- </div>
558
-
559
- <div class="block" style="margin-top: 12px">
560
- <h3>Audit table</h3>
561
- <div class="table-wrap">
562
- <table>
563
- <thead>
564
- <tr>
565
- <th>Station</th>
566
- <th>Observed P</th>
567
- <th>Observed S</th>
568
- <th>Distance from S−P</th>
569
- <th>Distance from inferred epicenter</th>
570
- <th>Origin time from station</th>
571
- </tr>
572
- </thead>
573
- <tbody id="auditTable"></tbody>
574
- </table>
575
- </div>
576
- </div>
577
-
578
- <div class="notice">
579
- The check layer uses the observed arrivals, recomputed radii, and independently recovered origin times. It
580
- also compares the inferred epicenter with the hidden event that generated the synthetic station data.
581
- </div>
582
-
583
- <div class="refs">
584
- <a href="https://www.usgs.gov/programs/earthquake-hazards/seismographs-keeping-track-earthquakes"
585
- >USGS on P and S waves</a
586
- >
587
- <a href="https://www.iris.edu/hq/inclass/fact-sheet/how_are_earthquakes_located"
588
- >IRIS on locating earthquakes</a
589
- >
590
- <a href="https://www.usgs.gov/programs/earthquake-hazards/earthquake-travel-times">USGS travel times</a>
591
- </div>
592
- </section>
593
- </div>
594
-
595
- <script>
596
- 'use strict';
597
-
598
- const stations = [
599
- { name: 'ALFA', x: 70, y: 90 },
600
- { name: 'BRAV', x: 430, y: 110 },
601
- { name: 'CHAR', x: 130, y: 365 },
602
- { name: 'DELT', x: 395, y: 330 },
603
- ];
604
-
605
- const presets = {
606
- coastal: { eventX: 150, eventY: 180, originTime: 8.0 },
607
- inland: { eventX: 270, eventY: 225, originTime: 9.5 },
608
- northern: { eventX: 250, eventY: 110, originTime: 7.2 },
609
- southern: { eventX: 300, eventY: 300, originTime: 10.0 },
610
- };
611
-
612
- function fmt(x, d = 2) {
613
- return Number(x).toFixed(d);
614
- }
615
- function clamp(x, a, b) {
616
- return Math.max(a, Math.min(b, x));
617
- }
618
- function dist(a, b) {
619
- return Math.hypot(a.x - b.x, a.y - b.y);
620
- }
621
- function mean(arr) {
622
- return arr.reduce((s, x) => s + x, 0) / arr.length;
623
- }
624
- function rms(arr) {
625
- return Math.sqrt(mean(arr.map((x) => x * x)));
626
- }
627
- function setStatus(id, ok) {
628
- const el = document.getElementById(id);
629
- el.textContent = ok ? 'PASS' : 'FAIL';
630
- el.className = 'status ' + (ok ? 'pass' : 'fail');
631
- }
632
- function syncLabels() {
633
- document.getElementById('vpValue').textContent = fmt(document.getElementById('vp').value, 1);
634
- document.getElementById('vsValue').textContent = fmt(document.getElementById('vs').value, 1);
635
- document.getElementById('originTimeValue').textContent = fmt(document.getElementById('originTime').value, 1);
636
- }
637
- function applyPreset(name) {
638
- const p = presets[name];
639
- if (!p) return;
640
- document.getElementById('originTime').value = p.originTime;
641
- document.getElementById('preset').value = name;
642
- syncLabels();
643
- }
644
- function currentInputs() {
645
- return {
646
- vp: Number(document.getElementById('vp').value),
647
- vs: Number(document.getElementById('vs').value),
648
- originTime: Number(document.getElementById('originTime').value),
649
- noise: document.getElementById('noiseToggle').value === 'on',
650
- preset: document.getElementById('preset').value,
651
- };
652
- }
653
- function hiddenEvent(inp) {
654
- const p = presets[inp.preset] || presets.coastal;
655
- return { x: p.eventX, y: p.eventY, t0: inp.originTime };
656
- }
657
- function stationObservations(inp) {
658
- const evt = hiddenEvent(inp);
659
- return stations.map((st, i) => {
660
- const d = dist(st, evt);
661
- const tp = evt.t0 + d / inp.vp;
662
- const ts = evt.t0 + d / inp.vs;
663
- const np = inp.noise ? 0.12 * Math.sin(1.7 * i + d * 0.013) : 0;
664
- const ns = inp.noise ? 0.15 * Math.cos(1.3 * i + d * 0.015) : 0;
665
- return {
666
- ...st,
667
- trueDistance: d,
668
- tP: tp + np,
669
- tS: ts + ns,
670
- };
671
- });
672
- }
673
- function distanceFromSP(obs, vp, vs) {
674
- const sp = obs.tS - obs.tP;
675
- return sp / (1 / vs - 1 / vp);
676
- }
677
-
678
- function fitEpicenter(obs, vp, vs) {
679
- const radii = obs.map((o) => distanceFromSP(o, vp, vs));
680
- let best = { x: 0, y: 0, sse: Infinity };
681
- for (let y = 0; y <= 420; y += 2) {
682
- for (let x = 0; x <= 500; x += 2) {
683
- let sse = 0;
684
- for (let i = 0; i < obs.length; i++) {
685
- const dd = Math.hypot(x - obs[i].x, y - obs[i].y);
686
- const r = radii[i];
687
- const e = dd - r;
688
- sse += e * e;
689
- }
690
- if (sse < best.sse) best = { x, y, sse };
691
- }
692
- }
693
- for (let step of [1, 0.5]) {
694
- let improved = true;
695
- while (improved) {
696
- improved = false;
697
- for (let dy of [-step, 0, step]) {
698
- for (let dx of [-step, 0, step]) {
699
- const x = clamp(best.x + dx, 0, 500);
700
- const y = clamp(best.y + dy, 0, 420);
701
- let sse = 0;
702
- for (let i = 0; i < obs.length; i++) {
703
- const dd = Math.hypot(x - obs[i].x, y - obs[i].y);
704
- const r = radii[i];
705
- const e = dd - r;
706
- sse += e * e;
707
- }
708
- if (sse + 1e-12 < best.sse) {
709
- best = { x, y, sse };
710
- improved = true;
711
- }
712
- }
713
- }
714
- }
715
- }
716
- const perStation = obs.map((o, i) => {
717
- const dfit = Math.hypot(best.x - o.x, best.y - o.y);
718
- const r = radii[i];
719
- return { radius: r, fitDistance: dfit, mismatch: dfit - r };
720
- });
721
- const t0s = obs.map((o, i) => o.tP - perStation[i].fitDistance / vp);
722
- const t0 = mean(t0s);
723
- return {
724
- x: best.x,
725
- y: best.y,
726
- radii,
727
- perStation,
728
- t0,
729
- t0s,
730
- residual: rms(perStation.map((p) => p.mismatch)),
731
- };
732
- }
733
-
734
- function stationGeometryQuality() {
735
- const pts = stations;
736
- const area =
737
- Math.abs(
738
- pts[0].x * (pts[1].y - pts[2].y) + pts[1].x * (pts[2].y - pts[0].y) + pts[2].x * (pts[0].y - pts[1].y),
739
- ) / 2;
740
- return area > 20000 ? 'broad station spread' : 'narrower station spread';
741
- }
742
-
743
- function prepareCanvas(id, cssHeight) {
744
- const canvas = document.getElementById(id);
745
- const dpr = window.devicePixelRatio || 1;
746
- const w = Math.max(320, Math.floor(canvas.clientWidth || canvas.parentElement.clientWidth || 600));
747
- const h = cssHeight;
748
- canvas.width = Math.floor(w * dpr);
749
- canvas.height = Math.floor(h * dpr);
750
- canvas.style.height = h + 'px';
751
- const ctx = canvas.getContext('2d');
752
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
753
- return { canvas, ctx, w, h };
754
- }
755
- function clearCanvas(ctx, w, h) {
756
- const g = ctx.createLinearGradient(0, 0, 0, h);
757
- g.addColorStop(0, '#ffffff');
758
- g.addColorStop(1, '#f8fbff');
759
- ctx.fillStyle = g;
760
- ctx.fillRect(0, 0, w, h);
761
- }
762
- function line(ctx, x1, y1, x2, y2, color, width = 1) {
763
- ctx.beginPath();
764
- ctx.moveTo(x1, y1);
765
- ctx.lineTo(x2, y2);
766
- ctx.strokeStyle = color;
767
- ctx.lineWidth = width;
768
- ctx.stroke();
769
- }
770
- function circle(ctx, x, y, r, stroke, fill, width = 1) {
771
- ctx.beginPath();
772
- ctx.arc(x, y, r, 0, Math.PI * 2);
773
- if (fill) {
774
- ctx.fillStyle = fill;
775
- ctx.fill();
776
- }
777
- if (stroke) {
778
- ctx.strokeStyle = stroke;
779
- ctx.lineWidth = width;
780
- ctx.stroke();
781
- }
782
- }
783
- function text(ctx, s, x, y, color = '#102133', font = '12px system-ui', align = 'left') {
784
- ctx.fillStyle = color;
785
- ctx.font = font;
786
- ctx.textAlign = align;
787
- ctx.fillText(s, x, y);
788
- }
789
-
790
- function drawMap(obs, fit, truth) {
791
- const { ctx, w, h } = prepareCanvas('mapCanvas', 320);
792
- clearCanvas(ctx, w, h);
793
- const pad = 18;
794
- function X(x) {
795
- return pad + (x * (w - 2 * pad)) / 500;
796
- }
797
- function Y(y) {
798
- return pad + (y * (h - 2 * pad)) / 420;
799
- }
800
-
801
- for (let gx = 0; gx <= 500; gx += 100) {
802
- line(ctx, X(gx), Y(0), X(gx), Y(420), '#e4edf4', 1);
803
- }
804
- for (let gy = 0; gy <= 420; gy += 84) {
805
- line(ctx, X(0), Y(gy), X(500), Y(gy), '#e4edf4', 1);
806
- }
807
-
808
- obs.forEach((o, i) => {
809
- circle(ctx, X(o.x), Y(o.y), (fit.radii[i] * (w - 2 * pad)) / 500, 'rgba(37,99,235,.22)', null, 1.5);
810
- circle(ctx, X(o.x), Y(o.y), 5, '#0ea5e9', '#0ea5e9', 1);
811
- text(ctx, o.name, X(o.x) + 8, Y(o.y) - 8, '#0c4a6e', '12px system-ui');
812
- });
813
-
814
- circle(ctx, X(fit.x), Y(fit.y), 6, '#2563eb', '#2563eb', 1);
815
- line(ctx, X(fit.x) - 8, Y(fit.y), X(fit.x) + 8, Y(fit.y), '#2563eb', 2);
816
- line(ctx, X(fit.x), Y(fit.y) - 8, X(fit.x), Y(fit.y) + 8, '#2563eb', 2);
817
- circle(ctx, X(truth.x), Y(truth.y), 5, '#ef4444', '#ef4444', 1);
818
-
819
- text(ctx, 'best-fit epicenter', X(fit.x) + 10, Y(fit.y) + 14, '#1d4ed8', '12px system-ui');
820
- text(
821
- ctx,
822
- 'hidden source used to make the station picks',
823
- X(truth.x) + 10,
824
- Y(truth.y) - 10,
825
- '#b91c1c',
826
- '12px system-ui',
827
- );
828
- }
829
- function drawSeismograms(obs) {
830
- const { ctx, w, h } = prepareCanvas('seismoCanvas', 280);
831
- clearCanvas(ctx, w, h);
832
- const pad = { l: 44, r: 14, t: 18, b: 22 };
833
- const maxT = Math.max(...obs.map((o) => o.tS)) + 4;
834
- function X(t) {
835
- return pad.l + (t * (w - pad.l - pad.r)) / maxT;
836
- }
837
-
838
- line(ctx, pad.l, h - pad.b, w - pad.r, h - pad.b, '#d7e3ec', 1);
839
- text(ctx, 'seconds after record start', w / 2, h - 6, '#5b7084', '12px system-ui', 'center');
840
-
841
- obs.forEach((o, idx) => {
842
- const y = pad.t + idx * ((h - pad.t - pad.b) / obs.length) + 22;
843
- text(ctx, o.name, 8, y + 4, '#5b7084', '12px system-ui');
844
- line(ctx, pad.l, y, w - pad.r, y, '#e7eef4', 1);
845
-
846
- const N = 220;
847
- ctx.beginPath();
848
- for (let i = 0; i < N; i++) {
849
- const t = (maxT * i) / (N - 1);
850
- const pulseP = Math.exp(-Math.pow((t - o.tP) / 0.45, 2)) * Math.sin((t - o.tP) * 14);
851
- const pulseS = 1.35 * Math.exp(-Math.pow((t - o.tS) / 0.85, 2)) * Math.sin((t - o.tS) * 9);
852
- const noise = 0.06 * Math.sin(i * 0.4 + idx);
853
- const amp = pulseP + pulseS + noise;
854
- const xx = X(t),
855
- yy = y - amp * 10;
856
- if (i === 0) ctx.moveTo(xx, yy);
857
- else ctx.lineTo(xx, yy);
858
- }
859
- ctx.strokeStyle = '#94a3b8';
860
- ctx.lineWidth = 1.4;
861
- ctx.stroke();
862
-
863
- line(ctx, X(o.tP), y - 18, X(o.tP), y + 18, '#0ea5e9', 1.8);
864
- line(ctx, X(o.tS), y - 18, X(o.tS), y + 18, '#f59e0b', 1.8);
865
- });
866
- }
867
-
868
- function renderAnswer(inp, fit, obs, truth) {
869
- const meanDist = mean(fit.radii);
870
- document.getElementById('ansXY').textContent = `(${fmt(fit.x, 1)}, ${fmt(fit.y, 1)}) km`;
871
- document.getElementById('ansT0').textContent = `${fmt(fit.t0, 2)} s`;
872
- document.getElementById('ansDist').textContent = `${fmt(meanDist, 1)} km`;
873
- document.getElementById('ansResidual').textContent = `${fmt(fit.residual, 2)} km`;
874
-
875
- const truthError = Math.hypot(fit.x - truth.x, fit.y - truth.y);
876
- const tSpread = Math.max(...fit.t0s) - Math.min(...fit.t0s);
877
- document.getElementById('classPill').textContent =
878
- `location quality — ${fit.residual < 4 ? 'tight fit' : fit.residual < 10 ? 'usable fit' : 'rough fit'}`;
879
- document.getElementById('timingPill').textContent = `timing spread — ${fmt(tSpread, 2)} s across stations`;
880
- document.getElementById('geometryPill').textContent = `station geometry — ${stationGeometryQuality()}`;
881
-
882
- document.getElementById('answerNotice').textContent =
883
- `The fitted epicenter lies ${fmt(truthError, 1)} km from the hidden source used to synthesize the station data. ` +
884
- `Each station contributes a radius from its observed S−P interval, and the solver picks the map point with the smallest circle mismatch.`;
885
- }
886
-
887
- function renderReason(inp, obs, fit) {
888
- const example = obs[0];
889
- const sp = example.tS - example.tP;
890
- document.getElementById('reasonDistance').textContent =
891
- `For station ${example.name}, S−P = ${fmt(sp, 2)} s, so the distance estimate is ${fmt(distanceFromSP(example, inp.vp, inp.vs), 1)} km.`;
892
- document.getElementById('reasonOrigin').textContent =
893
- `The four station-based origin-time estimates cluster around ${fmt(fit.t0, 2)} s with spread ${fmt(Math.max(...fit.t0s) - Math.min(...fit.t0s), 2)} s.`;
894
-
895
- drawMap(obs, fit, hiddenEvent(inp));
896
- drawSeismograms(obs);
897
- }
898
-
899
- function renderChecks(inp, obs, fit, truth) {
900
- const spDistances = obs.map((o) => distanceFromSP(o, inp.vp, inp.vs));
901
- const geomDistances = obs.map((o) => Math.hypot(fit.x - o.x, fit.y - o.y));
902
- const distErr = rms(spDistances.map((d, i) => d - geomDistances[i]));
903
- const distOk = distErr < (inp.noise ? 7.5 : 2.0);
904
- setStatus('checkDistBadge', distOk);
905
- document.getElementById('checkDistText').textContent =
906
- `RMS mismatch between S−P radii and map distances: ${fmt(distErr, 2)} km.`;
907
-
908
- const tSpread = Math.max(...fit.t0s) - Math.min(...fit.t0s);
909
- const timeOk = tSpread < (inp.noise ? 0.8 : 0.15);
910
- setStatus('checkTimeBadge', timeOk);
911
- document.getElementById('checkTimeText').textContent =
912
- `Station-by-station origin times differ by ${fmt(tSpread, 2)} s.`;
913
-
914
- const replayResiduals = obs.map((o, i) => {
915
- const d = geomDistances[i];
916
- const predP = fit.t0 + d / inp.vp;
917
- const predS = fit.t0 + d / inp.vs;
918
- return Math.max(Math.abs(predP - o.tP), Math.abs(predS - o.tS));
919
- });
920
- const replayMax = Math.max(...replayResiduals);
921
- const replayOk = replayMax < (inp.noise ? 0.5 : 0.08);
922
- setStatus('checkReplayBadge', replayOk);
923
- document.getElementById('checkReplayText').textContent =
924
- `Largest arrival-time replay error: ${fmt(replayMax, 2)} s.`;
925
-
926
- const truthError = Math.hypot(fit.x - truth.x, fit.y - truth.y);
927
- const truthOk = truthError < (inp.noise ? 12 : 3);
928
- setStatus('checkTruthBadge', truthOk);
929
- document.getElementById('checkTruthText').textContent =
930
- `Best-fit epicenter is ${fmt(truthError, 2)} km from the hidden source.`;
931
-
932
- const tbody = document.getElementById('auditTable');
933
- tbody.innerHTML = '';
934
- obs.forEach((o, i) => {
935
- const tr = document.createElement('tr');
936
- tr.innerHTML = `
937
- <td>${o.name}</td>
938
- <td class="code">${fmt(o.tP, 2)} s</td>
939
- <td class="code">${fmt(o.tS, 2)} s</td>
940
- <td class="code">${fmt(spDistances[i], 1)} km</td>
941
- <td class="code">${fmt(geomDistances[i], 1)} km</td>
942
- <td class="code">${fmt(fit.t0s[i], 2)} s</td>
943
- `;
944
- tbody.appendChild(tr);
945
- });
946
- }
947
-
948
- function runAll() {
949
- syncLabels();
950
- const inp = currentInputs();
951
- if (inp.vs >= inp.vp) {
952
- document.getElementById('ansXY').textContent = 'invalid wave speeds';
953
- return;
954
- }
955
- const obs = stationObservations(inp);
956
- const fit = fitEpicenter(obs, inp.vp, inp.vs);
957
- const truth = hiddenEvent(inp);
958
- renderAnswer(inp, fit, obs, truth);
959
- renderReason(inp, obs, fit);
960
- renderChecks(inp, obs, fit, truth);
961
- }
962
-
963
- document.getElementById('preset').addEventListener('change', (e) => {
964
- if (e.target.value !== 'custom') {
965
- applyPreset(e.target.value);
966
- }
967
- runAll();
968
- });
969
- document.getElementById('noiseToggle').addEventListener('change', runAll);
970
- ['vp', 'vs', 'originTime'].forEach((id) => {
971
- document.getElementById(id).addEventListener('input', () => {
972
- document.getElementById('preset').value = 'custom';
973
- runAll();
974
- });
975
- });
976
- window.addEventListener('resize', runAll);
977
-
978
- applyPreset('coastal');
979
- runAll();
980
- </script>
981
- </body>
982
- </html>