@zenuml/core 3.46.0 → 3.46.2

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 (60) hide show
  1. package/.claude/skills/dia-scoring/SKILL.md +139 -0
  2. package/.claude/skills/dia-scoring/agents/openai.yaml +7 -0
  3. package/.claude/skills/dia-scoring/references/selectors-and-keys.md +253 -0
  4. package/.claude/skills/land-pr/SKILL.md +98 -0
  5. package/.claude/skills/ship-branch/SKILL.md +81 -0
  6. package/.claude/skills/submit-branch/SKILL.md +76 -0
  7. package/.claude/skills/validate-branch/SKILL.md +54 -0
  8. package/CLAUDE.md +1 -1
  9. package/bun.lock +25 -11
  10. package/cy/canonical-history.html +908 -0
  11. package/cy/compare-case.html +357 -0
  12. package/cy/compare-cases.js +824 -0
  13. package/cy/compare.html +35 -0
  14. package/cy/diff-algorithm.js +199 -0
  15. package/cy/element-report.html +705 -0
  16. package/cy/icons-test.html +29 -0
  17. package/cy/legacy-vs-html.html +291 -0
  18. package/cy/native-diff-ext/background.js +60 -0
  19. package/cy/native-diff-ext/bridge.js +26 -0
  20. package/cy/native-diff-ext/content.js +194 -0
  21. package/cy/parity-test.html +122 -0
  22. package/cy/return-in-nested-if.html +29 -0
  23. package/cy/svg-preview.html +56 -0
  24. package/cy/svg-test.html +21 -0
  25. package/cy/theme-default-test.html +28 -0
  26. package/dist/stats.html +1 -1
  27. package/dist/zenuml.esm.mjs +16352 -15223
  28. package/dist/zenuml.js +701 -575
  29. package/docs/ship-branch-skill-plan.md +134 -0
  30. package/docs/superpowers/plans/2026-03-23-svg-parity-features.md +283 -0
  31. package/index.html +568 -73
  32. package/package.json +15 -4
  33. package/scripts/analyze-compare-case/collect-data.mjs +991 -0
  34. package/scripts/analyze-compare-case/config.mjs +102 -0
  35. package/scripts/analyze-compare-case/geometry.mjs +101 -0
  36. package/scripts/analyze-compare-case/native-diff.mjs +224 -0
  37. package/scripts/analyze-compare-case/output.mjs +74 -0
  38. package/scripts/analyze-compare-case/panel-diff.mjs +114 -0
  39. package/scripts/analyze-compare-case/report.mjs +157 -0
  40. package/scripts/analyze-compare-case/residual-scopes.mjs +325 -0
  41. package/scripts/analyze-compare-case/scoring.mjs +816 -0
  42. package/scripts/analyze-compare-case.mjs +149 -0
  43. package/scripts/snapshot-dual.js +34 -34
  44. package/skills/dia-scoring/SKILL.md +129 -0
  45. package/skills/dia-scoring/agents/openai.yaml +7 -0
  46. package/skills/dia-scoring/references/selectors-and-keys.md +253 -0
  47. package/test-setup.ts +8 -0
  48. package/types/index.d.ts +56 -0
  49. package/vite.config.ts +4 -0
  50. package/dist/10029-icon-service-Function-Apps-ObflOLuF.js +0 -5
  51. package/dist/Res_AWS-Identity-Access-Management_IAM-Access-Analyzer_48-BPq60XMY.js +0 -11
  52. package/dist/Res_AWS-Lambda_Lambda-Function_48-Co38UB_2.js +0 -12
  53. package/dist/Res_Amazon-EC2_Instance_48-CRaqbNUl.js +0 -12
  54. package/dist/Res_Amazon-Simple-Notification-Service_Topic_48-q13mxUeM.js +0 -11
  55. package/dist/Res_Amazon-Simple-Queue-Service_Queue_48-D2-8gbFw.js +0 -11
  56. package/dist/Robustness_Diagram_Boundary-nYnmTPs8.js +0 -10
  57. package/dist/Robustness_Diagram_Control-DLNLoMxd.js +0 -11
  58. package/dist/Robustness_Diagram_Entity-Be3kcbIE.js +0 -11
  59. package/dist/actor-BMj_HFpo.js +0 -11
  60. package/dist/database-BKHQQWQK.js +0 -8
@@ -0,0 +1,908 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Canonical Score History</title>
6
+ <style>
7
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap');
8
+
9
+ * { box-sizing: border-box; margin: 0; }
10
+ body {
11
+ font-family: 'DM Sans', sans-serif;
12
+ background: #0a0e1a;
13
+ color: #c8d1e0;
14
+ min-height: 100vh;
15
+ }
16
+
17
+ /* Header */
18
+ .header {
19
+ padding: 20px 28px 16px;
20
+ border-bottom: 1px solid #1a2035;
21
+ display: flex;
22
+ align-items: baseline;
23
+ gap: 16px;
24
+ }
25
+ .header h1 {
26
+ font-family: 'JetBrains Mono', monospace;
27
+ font-size: 16px;
28
+ font-weight: 600;
29
+ color: #e8ecf4;
30
+ letter-spacing: -0.3px;
31
+ }
32
+ .header .summary {
33
+ font-size: 12px;
34
+ color: #5a6a85;
35
+ font-family: 'JetBrains Mono', monospace;
36
+ }
37
+ .header .back-link {
38
+ margin-left: auto;
39
+ font-size: 12px;
40
+ color: #4a7cff;
41
+ text-decoration: none;
42
+ opacity: 0.7;
43
+ transition: opacity 0.15s;
44
+ }
45
+ .header .back-link:hover { opacity: 1; }
46
+
47
+ /* Main layout */
48
+ .layout {
49
+ display: grid;
50
+ grid-template-columns: 280px 1fr;
51
+ height: calc(100vh - 57px);
52
+ transition: grid-template-columns 0.2s ease;
53
+ }
54
+ .layout.collapsed {
55
+ grid-template-columns: 0px 1fr;
56
+ }
57
+
58
+ /* Sidebar toggle */
59
+ .sidebar-toggle {
60
+ background: #1a2035;
61
+ border: 1px solid #2a3555;
62
+ color: #8899bb;
63
+ font-size: 14px;
64
+ cursor: pointer;
65
+ padding: 4px 8px;
66
+ border-radius: 4px;
67
+ transition: color 0.15s;
68
+ }
69
+ .sidebar-toggle:hover { color: #e8ecf4; }
70
+
71
+ /* Case selector panel */
72
+ .case-panel {
73
+ border-right: 1px solid #1a2035;
74
+ display: flex;
75
+ flex-direction: column;
76
+ overflow: hidden;
77
+ min-width: 0;
78
+ }
79
+ .layout.collapsed .case-panel {
80
+ visibility: hidden;
81
+ }
82
+ .case-panel-header {
83
+ padding: 12px 16px;
84
+ border-bottom: 1px solid #1a2035;
85
+ display: flex;
86
+ align-items: center;
87
+ justify-content: space-between;
88
+ flex-shrink: 0;
89
+ }
90
+ .case-panel-title {
91
+ font-size: 11px;
92
+ font-weight: 600;
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.8px;
95
+ color: #5a6a85;
96
+ }
97
+ .case-panel-actions {
98
+ display: flex;
99
+ gap: 6px;
100
+ }
101
+ .case-panel-actions button {
102
+ font-family: 'JetBrains Mono', monospace;
103
+ font-size: 10px;
104
+ padding: 3px 8px;
105
+ border: 1px solid #1e2a42;
106
+ border-radius: 4px;
107
+ background: transparent;
108
+ color: #5a6a85;
109
+ cursor: pointer;
110
+ transition: all 0.15s;
111
+ }
112
+ .case-panel-actions button:hover {
113
+ border-color: #4a7cff;
114
+ color: #4a7cff;
115
+ }
116
+ .case-list {
117
+ flex: 1;
118
+ overflow-y: auto;
119
+ padding: 4px 0;
120
+ }
121
+ .case-list::-webkit-scrollbar { width: 4px; }
122
+ .case-list::-webkit-scrollbar-track { background: transparent; }
123
+ .case-list::-webkit-scrollbar-thumb { background: #1e2a42; border-radius: 2px; }
124
+
125
+ .case-item {
126
+ display: grid;
127
+ grid-template-columns: 28px 1fr 52px;
128
+ align-items: center;
129
+ padding: 5px 12px 5px 8px;
130
+ cursor: pointer;
131
+ transition: background 0.1s;
132
+ gap: 4px;
133
+ }
134
+ .case-item:hover { background: #0f1525; }
135
+ .case-item.checked { background: #0d1428; }
136
+
137
+ .case-item input[type="checkbox"] {
138
+ appearance: none;
139
+ width: 14px;
140
+ height: 14px;
141
+ border: 1.5px solid #2a3550;
142
+ border-radius: 3px;
143
+ cursor: pointer;
144
+ position: relative;
145
+ transition: all 0.15s;
146
+ justify-self: center;
147
+ }
148
+ .case-item input[type="checkbox"]:checked {
149
+ border-color: var(--case-color, #4a7cff);
150
+ background: var(--case-color, #4a7cff);
151
+ }
152
+ .case-item input[type="checkbox"]:checked::after {
153
+ content: '';
154
+ position: absolute;
155
+ left: 3px; top: 1px;
156
+ width: 4px; height: 7px;
157
+ border: solid #fff;
158
+ border-width: 0 1.5px 1.5px 0;
159
+ transform: rotate(45deg);
160
+ }
161
+
162
+ .case-item-name {
163
+ font-size: 12px;
164
+ color: #8a95aa;
165
+ white-space: nowrap;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ transition: color 0.15s;
169
+ }
170
+ .case-item.checked .case-item-name { color: #c8d1e0; }
171
+
172
+ .case-item-score {
173
+ font-family: 'JetBrains Mono', monospace;
174
+ font-size: 11px;
175
+ text-align: right;
176
+ font-variant-numeric: tabular-nums;
177
+ }
178
+
179
+ /* Color coding */
180
+ .sc-100 { color: #34d399; }
181
+ .sc-90 { color: #6ee7b7; }
182
+ .sc-80 { color: #fbbf24; }
183
+ .sc-70 { color: #f97316; }
184
+ .sc-low { color: #ef4444; }
185
+
186
+ /* Main content */
187
+ .main {
188
+ display: flex;
189
+ flex-direction: column;
190
+ overflow: hidden;
191
+ }
192
+
193
+ /* Chart area */
194
+ .chart-area {
195
+ padding: 20px 24px 12px;
196
+ flex-shrink: 0;
197
+ }
198
+ .chart-info {
199
+ display: flex;
200
+ align-items: baseline;
201
+ gap: 12px;
202
+ margin-bottom: 12px;
203
+ }
204
+ .chart-title {
205
+ font-size: 13px;
206
+ font-weight: 600;
207
+ color: #e8ecf4;
208
+ }
209
+ .chart-subtitle {
210
+ font-family: 'JetBrains Mono', monospace;
211
+ font-size: 11px;
212
+ color: #5a6a85;
213
+ }
214
+ .chart-legend {
215
+ display: flex;
216
+ gap: 12px;
217
+ flex-wrap: wrap;
218
+ margin-left: auto;
219
+ }
220
+ .legend-item {
221
+ display: flex;
222
+ align-items: center;
223
+ gap: 5px;
224
+ font-size: 11px;
225
+ color: #8a95aa;
226
+ }
227
+ .legend-dot {
228
+ width: 8px;
229
+ height: 3px;
230
+ border-radius: 1px;
231
+ }
232
+ .chart-wrap {
233
+ background: #0d1220;
234
+ border: 1px solid #151d30;
235
+ border-radius: 6px;
236
+ padding: 16px 12px 8px;
237
+ }
238
+ .chart-wrap canvas {
239
+ width: 100%;
240
+ height: 240px;
241
+ display: block;
242
+ }
243
+
244
+ /* History table */
245
+ .table-area {
246
+ flex: 1;
247
+ overflow-y: auto;
248
+ padding: 12px 24px 24px;
249
+ }
250
+ .table-area::-webkit-scrollbar { width: 5px; }
251
+ .table-area::-webkit-scrollbar-track { background: transparent; }
252
+ .table-area::-webkit-scrollbar-thumb { background: #1e2a42; border-radius: 3px; }
253
+
254
+ table {
255
+ width: 100%;
256
+ table-layout: fixed;
257
+ border-collapse: collapse;
258
+ font-size: 12px;
259
+ }
260
+ th {
261
+ text-align: left;
262
+ padding: 8px 10px;
263
+ border-bottom: 1px solid #1a2035;
264
+ color: #5a6a85;
265
+ font-weight: 500;
266
+ font-size: 10px;
267
+ text-transform: uppercase;
268
+ letter-spacing: 0.5px;
269
+ position: sticky;
270
+ top: 0;
271
+ background: #0a0e1a;
272
+ z-index: 1;
273
+ }
274
+ td {
275
+ padding: 6px 10px;
276
+ border-bottom: 1px solid #0f1525;
277
+ font-family: 'JetBrains Mono', monospace;
278
+ font-size: 11px;
279
+ }
280
+ tr:hover td { background: #0d1220; }
281
+ .ts { color: #4a5568; font-size: 11px; }
282
+ .d-pos { color: #34d399; }
283
+ .d-neg { color: #ef4444; }
284
+ .d-zero { color: #2a3550; }
285
+
286
+ .expand-btn {
287
+ cursor: pointer;
288
+ color: #4a7cff;
289
+ border: none;
290
+ background: none;
291
+ font-family: 'JetBrains Mono', monospace;
292
+ font-size: 11px;
293
+ padding: 2px 6px;
294
+ opacity: 0.6;
295
+ transition: opacity 0.15s;
296
+ }
297
+ .expand-btn:hover { opacity: 1; }
298
+ .detail-row { display: none; }
299
+ .detail-row.visible { display: table-row; }
300
+ .detail-row td { padding: 10px; }
301
+ .case-grid {
302
+ display: grid;
303
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
304
+ gap: 3px 16px;
305
+ font-size: 11px;
306
+ }
307
+ .case-grid-item {
308
+ display: flex;
309
+ justify-content: space-between;
310
+ padding: 2px 0;
311
+ }
312
+ .case-grid-name { color: #5a6a85; }
313
+
314
+ /* Case table header dot */
315
+ .th-case-dot {
316
+ display: inline-block;
317
+ width: 6px;
318
+ height: 6px;
319
+ border-radius: 50%;
320
+ margin-right: 4px;
321
+ vertical-align: middle;
322
+ }
323
+
324
+ .no-data {
325
+ text-align: center;
326
+ padding: 60px 24px;
327
+ color: #2a3550;
328
+ font-size: 13px;
329
+ }
330
+
331
+ /* Selection indicator */
332
+ .selection-count {
333
+ font-family: 'JetBrains Mono', monospace;
334
+ font-size: 10px;
335
+ color: #4a7cff;
336
+ background: #0d1428;
337
+ border: 1px solid #1a2540;
338
+ padding: 2px 7px;
339
+ border-radius: 3px;
340
+ }
341
+ </style>
342
+ </head>
343
+ <body>
344
+ <div class="header">
345
+ <button class="sidebar-toggle" id="sidebar-toggle" title="Toggle sidebar">&#9776;</button>
346
+ <h1>canonical scores</h1>
347
+ <span class="summary" id="summary"></span>
348
+ <a href="/cy/compare.html" class="back-link">compare cases &rarr;</a>
349
+ </div>
350
+
351
+ <div class="layout">
352
+ <!-- Case selector -->
353
+ <div class="case-panel">
354
+ <div class="case-panel-header">
355
+ <span class="case-panel-title">Cases</span>
356
+ <div class="case-panel-actions">
357
+ <button id="btn-clear">clear</button>
358
+ <button id="btn-all">all</button>
359
+ </div>
360
+ </div>
361
+ <div class="case-list" id="case-list"></div>
362
+ </div>
363
+
364
+ <!-- Main area -->
365
+ <div class="main">
366
+ <div class="chart-area">
367
+ <div class="chart-info">
368
+ <span class="chart-title" id="chart-title">Average Score Trend</span>
369
+ <span class="chart-subtitle" id="chart-subtitle"></span>
370
+ <div class="chart-legend" id="chart-legend"></div>
371
+ <button id="metric-toggle" style="
372
+ background: #1a2035; border: 1px solid #2a3555; border-radius: 4px;
373
+ color: #8a95aa; font-family: 'JetBrains Mono', monospace; font-size: 11px;
374
+ padding: 3px 10px; cursor: pointer; margin-left: 8px;
375
+ transition: all 0.15s;
376
+ " onmouseover="this.style.borderColor='#4a7cff';this.style.color='#c8d1e0'"
377
+ onmouseout="this.style.borderColor='#2a3555';this.style.color='#8a95aa'"
378
+ >pos avg</button>
379
+ </div>
380
+ <div class="chart-wrap">
381
+ <canvas id="main-chart" height="240"></canvas>
382
+ </div>
383
+ </div>
384
+
385
+ <div class="table-area" id="table-area">
386
+ <div class="no-data" id="no-data" style="display:none">No runs recorded yet.</div>
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ <script>
392
+ // --- Sidebar toggle ---
393
+ document.getElementById('sidebar-toggle').addEventListener('click', () => {
394
+ document.querySelector('.layout').classList.toggle('collapsed');
395
+ });
396
+
397
+ // --- Chart metric toggle ---
398
+ let chartMetric = 'pos'; // 'pixel' or 'pos'
399
+ function getRunAvg(run) {
400
+ return chartMetric === 'pos' ? runAveragePos(run) : run.average;
401
+ }
402
+ document.addEventListener('DOMContentLoaded', () => {
403
+ const btn = document.getElementById('metric-toggle');
404
+ const updateBtn = () => {
405
+ btn.textContent = chartMetric === 'pos' ? 'show pixel avg' : 'show pos avg';
406
+ };
407
+ updateBtn();
408
+ btn.addEventListener('click', () => {
409
+ chartMetric = chartMetric === 'pos' ? 'pixel' : 'pos';
410
+ updateBtn();
411
+ redrawChart();
412
+ });
413
+ });
414
+
415
+ // --- Color palette for case lines ---
416
+ const PALETTE = [
417
+ '#4a7cff','#34d399','#f97316','#a78bfa','#f472b6',
418
+ '#22d3ee','#facc15','#fb7185','#38bdf8','#a3e635',
419
+ '#e879f9','#2dd4bf','#f87171','#818cf8','#fbbf24',
420
+ '#67e8f9','#c084fc','#4ade80','#fb923c','#e2e8f0',
421
+ ];
422
+
423
+ function scoreClass(s) {
424
+ if (s >= 100) return 'sc-100';
425
+ if (s >= 90) return 'sc-90';
426
+ if (s >= 80) return 'sc-80';
427
+ if (s >= 70) return 'sc-70';
428
+ return 'sc-low';
429
+ }
430
+
431
+ function deltaHtml(d) {
432
+ if (d === null) return '<span class="d-zero">&mdash;</span>';
433
+ const sign = d > 0 ? '+' : '';
434
+ const cls = d > 0 ? 'd-pos' : d < 0 ? 'd-neg' : 'd-zero';
435
+ return `<span class="${cls}">${sign}${d.toFixed(1)}</span>`;
436
+ }
437
+
438
+ function fmtDate(iso) {
439
+ const d = new Date(iso);
440
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +
441
+ ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
442
+ }
443
+
444
+ function caseScore(entry) {
445
+ return typeof entry === 'object' ? entry.score : entry;
446
+ }
447
+
448
+ function casePosScore(entry) {
449
+ if (typeof entry === 'object' && typeof entry.posScore === 'number') return entry.posScore;
450
+ return caseScore(entry);
451
+ }
452
+
453
+ function runAveragePos(run) {
454
+ if (typeof run.averagePos === 'number') return run.averagePos;
455
+ const scores = Object.values(run.cases || {}).map(casePosScore);
456
+ return scores.length
457
+ ? parseFloat((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(1))
458
+ : (typeof run.average === 'number' ? run.average : 0);
459
+ }
460
+
461
+ async function loadHistory() {
462
+ if (typeof window.__getCanonicalHistory === 'function') {
463
+ return await window.__getCanonicalHistory();
464
+ }
465
+ return new Promise((resolve, reject) => {
466
+ const req = indexedDB.open('canonical-history', 1);
467
+ req.onupgradeneeded = () => req.result.createObjectStore('runs', { keyPath: 'timestamp' });
468
+ req.onsuccess = () => {
469
+ const db = req.result;
470
+ const tx = db.transaction('runs', 'readonly');
471
+ const store = tx.objectStore('runs');
472
+ const getAll = store.getAll();
473
+ getAll.onsuccess = () => { db.close(); resolve(getAll.result); };
474
+ getAll.onerror = () => { db.close(); reject(getAll.error); };
475
+ };
476
+ req.onerror = () => reject(req.error);
477
+ });
478
+ }
479
+
480
+ // --- Global state ---
481
+ let allRuns = []; // oldest first
482
+ let allCaseData = []; // { name, entries, scores, latest, delta, min, max, color }
483
+ let selectedCases = new Set();
484
+
485
+ // --- Build case data ---
486
+ function buildCaseData(runs) {
487
+ const sorted = [...runs].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
488
+ const names = new Set();
489
+ sorted.forEach(r => Object.keys(r.cases).forEach(k => names.add(k)));
490
+
491
+ const data = [];
492
+ let colorIdx = 0;
493
+ for (const name of names) {
494
+ const entries = [];
495
+ sorted.forEach(r => {
496
+ const d = r.cases[name];
497
+ if (d != null) entries.push({ timestamp: r.timestamp, score: caseScore(d), posScore: casePosScore(d) });
498
+ });
499
+ const scores = entries.map(e => e.score);
500
+ const latest = scores[scores.length - 1];
501
+ const prev = scores.length >= 2 ? scores[scores.length - 2] : null;
502
+ data.push({
503
+ name, entries, scores, latest,
504
+ delta: prev !== null ? latest - prev : null,
505
+ min: Math.min(...scores),
506
+ max: Math.max(...scores),
507
+ color: PALETTE[colorIdx % PALETTE.length],
508
+ });
509
+ colorIdx++;
510
+ }
511
+ data.sort((a, b) => b.latest - a.latest);
512
+ return data;
513
+ }
514
+
515
+ // --- Render case selector ---
516
+ function renderCaseList() {
517
+ const container = document.getElementById('case-list');
518
+ container.innerHTML = '';
519
+
520
+ allCaseData.forEach((cd, idx) => {
521
+ const div = document.createElement('div');
522
+ div.className = 'case-item';
523
+ div.innerHTML = `
524
+ <input type="checkbox" id="cb-${idx}" style="--case-color:${cd.color}">
525
+ <span class="case-item-name" title="${cd.name}">${cd.name}</span>
526
+ <span class="case-item-score ${scoreClass(cd.latest)}">${cd.latest.toFixed(1)}%</span>
527
+ `;
528
+
529
+ const cb = div.querySelector('input');
530
+ div.addEventListener('click', (e) => {
531
+ if (e.target === cb) return;
532
+ cb.checked = !cb.checked;
533
+ cb.dispatchEvent(new Event('change'));
534
+ });
535
+ cb.addEventListener('change', () => {
536
+ if (cb.checked) {
537
+ selectedCases.add(cd.name);
538
+ div.classList.add('checked');
539
+ } else {
540
+ selectedCases.delete(cd.name);
541
+ div.classList.remove('checked');
542
+ }
543
+ onSelectionChanged();
544
+ });
545
+
546
+ container.appendChild(div);
547
+ });
548
+ }
549
+
550
+ // --- Chart drawing ---
551
+ function redrawChart() {
552
+ const canvas = document.getElementById('main-chart');
553
+ const ctx = canvas.getContext('2d');
554
+ const dpr = window.devicePixelRatio || 1;
555
+ const rect = canvas.getBoundingClientRect();
556
+ canvas.width = rect.width * dpr;
557
+ canvas.height = rect.height * dpr;
558
+ ctx.scale(dpr, dpr);
559
+
560
+ const w = rect.width, h = rect.height;
561
+ const pad = { top: 24, right: 16, bottom: 28, left: 42 };
562
+ const plotW = w - pad.left - pad.right;
563
+ const plotH = h - pad.top - pad.bottom;
564
+
565
+ const titleEl = document.getElementById('chart-title');
566
+ const subtitleEl = document.getElementById('chart-subtitle');
567
+ const legendEl = document.getElementById('chart-legend');
568
+
569
+ if (selectedCases.size === 0) {
570
+ // --- Aggregate mode: avg(all) per run ---
571
+ const avgs = allRuns.map(r => getRunAvg(r));
572
+ const metricLabel = chartMetric === 'pos' ? 'Pos Avg' : 'Pixel Avg';
573
+
574
+ titleEl.textContent = metricLabel + ' Score Trend (canonical)';
575
+ subtitleEl.textContent = allRuns.length + ' runs';
576
+ legendEl.innerHTML = '';
577
+
578
+ if (avgs.length === 0) return;
579
+
580
+ const minY = Math.max(0, Math.floor((Math.min(...avgs) - 5) / 5) * 5);
581
+ const maxY = 100;
582
+ const range = maxY - minY || 1;
583
+
584
+ drawGrid(ctx, w, h, pad, plotW, plotH, minY, maxY, range);
585
+ drawXLabels(ctx, allRuns.map(r => r.timestamp), w, h, pad, plotW);
586
+
587
+ // Area fill
588
+ const grad = ctx.createLinearGradient(0, pad.top, 0, pad.top + plotH);
589
+ grad.addColorStop(0, 'rgba(74,124,255,0.12)');
590
+ grad.addColorStop(1, 'rgba(74,124,255,0)');
591
+ ctx.fillStyle = grad;
592
+ ctx.beginPath();
593
+ avgs.forEach((v, i) => {
594
+ const px = pad.left + (i / Math.max(1, avgs.length - 1)) * plotW;
595
+ const py = pad.top + plotH - ((v - minY) / range) * plotH;
596
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
597
+ });
598
+ ctx.lineTo(pad.left + plotW, pad.top + plotH);
599
+ ctx.lineTo(pad.left, pad.top + plotH);
600
+ ctx.closePath();
601
+ ctx.fill();
602
+
603
+ // Line
604
+ ctx.strokeStyle = '#4a7cff';
605
+ ctx.lineWidth = 2;
606
+ ctx.beginPath();
607
+ avgs.forEach((v, i) => {
608
+ const px = pad.left + (i / Math.max(1, avgs.length - 1)) * plotW;
609
+ const py = pad.top + plotH - ((v - minY) / range) * plotH;
610
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
611
+ });
612
+ ctx.stroke();
613
+
614
+ // Value labels (sparse) + run labels for labeled runs
615
+ const labelStep = Math.max(1, Math.floor(avgs.length / 10));
616
+ ctx.font = '500 10px JetBrains Mono, monospace';
617
+ ctx.textAlign = 'center';
618
+ avgs.forEach((v, i) => {
619
+ const px = pad.left + (i / Math.max(1, avgs.length - 1)) * plotW;
620
+ const py = pad.top + plotH - ((v - minY) / range) * plotH;
621
+ const run = allRuns[i];
622
+ // Always show dot + value for labeled runs or sparse interval
623
+ const showValue = (i % labelStep === 0) || i === avgs.length - 1 || run.label;
624
+ if (showValue) {
625
+ ctx.fillStyle = run.label ? '#ff9944' : '#4a7cff';
626
+ ctx.beginPath();
627
+ ctx.arc(px, py, run.label ? 4 : 2.5, 0, Math.PI * 2);
628
+ ctx.fill();
629
+ ctx.fillStyle = '#8a95aa';
630
+ ctx.fillText(v.toFixed(1), px, py - 8);
631
+ }
632
+ });
633
+ } else {
634
+ // --- Per-case mode ---
635
+ const selected = allCaseData.filter(cd => selectedCases.has(cd.name));
636
+ titleEl.textContent = selected.length === 1 ? selected[0].name : selected.length + ' cases selected';
637
+ subtitleEl.textContent = '';
638
+
639
+ // Build legend
640
+ legendEl.innerHTML = selected.map(cd =>
641
+ `<span class="legend-item"><span class="legend-dot" style="background:${cd.color}"></span>${cd.name}</span>`
642
+ ).join('');
643
+
644
+ // Compute Y range across all selected
645
+ let allScores = [];
646
+ selected.forEach(cd => allScores.push(...cd.scores));
647
+ const minY = Math.max(0, Math.floor((Math.min(...allScores) - 5) / 5) * 5);
648
+ const maxY = 100;
649
+ const range = maxY - minY || 1;
650
+
651
+ // Use the run timestamps as X axis (all runs, oldest first)
652
+ const timestamps = allRuns.map(r => r.timestamp);
653
+
654
+ drawGrid(ctx, w, h, pad, plotW, plotH, minY, maxY, range);
655
+ drawXLabels(ctx, timestamps, w, h, pad, plotW);
656
+
657
+ // Draw each case line
658
+ selected.forEach(cd => {
659
+ // Map entries to run indices
660
+ const scoreMap = {};
661
+ cd.entries.forEach(e => { scoreMap[e.timestamp] = e.score; });
662
+
663
+ ctx.strokeStyle = cd.color;
664
+ ctx.lineWidth = 1.8;
665
+ ctx.beginPath();
666
+ let started = false;
667
+ timestamps.forEach((ts, i) => {
668
+ if (scoreMap[ts] == null) return;
669
+ const px = pad.left + (i / Math.max(1, timestamps.length - 1)) * plotW;
670
+ const py = pad.top + plotH - ((scoreMap[ts] - minY) / range) * plotH;
671
+ if (!started) { ctx.moveTo(px, py); started = true; }
672
+ else ctx.lineTo(px, py);
673
+ });
674
+ ctx.stroke();
675
+
676
+ // Latest dot
677
+ const lastEntry = cd.entries[cd.entries.length - 1];
678
+ const lastIdx = timestamps.indexOf(lastEntry.timestamp);
679
+ if (lastIdx >= 0) {
680
+ const px = pad.left + (lastIdx / Math.max(1, timestamps.length - 1)) * plotW;
681
+ const py = pad.top + plotH - ((lastEntry.score - minY) / range) * plotH;
682
+ ctx.fillStyle = cd.color;
683
+ ctx.beginPath();
684
+ ctx.arc(px, py, 3, 0, Math.PI * 2);
685
+ ctx.fill();
686
+ ctx.font = '500 10px JetBrains Mono, monospace';
687
+ ctx.textAlign = 'center';
688
+ ctx.fillText(lastEntry.score.toFixed(1), px, py - 8);
689
+ }
690
+ });
691
+ }
692
+ }
693
+
694
+ function drawGrid(ctx, w, h, pad, plotW, plotH, minY, maxY, range) {
695
+ ctx.strokeStyle = '#141b2d';
696
+ ctx.lineWidth = 0.5;
697
+ ctx.font = '10px JetBrains Mono, monospace';
698
+ ctx.fillStyle = '#3a4560';
699
+ ctx.textAlign = 'right';
700
+ for (let y = minY; y <= maxY; y += 10) {
701
+ const py = pad.top + plotH - ((y - minY) / range) * plotH;
702
+ ctx.beginPath();
703
+ ctx.moveTo(pad.left, py);
704
+ ctx.lineTo(w - pad.right, py);
705
+ ctx.stroke();
706
+ ctx.fillText(y + '%', pad.left - 6, py + 3);
707
+ }
708
+ }
709
+
710
+ function drawXLabels(ctx, timestamps, w, h, pad, plotW) {
711
+ ctx.textAlign = 'center';
712
+ ctx.fillStyle = '#3a4560';
713
+ ctx.font = '10px JetBrains Mono, monospace';
714
+ const step = Math.max(1, Math.floor(timestamps.length / 8));
715
+ timestamps.forEach((ts, i) => {
716
+ if (i % step !== 0 && i !== timestamps.length - 1) return;
717
+ const px = pad.left + (i / Math.max(1, timestamps.length - 1)) * plotW;
718
+ const d = new Date(ts);
719
+ ctx.fillText(d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), px, h - 4);
720
+ });
721
+ }
722
+
723
+ // --- Table rendering (reactive) ---
724
+ function renderTable() {
725
+ const area = document.getElementById('table-area');
726
+ const noData = document.getElementById('no-data');
727
+
728
+ if (allRuns.length === 0) {
729
+ noData.style.display = '';
730
+ return;
731
+ }
732
+ noData.style.display = 'none';
733
+
734
+ if (selectedCases.size === 0) {
735
+ renderAggregateTable(area);
736
+ } else {
737
+ renderCaseTable(area);
738
+ }
739
+ }
740
+
741
+ function renderAggregateTable(area) {
742
+ const newestFirst = [...allRuns].reverse();
743
+ const table = document.createElement('table');
744
+ table.innerHTML = `<thead><tr>
745
+ <th style="width:28px"></th>
746
+ <th>Date</th><th style="width:40%">Label</th><th>Avg</th><th>Pos Avg</th><th>Delta</th>
747
+ <th>Cases</th><th>Time</th><th style="width:80px">Best</th><th style="width:80px">Worst</th>
748
+ </tr></thead>`;
749
+ const tbody = document.createElement('tbody');
750
+
751
+ newestFirst.forEach((run, idx) => {
752
+ const prev = idx < newestFirst.length - 1 ? newestFirst[idx + 1] : null;
753
+ const avg = run.average;
754
+ const avgPos = runAveragePos(run);
755
+ const prevAvgPos = prev ? runAveragePos(prev) : null;
756
+ const delta = prevAvgPos !== null ? avgPos - prevAvgPos : null;
757
+ const scores = Object.entries(run.cases).map(([n, d]) => ({
758
+ name: n, score: caseScore(d), posScore: casePosScore(d),
759
+ })).sort((a, b) => b.posScore - a.posScore);
760
+ const best = scores[0], worst = scores[scores.length - 1];
761
+
762
+ const tr = document.createElement('tr');
763
+ tr.innerHTML = `
764
+ <td><button class="expand-btn" data-idx="${idx}">+</button></td>
765
+ <td class="ts">${fmtDate(run.timestamp)}</td>
766
+ <td style="color:#8b9cc0;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${run.label || ''}">${run.label || ''}</td>
767
+ <td class="${scoreClass(avg)}" style="font-weight:bold">${avg.toFixed(1)}%</td>
768
+ <td class="${scoreClass(avgPos)}" style="font-weight:bold">${avgPos.toFixed(1)}%</td>
769
+ <td>${deltaHtml(delta)}</td>
770
+ <td style="color:#5a6a85">${run.caseCount}</td>
771
+ <td class="ts">${run.elapsed || '—'}s</td>
772
+ <td class="${scoreClass(best.posScore)}" style="font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${best.name} ${best.posScore.toFixed(1)}%">${best.name} ${best.posScore.toFixed(1)}%</td>
773
+ <td class="${scoreClass(worst.posScore)}" style="font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${worst.name} ${worst.posScore.toFixed(1)}%">${worst.name} ${worst.posScore.toFixed(1)}%</td>
774
+ `;
775
+ tbody.appendChild(tr);
776
+
777
+ const detailTr = document.createElement('tr');
778
+ detailTr.className = 'detail-row';
779
+ detailTr.id = `detail-${idx}`;
780
+ detailTr.innerHTML = `<td colspan="10"><div class="case-grid">${
781
+ scores.map(s => `<div class="case-grid-item"><span class="case-grid-name">${s.name}</span><span class="${scoreClass(s.score)}">${s.score}% / ${s.posScore.toFixed(1)}%</span></div>`).join('')
782
+ }</div></td>`;
783
+ tbody.appendChild(detailTr);
784
+ });
785
+
786
+ tbody.addEventListener('click', (e) => {
787
+ const btn = e.target.closest('.expand-btn');
788
+ if (!btn) return;
789
+ const detail = document.getElementById(`detail-${btn.dataset.idx}`);
790
+ detail.classList.toggle('visible');
791
+ btn.textContent = detail.classList.contains('visible') ? '−' : '+';
792
+ });
793
+
794
+ table.appendChild(tbody);
795
+ // Replace table only, keep no-data div
796
+ const oldTable = area.querySelector('table');
797
+ if (oldTable) oldTable.remove();
798
+ area.prepend(table);
799
+ }
800
+
801
+ function renderCaseTable(area) {
802
+ const selected = allCaseData.filter(cd => selectedCases.has(cd.name));
803
+ // Collect all unique timestamps where any selected case has data, newest first
804
+ const tsSet = new Set();
805
+ selected.forEach(cd => cd.entries.forEach(e => tsSet.add(e.timestamp)));
806
+ const timestamps = [...tsSet].sort((a, b) => b.localeCompare(a));
807
+
808
+ // Build score lookup per case: { timestamp -> score }
809
+ const lookups = selected.map(cd => {
810
+ const map = {};
811
+ cd.entries.forEach(e => { map[e.timestamp] = e.score; });
812
+ return map;
813
+ });
814
+
815
+ // Build prev-score lookup for deltas
816
+ const prevLookups = selected.map(cd => {
817
+ const map = {};
818
+ for (let i = 1; i < cd.entries.length; i++) {
819
+ map[cd.entries[i].timestamp] = cd.entries[i - 1].score;
820
+ }
821
+ return map;
822
+ });
823
+
824
+ const table = document.createElement('table');
825
+ // Header: Date + one column per selected case (score + delta subcolumns)
826
+ let headerHtml = '<thead><tr><th>Date</th>';
827
+ selected.forEach(cd => {
828
+ headerHtml += `<th><span class="th-case-dot" style="background:${cd.color}"></span>${cd.name}</th>`;
829
+ headerHtml += `<th style="width:50px">\u0394</th>`;
830
+ });
831
+ headerHtml += '</tr></thead>';
832
+ table.innerHTML = headerHtml;
833
+
834
+ const tbody = document.createElement('tbody');
835
+ timestamps.forEach(ts => {
836
+ const tr = document.createElement('tr');
837
+ let cells = `<td class="ts">${fmtDate(ts)}</td>`;
838
+ selected.forEach((cd, i) => {
839
+ const score = lookups[i][ts];
840
+ const prev = prevLookups[i][ts];
841
+ if (score != null) {
842
+ const delta = prev != null ? score - prev : null;
843
+ cells += `<td class="${scoreClass(score)}">${score.toFixed(1)}%</td>`;
844
+ cells += `<td>${deltaHtml(delta)}</td>`;
845
+ } else {
846
+ cells += `<td class="d-zero">—</td><td></td>`;
847
+ }
848
+ });
849
+ tr.innerHTML = cells;
850
+ tbody.appendChild(tr);
851
+ });
852
+
853
+ table.appendChild(tbody);
854
+ const oldTable = area.querySelector('table');
855
+ if (oldTable) oldTable.remove();
856
+ area.prepend(table);
857
+ }
858
+
859
+ // --- Button handlers ---
860
+ document.getElementById('btn-clear').addEventListener('click', () => {
861
+ selectedCases.clear();
862
+ document.querySelectorAll('.case-item').forEach(el => {
863
+ el.classList.remove('checked');
864
+ el.querySelector('input').checked = false;
865
+ });
866
+ onSelectionChanged();
867
+ });
868
+
869
+ document.getElementById('btn-all').addEventListener('click', () => {
870
+ allCaseData.forEach(cd => selectedCases.add(cd.name));
871
+ document.querySelectorAll('.case-item').forEach(el => {
872
+ el.classList.add('checked');
873
+ el.querySelector('input').checked = true;
874
+ });
875
+ onSelectionChanged();
876
+ });
877
+
878
+ function onSelectionChanged() {
879
+ redrawChart();
880
+ renderTable();
881
+ }
882
+
883
+ // --- Init ---
884
+ setTimeout(async () => {
885
+ try {
886
+ const runs = await loadHistory();
887
+ allRuns = [...runs].sort((a, b) => a.timestamp.localeCompare(b.timestamp));
888
+ allCaseData = buildCaseData(runs);
889
+ const latest = allRuns[allRuns.length - 1];
890
+ if (latest) {
891
+ document.getElementById('summary').textContent =
892
+ latest.average.toFixed(1) + '% px avg / ' + runAveragePos(latest).toFixed(1) + '% pos avg / ' + latest.caseCount + ' cases';
893
+ }
894
+ renderCaseList();
895
+ renderTable();
896
+ redrawChart();
897
+ } catch (e) {
898
+ console.error('Failed to load history:', e);
899
+ document.getElementById('no-data').style.display = '';
900
+ document.getElementById('no-data').textContent = 'Error: ' + e.message;
901
+ }
902
+ }, 500);
903
+
904
+ // Resize handling
905
+ window.addEventListener('resize', () => redrawChart());
906
+ </script>
907
+ </body>
908
+ </html>