-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcodemap.html
More file actions
634 lines (625 loc) · 71.1 KB
/
Copy pathcodemap.html
File metadata and controls
634 lines (625 loc) · 71.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
<!DOCTYPE html>
<!--
Functional Architecture Map (interactive). GENERATED from modules.json by the codemap skill.
Do not hand-edit — edit modules.json and re-run render.py.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Functional Architecture Map</title>
<style>
:root{
--bg:#131517; --bg2:#0e1012; --panel:#1b1e21; --panel2:#202428;
--ink:#e8e6e3; --muted:#9aa1a8; --faint:#6b7280;
--border:#2a2e33; --border2:#363b41;
--accent:#f59e0b; --accent-dim:#b4730e; --accent-soft:rgba(245,158,11,.14);
--in:#7c8794; --out:#f59e0b;
--c-low:#525a62; --c-med:#7e8893; --c-high:#c9cdd2; --c-core:#f59e0b;
--mono:"SF Mono",ui-monospace,"Cascadia Code","JetBrains Mono",Consolas,monospace;
--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%}
body{background:var(--bg);
color:var(--ink); font-family:var(--sans); -webkit-font-smoothing:antialiased; overflow:hidden;}
.app{display:grid; grid-template-columns:1fr 348px; grid-template-rows:auto 1fr; height:100vh}
header{grid-column:1/3; display:flex; align-items:center; gap:16px; padding:12px 20px;
border-bottom:1px solid var(--border); background:var(--bg);}
header .mark{width:22px;height:22px;border-radius:6px;flex:0 0 auto;background:var(--accent);}
header h1{font-size:14px;font-weight:600;margin:0;letter-spacing:.1px;white-space:nowrap}
header h1 span{color:var(--muted);font-weight:400}
header .sub{font-size:11.5px;color:var(--faint);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
header .spacer{flex:1}
.search{display:flex;align-items:center;gap:8px;background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 11px;width:230px;}
.search:focus-within{border-color:var(--border2)}
.search input{background:none;border:none;outline:none;color:var(--ink);font-family:var(--sans);font-size:12.5px;width:100%;}
.search svg{flex:0 0 auto;opacity:.6}
.btn{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 12px;cursor:pointer;transition:.12s;}
.btn:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.btn.active{color:#0e0f11;background:var(--accent);border-color:var(--accent)}
.filt{font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:8px;padding:7px 8px;cursor:pointer;max-width:150px}
.filt:hover{color:var(--ink);border-color:var(--border2);background:var(--panel2)}
.filt.on{color:#1a1a1a;background:var(--accent);border-color:var(--accent)}
.fcount{font-family:var(--mono);font-size:10.5px;color:var(--accent);white-space:nowrap;min-width:52px}
.board-wrap{position:relative;overflow:auto;background:var(--bg2)}
.board{position:relative;min-width:min-content;padding:26px 30px 60px}
svg.edges{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:1;overflow:visible}
.band{position:relative;z-index:2;margin-bottom:13px}
.band-head{display:flex;align-items:baseline;gap:10px;margin:0 2px 9px;position:sticky;left:0;}
.band-head .t{font-size:11px;font-weight:700;letter-spacing:.9px;text-transform:uppercase;color:var(--ink)}
.band-head .d{font-size:11px;color:var(--faint)}
.band-head .num{font-size:9.5px;color:var(--faint);border:1px solid var(--border);border-radius:20px;padding:1px 7px;font-family:var(--mono)}
.band-head .gloc{color:var(--muted);border-color:transparent;padding-left:0}
.band-body{display:flex;flex-wrap:wrap;gap:7px}
.wire{position:relative;z-index:2;display:flex;align-items:center;gap:14px;margin:6px 2px 16px;color:var(--accent);}
.wire .line{flex:1;height:1px;background:linear-gradient(90deg,transparent,var(--accent-dim),transparent)}
.wire .lbl{font-family:var(--mono);font-size:10.5px;letter-spacing:1px;color:var(--accent);white-space:nowrap}
.node{position:relative;background:var(--panel);border:1px solid var(--border);border-radius:9px;
padding:8px 30px 8px 12px;cursor:pointer;min-width:104px;
transition:transform .12s, border-color .12s, background .12s, box-shadow .12s, opacity .15s;user-select:none;}
.node::before{content:"";position:absolute;left:0;top:8px;bottom:8px;width:3px;border-radius:3px;background:var(--c-low);}
.node.c-med::before{background:var(--c-med)} .node.c-high::before{background:var(--c-high)}
.node.c-core::before{background:var(--c-core);box-shadow:0 0 8px var(--accent-soft)}
.node .lab{font-size:12px;font-weight:560;line-height:1.25;letter-spacing:.1px}
.node .meta{font-size:9.5px;color:var(--faint);font-family:var(--mono);margin-top:2px}
.node:hover{border-color:var(--border2);background:var(--panel2);transform:translateY(-1px)}
.node .chip{position:absolute;top:6px;right:7px;font-family:var(--mono);font-size:9px;font-weight:700;
line-height:1;padding:2px 4px;border-radius:4px;color:#0c0d0e;background:var(--h,#6b7280);opacity:0;transition:opacity .15s;}
.board.show-health .node::before{background:var(--h)!important;box-shadow:none}
.board.show-health .node .chip{opacity:1}
.board.show-health.has-sel .node:not(.lit) .chip{opacity:.25}
.board.has-sel .node{opacity:.26;filter:saturate(.7)}
.board.has-sel .node.lit{opacity:1;filter:none}
.node.sel{border-color:var(--accent);background:#241d12;box-shadow:0 0 0 1px var(--accent), 0 6px 22px rgba(245,158,11,.18);transform:translateY(-1px);}
.node.dep{border-color:var(--accent-dim)} .node.dependent{border-color:var(--in)}
aside{background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:20px 20px 40px;}
.empty-hint{color:var(--faint);font-size:12.5px;line-height:1.7} .empty-hint b{color:var(--muted);font-weight:600}
.legend{margin-top:22px} .legend h4,aside h4{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:0 0 10px}
.legend .row{display:flex;align-items:center;gap:9px;margin-bottom:7px;font-size:12px;color:var(--muted)}
.swatch{width:16px;height:9px;border-radius:3px;flex:0 0 auto}
.ln{width:26px;height:0;border-top-width:2px;border-top-style:solid;flex:0 0 auto}
.detail .kicker{font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px;text-transform:uppercase}
.detail h2{font-size:18px;margin:5px 0 3px;font-weight:650;line-height:1.2}
.detail .path{font-family:var(--mono);font-size:11px;color:var(--faint);word-break:break-all;margin-bottom:13px}
.pill-row{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:15px}
.pill{font-size:10.5px;border:1px solid var(--border2);border-radius:20px;padding:3px 9px;color:var(--muted);font-family:var(--mono)}
.pill.cpl{border-color:var(--accent-dim);color:var(--accent)}
.detail p.desc{font-size:13px;line-height:1.6;color:var(--ink);margin:0 0 16px}
.reltitle{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:16px 0 8px;display:flex;align-items:center;gap:7px}
.reltitle .dotc{width:7px;height:7px;border-radius:50%}
.rel{display:flex;flex-direction:column;gap:4px}
.rel button{text-align:left;font:inherit;font-size:12px;color:var(--muted);background:var(--panel);
border:1px solid var(--border);border-radius:7px;padding:6px 9px;cursor:pointer;transition:.13s;
display:flex;justify-content:space-between;align-items:center;gap:8px;}
.rel button:hover{color:var(--ink);border-color:var(--accent-dim);background:var(--panel2)}
.rel button .bnd{font-size:9px;font-family:var(--mono);color:var(--faint)}
.rel .none{font-size:11.5px;color:var(--faint);font-style:italic;padding:2px}
.clearbtn{margin-top:18px;width:100%}
.scorebox{display:flex;align-items:center;gap:12px;margin:2px 0 13px}
.scorebox .big{font-size:34px;font-weight:740;line-height:1;font-family:var(--mono)}
.scorebox .gr{font-size:13px;font-weight:700;border:1.5px solid;border-radius:7px;padding:3px 9px}
.scorebox .bar{flex:1;height:7px;border-radius:5px;background:#23272c;overflow:hidden}
.scorebox .bar i{display:block;height:100%;border-radius:5px}
.findings{display:flex;flex-direction:column;gap:7px;margin-top:4px}
.finding{border:1px solid var(--border);border-left-width:3px;border-radius:7px;padding:7px 9px;background:#16191c}
.finding .top{display:flex;align-items:center;gap:7px;margin-bottom:3px}
.finding .sev{font-family:var(--mono);font-size:8.5px;font-weight:700;padding:1px 5px;border-radius:4px;letter-spacing:.4px}
.finding .loc{font-family:var(--mono);font-size:10px;color:var(--muted);word-break:break-all}
.finding .txt{font-size:11.5px;line-height:1.5;color:#cfd3d8}
.sev-HIGH{background:#3a1714;color:#f0857a} .finding.sev-HIGH{border-left-color:#e0524b}
.sev-MED{background:#3a2c12;color:#e7b35e} .finding.sev-MED{border-left-color:#d9a441}
.sev-LOW{background:#23282d;color:#9aa1a8} .finding.sev-LOW{border-left-color:#525a62}
.tagchips{display:flex;flex-wrap:wrap;gap:5px;margin:0 0 13px}
.tg{font-size:9.5px;font-family:var(--mono);border-radius:5px;padding:2px 7px;background:#23282d;color:#aeb4bb;border:1px solid var(--border)}
.tg.bad{background:#2c1a17;color:#e88;border-color:#4a2420} .tg.ok{background:#1d2420;color:#8a9;border-color:#2a352e}
.report .stat-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:6px;margin:14px 0 6px}
.report .stat{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:9px 6px;text-align:center}
.report .stat .n{font-family:var(--mono);font-size:18px;font-weight:740;line-height:1}
.report .stat .l{font-size:8.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.5px;margin-top:3px}
.report h3{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:20px 0 8px}
.report .theme{font-size:12px;line-height:1.6;color:#cfd3d8;margin:0 0 9px;padding-left:11px;border-left:2px solid var(--accent-dim)}
.report .theme b{color:#fff;font-weight:600}
.grade-A{color:#76b39a;border-color:#3a5249} .grade-B{color:#b9c0c7;border-color:var(--border2)}
.grade-C{color:#d9a441;border-color:#5a4720} .grade-D{color:#e08a4a;border-color:#5a3a20} .grade-F{color:#e0524b;border-color:#5a2420}
.scrollnote{position:absolute;right:14px;bottom:12px;z-index:5;font-size:10.5px;color:var(--faint);font-family:var(--mono);pointer-events:none;background:#0e1012aa;padding:3px 8px;border-radius:6px;border:1px solid var(--border)}
.modal{position:fixed;inset:0;z-index:60;display:none;background:#0a0b0ccc;overflow:auto;padding:46px 20px}
.modal.open{display:block}
.modal .sheet{position:relative;max-width:740px;margin:0 auto;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:24px 26px 30px}
.modal .sheet h2{font-size:17px;font-weight:650;margin:0 0 3px;letter-spacing:.1px}
.modal .sheet .intro{font-size:12.5px;color:var(--faint);margin:0 0 6px;line-height:1.5}
.modal .xbtn{position:absolute;top:16px;right:16px}
.std-h4{font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--faint);margin:22px 0 4px}
.std-row{display:flex;gap:13px;align-items:baseline;padding:8px 0;border-top:1px solid var(--border)}
.std-badge{flex:0 0 56px;font-family:var(--mono);font-weight:700;font-size:12px;text-align:center;border-radius:6px;padding:3px 0;color:#0c0d0e}
.std-range{flex:0 0 56px;font-family:var(--mono);font-size:11px;color:var(--muted)}
.std-key{flex:0 0 116px}
.std-row .sev{font-family:var(--mono);font-size:9px;font-weight:700;padding:2px 6px;border-radius:4px;letter-spacing:.4px}
.std-row .tg[contenteditable]{outline:none;min-width:40px}
.std-desc[contenteditable]{outline:1px dashed var(--border2);outline-offset:2px;border-radius:3px}
.xrm{flex:0 0 auto;background:none;border:none;color:var(--faint);cursor:pointer;font-size:12px;padding:0 2px;line-height:1}
.xrm:hover{color:#e0524b}
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%) translateY(8px);z-index:80;
background:var(--panel2);border:1px solid var(--border2);color:var(--ink);font-size:12.5px;
padding:9px 14px;border-radius:8px;opacity:0;pointer-events:none;transition:.18s;box-shadow:0 6px 20px #0007}
.toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
.std-desc{flex:1;font-size:12.5px;color:#cfd3d8;line-height:1.5}
::-webkit-scrollbar{width:11px;height:11px}
::-webkit-scrollbar-thumb{background:#2c3137;border-radius:6px;border:3px solid var(--bg2)}
::-webkit-scrollbar-track{background:transparent}
</style>
</head>
<body>
<div class="app">
<header>
<div class="mark"></div>
<div>
<h1 id="projTitle">Functional Architecture Map</h1>
<div class="sub" id="subLine"></div>
</div>
<div class="spacer"></div>
<div class="search">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="search" placeholder="Find a module…" autocomplete="off"/>
</div>
<select class="filt" id="gradeFilter" title="Show only modules at/below this grade">
<option value="">All grades</option>
<option value="90">≤ B (score <90)</option>
<option value="75">≤ C (<75)</option>
<option value="60">≤ D (<60)</option>
<option value="40">F (<40)</option>
</select>
<select class="filt" id="tagFilter" title="Show only modules with this issue"><option value="">Any issue</option></select>
<span class="fcount" id="fcount"></span>
<button class="btn" id="stdBtn">Standard</button>
<button class="btn" id="reportBtn">Audit report</button>
<button class="btn" id="healthBtn">Color: coupling</button>
<button class="btn" id="spineBtn">Data-flow spine</button>
</header>
<div class="board-wrap" id="boardWrap">
<div class="board" id="board">
<svg class="edges" id="edges">
<defs>
<marker id="ah-out" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--out)"/></marker>
<marker id="ah-in" markerWidth="9" markerHeight="9" refX="7" refY="4.2" orient="auto"><path d="M0,0 L8,4.2 L0,8.4 Z" fill="var(--in)"/></marker>
</defs>
</svg>
</div>
<div class="scrollnote">click a module · scroll to pan</div>
</div>
<aside id="aside"><div id="detail"></div></aside>
</div>
<div class="modal" id="stdModal"></div>
<script>
const DATA = {"meta": {"project": "Acme Storefront", "subtitle": "Sample project — a codemap demo", "lang": "en", "generatedAt": "2026-01-01", "htmlPath": ".codemap/codemap.html", "mdPath": ".codemap/codemap.md", "spineDesc": "A shopper opens a Product page → the cartStore calls the API Gateway → the orders route hands off to OrderService → which writes the Order aggregate through the Repository.", "tracked_loc": 28640, "tracked_files": 214, "locLine": "≈ 28,600 LoC · 214 files (sample)"}, "bands": [{"id": "shell", "tier": "fe", "t": "Frontend · Shell & Routing", "d": "layout, routing, navigation"}, {"id": "pages", "tier": "fe", "t": "Frontend · Pages", "d": "screens the user sees"}, {"id": "stores", "tier": "fe", "t": "Frontend · State Stores", "d": "one store per domain"}, {"id": "transport", "tier": "fe", "t": "Frontend · Transport", "d": "REST + WebSocket clients"}, {"id": "wire", "wire": true, "t": "◀ REST / WebSocket ▶"}, {"id": "api", "tier": "be", "t": "Backend · API Routes", "d": "HTTP endpoints"}, {"id": "services", "tier": "be", "t": "Backend · Services", "d": "business logic"}, {"id": "domain", "tier": "be", "t": "Backend · Domain Core", "d": "entities & value objects"}, {"id": "data", "tier": "be", "t": "Backend · Persistence", "d": "data access"}, {"id": "workers", "tier": "be", "t": "Backend · Workers & Jobs", "d": "async processing"}, {"id": "integrations", "tier": "be", "t": "Integrations", "d": "third-party adapters"}], "spine": ["product_page", "cart_store", "api_client", "api_gw", "orders_api", "order_svc", "order_model", "repo"], "reportThemes": [["Payments is the weakest area", "PaymentService and the payments routes are still sandbox stubs (fake-output / stub) — real provider integration is unfinished, yet it is already wired into checkout."], ["Checkout and Order carry the most debt", "checkout_page, cartStore and OrderService are god-components with duplicated state-machine logic; the multi-step checkout mixes UI, validation and API calls in one file."], ["Dual-format is creeping in at the order boundary", "orders routes, checkoutStore and the checkout page accept both legacy and v2 payload shapes — normalize once at the transport layer instead."], ["apiClient is mostly glue", "~50 near-identical endpoint wrappers add no value; generate them or collapse to a single typed client."]], "standard": {"rubric": [{"grade": "A", "score": 95, "range": "90–100", "en": "clean, well-scoped, idiomatic", "zh": "干净、职责单一、地道"}, {"grade": "B", "score": 82, "range": "75–89", "en": "minor issues: a documented shim, mild bloat", "zh": "小问题:有文档的兼容、轻微臃肿"}, {"grade": "C", "score": 67, "range": "60–74", "en": "notable hacks / fallbacks / bloat / duplication", "zh": "明显的 hack / 回退 / 臃肿 / 重复"}, {"grade": "D", "score": 50, "range": "40–59", "en": "significant legacy / stubs / duplication, or a protocol violation", "zh": "严重的遗留 / 占位 / 重复,或协议违规"}, {"grade": "F", "score": 25, "range": "0–39", "en": "broken, fake output, or unfinished-but-wired", "zh": "损坏、伪造输出,或未完成却已接线"}], "severities": [{"key": "HIGH", "en": "wrong / dangerous / fake, a protocol or security issue, or a genuine maintenance hazard", "zh": "错误 / 危险 / 伪造、协议或安全问题,或真正的维护地雷"}, {"key": "MED", "en": "a real smell a maintainer should fix", "zh": "维护者该修的真坏味"}, {"key": "LOW", "en": "a documented shim, a cosmetic cast, benign bloat — worth noting, not urgent", "zh": "有文档的兼容、装饰性 cast、良性臃肿 —— 值得记、不紧急"}], "coupling": [{"key": "core", "en": "system spine — central to almost everything", "zh": "系统主线 —— 几乎牵连一切"}, {"key": "high", "en": "many connections", "zh": "连接很多"}, {"key": "med", "en": "moderate", "zh": "中等"}, {"key": "low", "en": "leaf / self-contained", "zh": "叶子 / 自洽"}], "tags": [{"id": "monkeypatch", "label": "monkeypatch", "labelZh": "猴补丁", "bad": true, "en": "runtime mutation of another module / stdlib / vendor; reflection or prototype patching", "zh": "运行时改写别的模块/标准库/依赖;反射或原型补丁"}, {"id": "fallback", "label": "fallback", "labelZh": "回退兜底", "bad": true, "en": "'try the real thing, then fake/degrade'; a||b||c chains that hide which value is real", "zh": "“先试真的再退化/造假”;a||b||c 掩盖哪个是真值"}, {"id": "silent-except", "label": "silent-except", "labelZh": "静默吞错", "bad": true, "en": "swallowed errors: empty catch / except:pass / ignored return codes", "zh": "吞掉错误:空 catch / except:pass / 忽略返回码"}, {"id": "legacy", "label": "legacy", "labelZh": "遗留", "bad": true, "en": "deprecated/back-compat shims, dead-but-shipped code, parallel old+new paths", "zh": "遗留/兼容垫片、已发布的死代码、新旧并存"}, {"id": "dual-format", "label": "dual-format", "labelZh": "双格式", "bad": true, "en": "accepting two shapes for one field (snake||camel), patched through the code", "zh": "同一字段接受两种形态(snake||camel)并散落各处"}, {"id": "stub", "label": "stub", "labelZh": "占位桩", "bad": true, "en": "NotImplemented / TODO / dead buttons / demo scripts presented as real", "zh": "未实现/TODO/死按钮/演示脚本当成品"}, {"id": "fake-output", "label": "fake-output", "labelZh": "伪造输出", "bad": true, "en": "returns random/canned/hardcoded results where real work is implied", "zh": "本应真算的地方返回随机/写死结果"}, {"id": "duplication", "label": "duplication", "labelZh": "重复", "bad": true, "en": "copy-pasted logic, or an existing shared abstraction not reused", "zh": "复制粘贴,或已有共享抽象却不复用"}, {"id": "bloat", "label": "bloat", "labelZh": "臃肿", "bad": true, "en": "oversized file / function; too many responsibilities in one unit", "zh": "超大文件/函数;单元职责过多"}, {"id": "glue", "label": "glue", "labelZh": "胶水", "bad": true, "en": "valueless pass-through: rows of thin forwarders / no-op adapters", "zh": "无价值透传:成片薄包装 / 零转换适配器"}, {"id": "any-escape", "label": "any-escape", "labelZh": "类型逃逸", "bad": true, "en": "bypassing the type system: as any / @ts-ignore / dynamic / void* / reinterpret_cast / unsafe", "zh": "绕过类型系统:as any / @ts-ignore / dynamic / void* / reinterpret_cast / unsafe"}, {"id": "over-fit", "label": "over-fit", "labelZh": "过度特化", "bad": true, "en": "hardcoded to one case where a small generalization was expected", "zh": "硬编码单一情况,本应小幅泛化"}, {"id": "god-component", "label": "god-component", "labelZh": "上帝组件", "bad": true, "en": "one component/class/file doing far too much", "zh": "一个组件/类/文件做太多事"}, {"id": "placeholder", "label": "placeholder", "labelZh": "占位", "bad": true, "en": "unfinished UI / data presented as if complete", "zh": "未完成的 UI/数据当作已完成"}, {"id": "clean", "label": "clean", "labelZh": "干净", "bad": false, "en": "no material issues — well-scoped", "zh": "无实质问题 —— 职责单一"}]}, "modules": [{"id": "app_shell", "label": "App Shell", "band": "shell", "path": "src/app/AppShell.tsx", "desc": "Root component: layout, providers, top-level routing and the error boundary.", "coupling": "core", "deps": ["router", "ui_store", "auth_store"], "loc": 420, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "router", "label": "Router", "band": "shell", "path": "src/app/router.tsx", "desc": "Client-side route table and auth guards.", "coupling": "high", "deps": [], "loc": 180, "score": 90, "grade": "A", "tags": [], "findings": []}, {"id": "nav", "label": "Navigation", "band": "shell", "path": "src/app/Nav.tsx", "desc": "Top nav, sidebar and breadcrumbs.", "coupling": "low", "deps": ["router", "auth_store"], "loc": 240, "score": 88, "grade": "B", "tags": [], "findings": []}, {"id": "catalog_page", "label": "Catalog", "band": "pages", "path": "src/pages/Catalog.tsx", "desc": "Product listing with filters, sorting and pagination.", "coupling": "med", "deps": ["catalog_store", "search_store"], "loc": 980, "score": 78, "grade": "B", "tags": [], "findings": []}, {"id": "product_page", "label": "Product", "band": "pages", "path": "src/pages/Product.tsx", "desc": "Product detail: gallery, variant picker, reviews, add-to-cart.", "coupling": "med", "deps": ["catalog_store", "cart_store"], "loc": 1240, "score": 72, "grade": "C", "tags": ["bloat"], "findings": [{"sev": "MED", "loc": "src/pages/Product.tsx", "text": "1240-line component: gallery, variant picker and reviews in one file."}]}, {"id": "cart_page", "label": "Cart", "band": "pages", "path": "src/pages/Cart.tsx", "desc": "Cart view, line-item editing and promo codes.", "coupling": "med", "deps": ["cart_store", "pricing_client"], "loc": 1420, "score": 70, "grade": "C", "tags": ["god-component"], "findings": []}, {"id": "checkout_page", "label": "Checkout", "band": "pages", "path": "src/pages/Checkout.tsx", "desc": "Multi-step checkout: address, shipping, payment, review.", "coupling": "high", "deps": ["cart_store", "auth_store", "payments_client"], "loc": 1860, "score": 58, "grade": "D", "tags": ["god-component", "dual-format", "fallback"], "findings": [{"sev": "HIGH", "loc": "src/pages/Checkout.tsx", "text": "1860-line god-component mixing the address/shipping/payment steps, validation and direct API calls."}, {"sev": "MED", "loc": "src/pages/Checkout.tsx:412", "text": "reads both `postal_code` and `postalCode` from the address form (dual-format)."}, {"sev": "LOW", "loc": "src/pages/Checkout.tsx:980", "text": "silent catch around the shipping-rate fetch falls back to a flat rate."}]}, {"id": "account_page", "label": "Account", "band": "pages", "path": "src/pages/Account.tsx", "desc": "Profile, order history and saved addresses.", "coupling": "low", "deps": ["auth_store"], "loc": 760, "score": 82, "grade": "B", "tags": [], "findings": []}, {"id": "admin_page", "label": "Admin", "band": "pages", "path": "src/pages/Admin.tsx", "desc": "Internal dashboard: orders, inventory and reports.", "coupling": "med", "deps": ["admin_client"], "loc": 2100, "score": 66, "grade": "C", "tags": ["bloat", "any-escape"], "findings": [{"sev": "MED", "loc": "src/pages/Admin.tsx", "text": "2100-line page: reports, tables and editors all in one file."}, {"sev": "LOW", "loc": "src/pages/Admin.tsx:300", "text": "several `as any` casts around the chart library."}]}, {"id": "search_page", "label": "Search", "band": "pages", "path": "src/pages/Search.tsx", "desc": "Search results with facets.", "coupling": "low", "deps": ["search_store"], "loc": 540, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "cart_store", "label": "cartStore", "band": "stores", "path": "src/stores/cart.ts", "desc": "Cart line items, totals and promo state.", "coupling": "high", "deps": ["api_client"], "loc": 360, "score": 74, "grade": "C", "tags": ["duplication"], "findings": [{"sev": "MED", "loc": "src/stores/cart.ts:90", "text": "cart totals re-implemented here and in PricingEngine (duplication)."}]}, {"id": "auth_store", "label": "authStore", "band": "stores", "path": "src/stores/auth.ts", "desc": "Session, tokens and the current user.", "coupling": "core", "deps": ["api_client"], "loc": 290, "score": 80, "grade": "B", "tags": [], "findings": []}, {"id": "catalog_store", "label": "catalogStore", "band": "stores", "path": "src/stores/catalog.ts", "desc": "Products, categories and cached pages.", "coupling": "high", "deps": ["api_client"], "loc": 410, "score": 86, "grade": "B", "tags": [], "findings": []}, {"id": "search_store", "label": "searchStore", "band": "stores", "path": "src/stores/search.ts", "desc": "Query, facets and results.", "coupling": "med", "deps": ["api_client"], "loc": 220, "score": 82, "grade": "B", "tags": [], "findings": []}, {"id": "ui_store", "label": "uiStore", "band": "stores", "path": "src/stores/ui.ts", "desc": "Modals, toasts and theme.", "coupling": "low", "deps": [], "loc": 150, "score": 88, "grade": "B", "tags": [], "findings": []}, {"id": "checkout_store", "label": "checkoutStore", "band": "stores", "path": "src/stores/checkout.ts", "desc": "Checkout step state and form drafts.", "coupling": "med", "deps": ["cart_store"], "loc": 480, "score": 62, "grade": "C", "tags": ["dual-format", "legacy"], "findings": [{"sev": "MED", "loc": "src/stores/checkout.ts:40", "text": "reads both snake_case and camelCase address fields (dual-format)."}, {"sev": "LOW", "loc": "src/stores/checkout.ts:8", "text": "legacy single-step draft kept for old links."}]}, {"id": "api_client", "label": "apiClient", "band": "transport", "path": "src/transport/apiClient.ts", "desc": "REST client — ~50 thin endpoint wrappers plus auth and retry.", "coupling": "core", "deps": ["api_gw"], "loc": 690, "score": 68, "grade": "C", "tags": ["glue", "bloat"], "findings": [{"sev": "MED", "loc": "src/transport/apiClient.ts", "text": "~50 one-line get/post wrappers that only forward args (glue) — generate or collapse to a typed client."}, {"sev": "LOW", "loc": "src/transport/apiClient.ts:1", "text": "one 690-line file mixing transport with the whole endpoint surface."}]}, {"id": "ws_client", "label": "wsClient", "band": "transport", "path": "src/transport/wsClient.ts", "desc": "WebSocket for live order and stock updates.", "coupling": "med", "deps": ["api_gw"], "loc": 230, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "payments_client", "label": "paymentsClient", "band": "transport", "path": "src/transport/payments.ts", "desc": "Thin bridge to the payments API.", "coupling": "med", "deps": ["stripe_gw"], "loc": 140, "score": 86, "grade": "B", "tags": [], "findings": []}, {"id": "admin_client", "label": "adminClient", "band": "transport", "path": "src/transport/admin.ts", "desc": "Admin-only API client.", "coupling": "low", "deps": ["api_gw"], "loc": 180, "score": 80, "grade": "B", "tags": [], "findings": []}, {"id": "pricing_client", "label": "pricingClient", "band": "transport", "path": "src/transport/pricing.ts", "desc": "Live price/quote client.", "coupling": "low", "deps": ["api_gw"], "loc": 110, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "api_gw", "label": "API Gateway", "band": "api", "path": "api/app.py", "desc": "HTTP app: routing, middleware, auth and the request lifecycle.", "coupling": "core", "deps": ["auth_api", "products_api", "orders_api", "payments_api", "search_api"], "loc": 540, "score": 88, "grade": "B", "tags": [], "findings": []}, {"id": "auth_api", "label": "auth routes", "band": "api", "path": "api/auth.py", "desc": "Login, signup, token refresh and OAuth.", "coupling": "high", "deps": ["auth_svc"], "loc": 420, "score": 78, "grade": "B", "tags": ["silent-except"], "findings": [{"sev": "LOW", "loc": "api/auth.py:140", "text": "broad except around the OAuth token exchange logs but swallows the cause."}]}, {"id": "products_api", "label": "products routes", "band": "api", "path": "api/products.py", "desc": "Product and category CRUD + listing.", "coupling": "med", "deps": ["catalog_svc"], "loc": 360, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "orders_api", "label": "orders routes", "band": "api", "path": "api/orders.py", "desc": "Cart, order placement and status.", "coupling": "high", "deps": ["order_svc"], "loc": 610, "score": 74, "grade": "C", "tags": ["dual-format"], "findings": [{"sev": "MED", "loc": "api/orders.py:88", "text": "accepts both the legacy and v2 cart payload shapes (dual-format)."}]}, {"id": "payments_api", "label": "payments routes", "band": "api", "path": "api/payments.py", "desc": "Charge, refund and webhooks (provider integration WIP).", "coupling": "high", "deps": ["payment_svc"], "loc": 480, "score": 55, "grade": "D", "tags": ["stub", "fallback"], "findings": [{"sev": "HIGH", "loc": "api/payments.py:44", "text": "webhook handler always returns 200 without verifying the signature (stub)."}, {"sev": "MED", "loc": "api/payments.py:70", "text": "falls back to marking the order paid when the provider call times out."}]}, {"id": "search_api", "label": "search routes", "band": "api", "path": "api/search.py", "desc": "Query and indexing endpoints.", "coupling": "low", "deps": ["search_svc"], "loc": 240, "score": 82, "grade": "B", "tags": [], "findings": []}, {"id": "auth_svc", "label": "AuthService", "band": "services", "path": "services/auth.py", "desc": "Credentials, sessions and password hashing.", "coupling": "high", "deps": ["user_model", "token_util"], "loc": 520, "score": 80, "grade": "B", "tags": [], "findings": []}, {"id": "order_svc", "label": "OrderService", "band": "services", "path": "services/order.py", "desc": "Order placement, the state machine and fulfillment.", "coupling": "core", "deps": ["order_model", "inventory_svc", "pricing_svc", "payment_svc"], "loc": 1480, "score": 64, "grade": "C", "tags": ["duplication", "bloat"], "findings": [{"sev": "HIGH", "loc": "services/order.py", "text": "1480-line service; the order state machine is duplicated between place() and fulfill()."}, {"sev": "MED", "loc": "services/order.py:620", "text": "inventory reservation logic copy-pasted from InventoryService."}]}, {"id": "pricing_svc", "label": "PricingEngine", "band": "services", "path": "services/pricing.py", "desc": "Prices, taxes, discounts and promotions.", "coupling": "high", "deps": ["product_model"], "loc": 880, "score": 70, "grade": "C", "tags": ["over-fit"], "findings": [{"sev": "MED", "loc": "services/pricing.py:120", "text": "discount rules hardcoded to the current promo set (over-fit)."}]}, {"id": "inventory_svc", "label": "InventoryService", "band": "services", "path": "services/inventory.py", "desc": "Stock levels and reservations.", "coupling": "med", "deps": ["product_model", "repo"], "loc": 540, "score": 78, "grade": "B", "tags": [], "findings": []}, {"id": "payment_svc", "label": "PaymentService", "band": "services", "path": "services/payment.py", "desc": "Charges/refunds — currently a sandbox stub returning canned results.", "coupling": "high", "deps": ["stripe_gw"], "loc": 260, "score": 48, "grade": "D", "tags": ["stub", "fake-output"], "findings": [{"sev": "HIGH", "loc": "services/payment.py:31", "text": "charge()/refund() return a canned `{status:'succeeded'}` — sandbox stub, no real gateway call."}, {"sev": "MED", "loc": "services/payment.py:88", "text": "'TODO: wire the real provider before launch.'"}]}, {"id": "catalog_svc", "label": "CatalogService", "band": "services", "path": "services/catalog.py", "desc": "Product/category reads with caching.", "coupling": "med", "deps": ["product_model", "repo"], "loc": 430, "score": 86, "grade": "B", "tags": [], "findings": []}, {"id": "search_svc", "label": "SearchService", "band": "services", "path": "services/search.py", "desc": "Index and query; falls back to SQL LIKE when the search cluster is down.", "coupling": "med", "deps": ["repo"], "loc": 470, "score": 76, "grade": "B", "tags": ["fallback"], "findings": [{"sev": "LOW", "loc": "services/search.py:80", "text": "documented fallback to SQL LIKE when Elasticsearch is unreachable."}]}, {"id": "notif_svc", "label": "NotificationService", "band": "services", "path": "services/notify.py", "desc": "Email/SMS/push fan-out.", "coupling": "low", "deps": ["email_worker"], "loc": 300, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "order_model", "label": "Order", "band": "domain", "path": "domain/order.py", "desc": "Order aggregate: items, totals and status.", "coupling": "core", "deps": ["product_model", "user_model"], "loc": 380, "score": 88, "grade": "B", "tags": [], "findings": []}, {"id": "product_model", "label": "Product", "band": "domain", "path": "domain/product.py", "desc": "Product, variant and category entities.", "coupling": "high", "deps": [], "loc": 260, "score": 90, "grade": "A", "tags": [], "findings": []}, {"id": "user_model", "label": "User", "band": "domain", "path": "domain/user.py", "desc": "User, address and role entities.", "coupling": "high", "deps": [], "loc": 210, "score": 88, "grade": "B", "tags": [], "findings": []}, {"id": "money_util", "label": "Money", "band": "domain", "path": "domain/money.py", "desc": "Currency-safe money arithmetic.", "coupling": "med", "deps": [], "loc": 120, "score": 92, "grade": "A", "tags": [], "findings": []}, {"id": "token_util", "label": "TokenUtil", "band": "domain", "path": "domain/token.py", "desc": "JWT sign/verify helpers.", "coupling": "med", "deps": [], "loc": 90, "score": 86, "grade": "B", "tags": [], "findings": []}, {"id": "repo", "label": "Repository", "band": "data", "path": "data/repo.py", "desc": "Data-access layer over the database.", "coupling": "core", "deps": ["db", "migrations"], "loc": 640, "score": 72, "grade": "C", "tags": ["duplication"], "findings": [{"sev": "MED", "loc": "data/repo.py", "text": "per-entity CRUD copy-pasted across 9 repositories — extract a base."}]}, {"id": "db", "label": "DB Pool", "band": "data", "path": "data/db.py", "desc": "Connection pool and query helpers.", "coupling": "high", "deps": [], "loc": 180, "score": 90, "grade": "A", "tags": [], "findings": []}, {"id": "migrations", "label": "Migrations", "band": "data", "path": "data/migrations", "desc": "Schema migrations.", "coupling": "low", "deps": [], "loc": 220, "score": 85, "grade": "B", "tags": [], "findings": []}, {"id": "email_worker", "label": "EmailWorker", "band": "workers", "path": "workers/email.py", "desc": "Async email queue consumer.", "coupling": "low", "deps": ["notif_tmpl"], "loc": 260, "score": 80, "grade": "B", "tags": [], "findings": []}, {"id": "webhook_dispatcher", "label": "WebhookDispatcher", "band": "workers", "path": "workers/webhooks.py", "desc": "Outbound webhooks with retry.", "coupling": "med", "deps": [], "loc": 340, "score": 68, "grade": "C", "tags": ["silent-except", "legacy"], "findings": [{"sev": "MED", "loc": "workers/webhooks.py:55", "text": "`except: pass` swallows delivery errors — failed webhooks vanish."}, {"sev": "LOW", "loc": "workers/webhooks.py:12", "text": "legacy v1 payload path kept alongside v2."}]}, {"id": "notif_tmpl", "label": "Templates", "band": "workers", "path": "workers/templates.py", "desc": "Email/notification templates.", "coupling": "low", "deps": [], "loc": 150, "score": 84, "grade": "B", "tags": [], "findings": []}, {"id": "stripe_gw", "label": "Stripe Gateway", "band": "integrations", "path": "integrations/stripe.py", "desc": "Adapter to the Stripe payments API.", "coupling": "med", "deps": [], "loc": 280, "score": 78, "grade": "B", "tags": [], "findings": []}, {"id": "shipping_gw", "label": "Shipping Provider", "band": "integrations", "path": "integrations/shipping.py", "desc": "Adapter to a shipping rate/label API.", "coupling": "low", "deps": [], "loc": 230, "score": 74, "grade": "C", "tags": ["glue"], "findings": [{"sev": "LOW", "loc": "integrations/shipping.py", "text": "adapter forwards every field unchanged (glue)."}]}, {"id": "analytics_sink", "label": "Analytics", "band": "integrations", "path": "integrations/analytics.py", "desc": "Event sink to the analytics pipeline.", "coupling": "low", "deps": [], "loc": 190, "score": 70, "grade": "C", "tags": ["silent-except"], "findings": [{"sev": "LOW", "loc": "integrations/analytics.py:22", "text": "fire-and-forget send swallows failures silently."}]}]};
const M = DATA.modules || [];
const BANDS = DATA.bands || [];
const SPINE = DATA.spine || [];
const REPORT_THEMES = DATA.reportThemes || [];
const META = DATA.meta || {};
/* HTML-escape every string that comes from modules.json (labels, descriptions,
findings, tags, paths, meta, themes, standard) before it enters innerHTML. The only
trusted HTML is the template's own i18n strings (tl) and the structural markup. */
function esc(s){ return String(s==null?"":s).replace(/[&<>"']/g, c=>(
{"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
let BAD_TAGS = new Set(["monkeypatch","fallback","legacy","dual-format","stub","fake-output","bloat","duplication","glue","silent-except","silent-catch","any-escape","over-fit","god-component","placeholder"]);
/* ---------- i18n: display language via meta.lang (module names never translated) ---------- */
const LANG = META.lang || "en";
const I18N = {
en:{ mapSuffix:"· Functional Architecture Map", sub:"functional modules · call hierarchy · coupling · quality score · LoC",
report:"report", searchPh:"Find a module…", allGrades:"All grades", anyIssue:"Any issue", scrollNote:"click a module · scroll to pan",
about:"What it does", audit:"Quality audit", clean:"no material issues — clean / well-scoped",
dependsOn:"Depends on / calls →", usedBy:"← Used by (dependents)", none:"— none —", clearSel:"Clear selection",
couplingLbl:"coupling", deps:"dependencies", dependents:"dependents", locUnit:"LoC", coreModules:"Core modules (the spine)",
lgCoupling:"Coupling", lgCore:"core — system spine", lgHigh:"high — many connections", lgMed:"medium", lgLow:"low — leaf / self-contained",
lgEdges:"Edges", lgOut:"depends on / calls →", lgIn:"← used by (dependent)",
introHint:"<b>Click any module</b> to see what it does, its quality score & findings, what it calls (solid amber, downstream) and what depends on it (dashed gray, upstream).<br><br>Each card shows a <b>health score</b> (0–100). Toggle the color mode, filter by grade/issue, or open the <b>Audit report</b>.",
criticalPath:"Critical path", spineTitle:"Data-flow spine", path:"Path", healthReport:"Health report", qa:"Quality audit",
worst:"Worst offenders — click to inspect", commonTags:"Most-common smell tags", themes:"Cross-cutting themes", backToMap:"← Back to map", avg:"avg",
filter:"Filter", modulesWord:"modules", allWord:"all", noMatches:"no matches", clearFilters:"Clear filters",
btnReport:"Audit report", btnSpine:"Data-flow spine", colorCoupling:"Color: coupling", colorHealth:"Color: health",
btnStd:"Standard", stdTitle:"Audit standard", stdIntro:"How modules are scored — the same rubric for every module, language, and run. Click Edit to add your own issue tags.",
stdRubric:"Score → grade", stdSeverity:"Finding severity", stdTags:"Issue tags", stdCoupling:"Coupling (structural, not quality)", close:"Close",
stdEditBtn:"Edit", stdDone:"Done", stdExport:"Export", stdReset:"Reset", stdAddTag:"+ Tag",
stdEditHint:"Editing — changes save in this browser. Export → save as <code>.codemap/standard.json</code> to make them permanent and used by audits.",
stdNewTagId:"New tag id (e.g. perf-risk, security):", stdExported:"Saved standard.json — put it in .codemap/",
copyFix:"Copy fix prompt", copied:"Copied — paste into Claude Code / Codex",
trackedLoc:(l,f)=>`${l} tracked LoC · ${f} files` },
zh:{ mapSuffix:"· 功能架构图", sub:"功能模块 · 调用层级 · 耦合 · 质量评分 · 代码行数",
report:"报告", searchPh:"查找模块…", allGrades:"全部等级", anyIssue:"全部问题", scrollNote:"点击模块 · 滚动平移",
about:"模块简介", audit:"质量审计", clean:"无实质问题 —— 干净、职责单一",
dependsOn:"依赖 / 调用 →", usedBy:"← 被谁依赖", none:"— 无 —", clearSel:"清除选择",
couplingLbl:"耦合", deps:"个依赖", dependents:"个被依赖", locUnit:"行", coreModules:"核心模块(主线)",
lgCoupling:"耦合", lgCore:"core — 系统主线枢纽", lgHigh:"high — 连接很多", lgMed:"medium — 中等", lgLow:"low — 叶子 / 自洽",
lgEdges:"连线", lgOut:"依赖 / 调用 →", lgIn:"← 被依赖",
introHint:"<b>点击任意模块</b>查看它在做什么、质量评分与问题、它调用了谁(实线琥珀=下游)以及谁依赖它(虚线灰=上游)。<br><br>每张卡片都有<b>健康分</b>(0–100)。可切换配色模式、按等级或问题筛选,或打开<b>审计报告</b>。",
criticalPath:"关键路径", spineTitle:"数据流主线", path:"路径", healthReport:"健康报告", qa:"质量审计",
worst:"最差模块 —— 点击查看", commonTags:"最常见问题标签", themes:"跨模块共性问题", backToMap:"← 返回地图", avg:"平均",
filter:"筛选", modulesWord:"个模块", allWord:"全部", noMatches:"无匹配", clearFilters:"清除筛选",
btnReport:"审计报告", btnSpine:"数据流主线", colorCoupling:"配色:耦合", colorHealth:"配色:健康度",
btnStd:"评判标准", stdTitle:"评判标准", stdIntro:"模块如何打分 —— 所有模块、所有语言、每一次运行都用同一套标准。点「编辑」可加入你自己的问题标签。",
stdRubric:"分数 → 等级", stdSeverity:"问题严重度", stdTags:"问题标签", stdCoupling:"耦合(结构维度,非质量)", close:"关闭",
stdEditBtn:"编辑", stdDone:"完成", stdExport:"导出", stdReset:"重置", stdAddTag:"+ 标签",
stdEditHint:"编辑中 —— 改动保存在本浏览器。导出 → 存为 <code>.codemap/standard.json</code> 即永久生效并被审计采用。",
stdNewTagId:"新标签 id(如 perf-risk、security):", stdExported:"已生成 standard.json —— 放到 .codemap/ 下",
copyFix:"复制修复指令", copied:"已复制 —— 粘贴到 Claude Code / Codex",
trackedLoc:(l,f)=>`${l} 行(已跟踪)· ${f} 个文件` },
};
function tl(k){ const d=I18N[LANG]||I18N.en; return d[k]!=null?d[k]:(I18N.en[k]!=null?I18N.en[k]:k); }
document.documentElement.lang = LANG;
function stdText(o){ return o ? (LANG==="zh" ? (o.zh||o.en||"") : (o.en||o.zh||"")) : ""; }
/* ---------- the audit standard — editable, configurable, its own page ----------
Effective standard = a saved browser draft (user edits), else the injected file
(DATA.standard, from .codemap/standard.json or the skill default), else
this built-in fallback. Edit it on the Standard page and Export to standard.json
to make it permanent and used by audits. Custom tags flow through the whole map. */
const BUILTIN_STD = {
rubric:[
{grade:"A",score:95,range:"90–100",en:"clean, well-scoped, idiomatic",zh:"干净、职责单一、地道"},
{grade:"B",score:82,range:"75–89",en:"minor issues: a documented shim, mild bloat",zh:"小问题:有文档的兼容、轻微臃肿"},
{grade:"C",score:67,range:"60–74",en:"notable hacks / fallbacks / bloat / duplication",zh:"明显的 hack / 回退 / 臃肿 / 重复"},
{grade:"D",score:50,range:"40–59",en:"significant legacy / stubs / duplication, or a protocol violation",zh:"严重的遗留 / 占位 / 重复,或协议违规"},
{grade:"F",score:25,range:"0–39",en:"broken, fake output, or unfinished-but-wired",zh:"损坏、伪造输出,或未完成却已接线"},
],
severities:[
{key:"HIGH",en:"wrong / dangerous / fake, a protocol or security issue, or a genuine maintenance hazard",zh:"错误 / 危险 / 伪造、协议或安全问题,或真正的维护地雷"},
{key:"MED",en:"a real smell a maintainer should fix",zh:"维护者该修的真坏味"},
{key:"LOW",en:"a documented shim, a cosmetic cast, benign bloat — worth noting, not urgent",zh:"有文档的兼容、装饰性 cast、良性臃肿 —— 值得记、不紧急"},
],
coupling:[
{key:"core",en:"system spine — central to almost everything",zh:"系统主线 —— 几乎牵连一切"},
{key:"high",en:"many connections",zh:"连接很多"},
{key:"med",en:"moderate",zh:"中等"},
{key:"low",en:"leaf / self-contained",zh:"叶子 / 自洽"},
],
tags:[
{id:"monkeypatch",label:"monkeypatch",labelZh:"猴补丁",bad:true,en:"runtime mutation of another module / stdlib / vendor; reflection or prototype patching",zh:"运行时改写别的模块/标准库/依赖;反射或原型补丁"},
{id:"fallback",label:"fallback",labelZh:"回退兜底",bad:true,en:"'try the real thing, then fake/degrade'; a||b||c chains that hide which value is real",zh:"“先试真的再退化/造假”;a||b||c 掩盖哪个是真值"},
{id:"silent-except",label:"silent-except",labelZh:"静默吞错",bad:true,en:"swallowed errors: empty catch / except:pass / ignored return codes",zh:"吞掉错误:空 catch / except:pass / 忽略返回码"},
{id:"legacy",label:"legacy",labelZh:"遗留",bad:true,en:"deprecated/back-compat shims, dead-but-shipped code, parallel old+new paths",zh:"遗留/兼容垫片、已发布的死代码、新旧并存"},
{id:"dual-format",label:"dual-format",labelZh:"双格式",bad:true,en:"accepting two shapes for one field (snake||camel), patched through the code",zh:"同一字段接受两种形态(snake||camel)并散落各处"},
{id:"stub",label:"stub",labelZh:"占位桩",bad:true,en:"NotImplemented / TODO / dead buttons / demo scripts presented as real",zh:"未实现/TODO/死按钮/演示脚本当成品"},
{id:"fake-output",label:"fake-output",labelZh:"伪造输出",bad:true,en:"returns random/canned/hardcoded results where real work is implied",zh:"本应真算的地方返回随机/写死结果"},
{id:"duplication",label:"duplication",labelZh:"重复",bad:true,en:"copy-pasted logic, or an existing shared abstraction not reused",zh:"复制粘贴,或已有共享抽象却不复用"},
{id:"bloat",label:"bloat",labelZh:"臃肿",bad:true,en:"oversized file / function; too many responsibilities in one unit",zh:"超大文件/函数;单元职责过多"},
{id:"glue",label:"glue",labelZh:"胶水",bad:true,en:"valueless pass-through: rows of thin forwarders / no-op adapters",zh:"无价值透传:成片薄包装 / 零转换适配器"},
{id:"any-escape",label:"any-escape",labelZh:"类型逃逸",bad:true,en:"bypassing the type system: as any / @ts-ignore / dynamic / void* / reinterpret_cast / unsafe",zh:"绕过类型系统:as any / @ts-ignore / dynamic / void* / reinterpret_cast / unsafe"},
{id:"over-fit",label:"over-fit",labelZh:"过度特化",bad:true,en:"hardcoded to one case where a small generalization was expected",zh:"硬编码单一情况,本应小幅泛化"},
{id:"god-component",label:"god-component",labelZh:"上帝组件",bad:true,en:"one component/class/file doing far too much",zh:"一个组件/类/文件做太多事"},
{id:"placeholder",label:"placeholder",labelZh:"占位",bad:true,en:"unfinished UI / data presented as if complete",zh:"未完成的 UI/数据当作已完成"},
{id:"clean",label:"clean",labelZh:"干净",bad:false,en:"no material issues — well-scoped",zh:"无实质问题 —— 职责单一"},
],
};
const STD_KEY = "codemap.standard."+(META.project||"default");
function loadStdDraft(){ try{ const s=localStorage.getItem(STD_KEY); return s?JSON.parse(s):null; }catch(e){ return null; } }
let STDDATA = loadStdDraft() || JSON.parse(JSON.stringify(DATA.standard || BUILTIN_STD));
const TAGMETA = {};
function rebuildTagMeta(){
for(const k in TAGMETA) delete TAGMETA[k];
(STDDATA.tags||[]).forEach(t=>TAGMETA[t.id]=t);
BAD_TAGS = new Set((STDDATA.tags||[]).filter(t=>t.bad!==false).map(t=>t.id));
}
function tagMeta(id){ return TAGMETA[id] || {id,label:id,bad:true}; }
function tagLabel(t){ const m=tagMeta(t); return LANG==="zh" ? (m.labelZh||m.label||t) : (m.label||t); }
rebuildTagMeta();
/* problems POP (saturated red→amber), good RECEDES (pale low-sat green).
The cue is saturation/lightness, not hue — colorblind-friendlier. */
function healthColor(s){
if(s==null) return "#6b7280";
const stops=[[0,[224,78,74]],[42,[226,128,66]],[60,[222,172,52]],
[74,[214,180,72]],[82,[168,180,140]],[100,[150,178,150]]];
s=Math.max(0,Math.min(100,s));
let a=stops[0], b=stops[stops.length-1];
for(let i=0;i<stops.length-1;i++){ if(s>=stops[i][0] && s<=stops[i+1][0]){a=stops[i];b=stops[i+1];break;} }
const t=(s-a[0])/((b[0]-a[0])||1);
const c=a[1].map((v,k)=>Math.round(v+(b[1][k]-v)*t));
return `rgb(${c[0]},${c[1]},${c[2]})`;
}
const byId = Object.fromEntries(M.map(m=>[m.id,m]));
const dependentsOf = id => M.filter(m=>(m.deps||[]).includes(id)).map(m=>m.id);
const board=document.getElementById("board");
const svg=document.getElementById("edges");
const detail=document.getElementById("detail");
const cardEl={};
document.getElementById("projTitle").innerHTML = esc(META.project||"Project")+` <span>${tl('mapSuffix')}</span>`;
document.getElementById("subLine").innerHTML = esc(META.subtitle||tl('sub'))+
(META.mdPath?` · <a href="${esc(META.mdPath.split('/').pop())}" style="color:var(--accent);text-decoration:none">${tl('report')} ↗</a>`:"");
/* localize static header chrome (module names/labels are never translated) */
document.querySelector(".scrollnote").textContent = tl("scrollNote");
document.getElementById("search").placeholder = tl("searchPh");
document.getElementById("reportBtn").textContent = tl("btnReport");
document.getElementById("spineBtn").textContent = tl("btnSpine");
document.getElementById("stdBtn").textContent = tl("btnStd");
document.getElementById("gradeFilter").options[0].textContent = tl("allGrades");
document.getElementById("tagFilter").options[0].textContent = tl("anyIssue");
BANDS.forEach(b=>{
if(b.wire){
const w=document.createElement("div"); w.className="wire";
w.innerHTML=`<div class="line"></div><div class="lbl">${esc(b.t||"boundary")}</div><div class="line"></div>`;
board.appendChild(w); return;
}
const band=document.createElement("div"); band.className=`band tier-${b.tier||""}`;
const items=M.filter(m=>m.band===b.id);
const gloc=items.reduce((a,m)=>a+(m.loc||0),0);
const head=document.createElement("div"); head.className="band-head";
head.innerHTML=`<span class="t">${esc(b.t||b.id)}</span><span class="d">${esc(b.d||"")}</span>`+
`<span class="num">${items.length}</span><span class="num gloc">${gloc.toLocaleString()} ${tl('locUnit')}</span>`;
band.appendChild(head);
const body=document.createElement("div"); body.className="band-body";
items.forEach(m=>{
const n=document.createElement("div");
n.className=`node c-${m.coupling||"low"}`; n.dataset.id=m.id;
if(m.score!=null) n.style.setProperty("--h",healthColor(m.score));
const chip=m.score!=null?`<span class="chip">${m.score}</span>`:"";
const locStr=m.loc!=null?` · ${Number(m.loc).toLocaleString()} ${tl('locUnit')}`:"";
n.innerHTML=`${chip}<div class="lab">${esc(m.label)}</div><div class="meta">${esc(m.grade||"–")} · ${(m.deps||[]).length}→ ${dependentsOf(m.id).length}←${locStr}</div>`;
n.addEventListener("click",e=>{e.stopPropagation();select(m.id)});
body.appendChild(n); cardEl[m.id]=n;
});
band.appendChild(body); board.appendChild(band);
});
function center(el){const r=el.getBoundingClientRect(), br=board.getBoundingClientRect();
return {x:r.left-br.left+r.width/2, y:r.top-br.top+r.height/2, w:r.width, h:r.height};}
function clip(c,from){const dx=from.x-c.x, dy=from.y-c.y, hw=c.w/2+2, hh=c.h/2+2;
if(dx===0&&dy===0) return c;
const s=Math.min(hw/Math.abs(dx||1e-6), hh/Math.abs(dy||1e-6));
return {x:c.x+dx*s, y:c.y+dy*s};}
function edge(aEl,bEl,kind){
const ca=center(aEl), cb=center(bEl), a=clip(ca,cb), b=clip(cb,ca);
const dy=b.y-a.y, k=Math.min(Math.abs(dy)*0.4+30,150);
const p=document.createElementNS("http://www.w3.org/2000/svg","path");
p.setAttribute("d",`M${a.x},${a.y} C${a.x},${a.y+(dy>=0?k:-k)} ${b.x},${b.y-(dy>=0?k:-k)} ${b.x},${b.y}`);
p.setAttribute("fill","none");
p.setAttribute("stroke",kind==="out"?"var(--out)":"var(--in)");
p.setAttribute("stroke-width",kind==="out"?"1.9":"1.5");
p.setAttribute("stroke-opacity",kind==="out"?"0.95":"0.6");
if(kind==="in")p.setAttribute("stroke-dasharray","4 3");
p.setAttribute("marker-end",kind==="out"?"url(#ah-out)":"url(#ah-in)");
svg.appendChild(p);
}
function clearEdges(){[...svg.querySelectorAll("path")].forEach(p=>p.remove());}
let current=null, spineOn=false;
function resetClasses(){board.classList.remove("has-sel");
Object.values(cardEl).forEach(n=>n.classList.remove("sel","dep","dependent","lit"));}
function select(id){
if(current===id){clearSel();return;}
current=id; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses();
const m=byId[id]; const outs=(m.deps||[]); const ins=dependentsOf(id);
board.classList.add("has-sel"); cardEl[id].classList.add("sel","lit");
outs.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dep","lit"),edge(cardEl[id],cardEl[d],"out"));});
ins.forEach(d=>{cardEl[d]&&(cardEl[d].classList.add("dependent","lit"),edge(cardEl[d],cardEl[id],"in"));});
renderDetail(m,outs,ins);
cardEl[id].scrollIntoView({block:"nearest",inline:"nearest",behavior:"smooth"});
}
function clearSel(){current=null; clearEdges(); resetClasses(); renderIntro();}
function showSpine(){
current=null; clearEdges(); resetClasses(); board.classList.add("has-sel");
spineOn=true; document.getElementById("spineBtn").classList.add("active");
SPINE.forEach(id=>cardEl[id]&&cardEl[id].classList.add("lit"));
for(let i=0;i<SPINE.length-1;i++){const a=cardEl[SPINE[i]],b=cardEl[SPINE[i+1]];
if(a&&b){a.classList.add("sel");b.classList.add("sel");edge(a,b,"out");}}
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('criticalPath')}</div><h2>${tl('spineTitle')}</h2>
<p class="desc">${esc(META.spineDesc||"The system's critical request path, end to end.")}</p>
<div class="reltitle">${tl('path')}</div>
<div class="rel">${SPINE.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].coupling)}</span></button>`:"").join("")}</div>
${legendHTML()}</div>`;
bindGo();
}
function auditHTML(m){
if(m.score==null) return "";
const col=healthColor(m.score);
const tags=(m.tags||[]).map(t=>`<span class="tg ${t==='clean'?'ok':(BAD_TAGS.has(t)?'bad':'')}">${esc(tagLabel(t))}</span>`).join("");
const sevCls=s=>({HIGH:"HIGH",MED:"MED",LOW:"LOW"}[s]||"LOW");
const fnd=(m.findings||[]).length
? m.findings.map(f=>`<div class="finding sev-${sevCls(f.sev)}"><div class="top"><span class="sev sev-${sevCls(f.sev)}">${esc(f.sev)}</span><span class="loc">${esc(f.loc||"")}</span></div><div class="txt">${esc(f.text||"")}</div></div>`).join("")
: `<div class="none">${tl('clean')}</div>`;
return `<div class="reltitle" style="margin-top:6px">${tl('audit')}</div>`+`
<div class="scorebox"><span class="big" style="color:${col}">${m.score}</span>
<span class="gr grade-${esc(m.grade)}">${esc(m.grade)}</span>
<span class="bar"><i style="width:${m.score}%;background:${col}"></i></span></div>
<div class="tagchips">${tags}</div><div class="findings">${fnd}</div>`;
}
function renderDetail(m,outs,ins){
const li=arr=>arr.length?arr.map(id=>byId[id]?`<button data-go="${esc(id)}"><span>${esc(byId[id].label)}</span><span class="bnd">${esc(byId[id].band)}</span></button>`:"").join(""):`<div class="none">${tl('none')}</div>`;
detail.innerHTML=`<div class="detail"><div class="kicker">${esc(bandTitle(m.band))}</div>
<h2>${esc(m.label)}</h2><div class="path">${esc(m.path||"")}</div>
<div class="pill-row"><span class="pill cpl">${tl('couplingLbl')}: ${esc(m.coupling)}</span>
${m.loc!=null?`<span class="pill">${Number(m.loc).toLocaleString()} ${tl('locUnit')}</span>`:""}
<span class="pill">${outs.length} ${tl('deps')}</span><span class="pill">${ins.length} ${tl('dependents')}</span></div>
${m.desc?`<div class="reltitle">${tl('about')}</div><p class="desc">${esc(m.desc)}</p>`:""}
${auditHTML(m)}
<div class="reltitle"><span class="dotc" style="background:var(--out)"></span>${tl('dependsOn')}</div>
<div class="rel">${li(outs)}</div>
<div class="reltitle"><span class="dotc" style="background:var(--in)"></span>${tl('usedBy')}</div>
<div class="rel">${li(ins)}</div>
${m.score!=null?`<button class="btn clearbtn" id="copyFixBtn" style="margin-top:14px">${tl('copyFix')} ↗</button>`:""}
<button class="btn clearbtn" id="clearBtn" style="margin-top:8px">${tl('clearSel')}</button></div>`;
bindGo(); document.getElementById("clearBtn").addEventListener("click",clearSel);
const cf=document.getElementById("copyFixBtn");
if(cf) cf.onclick=()=>{ copyText("/codemap fix "+m.id); toast(tl('copied')); };
}
function bindGo(){detail.querySelectorAll("[data-go]").forEach(b=>b.addEventListener("click",()=>select(b.dataset.go)));}
function bandTitle(b){return (BANDS.find(x=>x.id===b)||{}).t||b;}
function legendHTML(){
return `<div class="legend"><h4>${tl('lgCoupling')}</h4>
<div class="row"><span class="swatch" style="background:var(--c-core)"></span>${tl('lgCore')}</div>
<div class="row"><span class="swatch" style="background:var(--c-high)"></span>${tl('lgHigh')}</div>
<div class="row"><span class="swatch" style="background:var(--c-med)"></span>${tl('lgMed')}</div>
<div class="row"><span class="swatch" style="background:var(--c-low)"></span>${tl('lgLow')}</div>
<h4 style="margin-top:18px">${tl('lgEdges')}</h4>
<div class="row"><span class="ln" style="border-color:var(--out)"></span>${tl('lgOut')}</div>
<div class="row"><span class="ln" style="border-color:var(--in);border-top-style:dashed"></span>${tl('lgIn')}</div></div>`;
}
function renderIntro(){
const cores=M.filter(m=>m.coupling==="core");
detail.innerHTML=`<div class="detail"><div class="empty-hint">${tl('introHint')}</div>
${cores.length?`<div class="reltitle" style="margin-top:22px">${tl('coreModules')}</div>
<div class="rel">${cores.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd">${esc(m.band)}</span></button>`).join("")}</div>`:""}
${legendHTML()}</div>`;
bindGo();
}
function renderReport(){
current=null; spineOn=false; clearEdges(); resetClasses();
document.getElementById("spineBtn").classList.remove("active");
const scored=M.filter(m=>m.score!=null);
if(!scored.length){renderIntro();return;}
const avg=Math.round(scored.reduce((a,m)=>a+m.score,0)/scored.length);
const gc={A:0,B:0,C:0,D:0,F:0}; scored.forEach(m=>gc[m.grade]!=null&&gc[m.grade]++);
const worst=[...scored].sort((a,b)=>a.score-b.score).slice(0,10);
const tagCount={}; scored.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))tagCount[t]=(tagCount[t]||0)+1;}));
const topTags=Object.entries(tagCount).sort((a,b)=>b[1]-a[1]).slice(0,8);
const locLine=META.locLine||(META.tracked_loc?tl('trackedLoc')(Number(META.tracked_loc).toLocaleString(),META.tracked_files||"?"):"");
detail.innerHTML=`<div class="report">
<div class="kicker" style="font-family:var(--mono);font-size:10px;color:var(--accent);letter-spacing:.5px">${tl('qa')} · ${scored.length} ${tl('modulesWord')}</div>
<h2 style="font-size:18px;margin:5px 0 2px;font-weight:650">${tl('healthReport')}</h2>
<div class="path" style="color:var(--faint);font-size:11px;margin-bottom:4px">${["monkeypatch","fallback","legacy","stub","bloat","duplication","dual-format"].map(tagLabel).join(" · ")}</div>
${locLine?`<div class="path" style="color:var(--muted);font-size:11px;margin-bottom:2px">${esc(locLine)}</div>`:""}
<div class="stat-grid">
<div class="stat"><div class="n" style="color:${healthColor(avg)}">${avg}</div><div class="l">${tl('avg')}</div></div>
<div class="stat"><div class="n grade-A">${gc.A}</div><div class="l">A</div></div>
<div class="stat"><div class="n grade-B">${gc.B}</div><div class="l">B</div></div>
<div class="stat"><div class="n grade-C">${gc.C}</div><div class="l">C</div></div>
<div class="stat"><div class="n grade-D" style="color:#e0524b">${gc.D+gc.F}</div><div class="l">D/F</div></div>
</div>
<h3>${tl('worst')}</h3>
<div class="rel">${worst.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score} · ${esc(m.grade)}</span></button>`).join("")}</div>
${topTags.length?`<h3>${tl('commonTags')}</h3><div class="tagchips">${topTags.map(([t,n])=>`<span class="tg bad">${esc(tagLabel(t))} ·${n}</span>`).join("")}</div>`:""}
${REPORT_THEMES.length?`<h3>${tl('themes')}</h3>${REPORT_THEMES.map(([h,b])=>`<p class="theme"><b>${esc(h)}.</b> ${esc(b)}</p>`).join("")}`:""}
<button class="btn clearbtn" id="toMapBtn">${tl('backToMap')}</button></div>`;
bindGo(); document.getElementById("toMapBtn").addEventListener("click",clearSel);
}
function toast(msg){
let t=document.getElementById("toast");
if(!t){ t=document.createElement("div"); t.id="toast"; t.className="toast"; document.body.appendChild(t); }
t.textContent=msg; t.classList.add("show");
clearTimeout(t._h); t._h=setTimeout(()=>t.classList.remove("show"),2400);
}
function copyText(s){
if(navigator.clipboard) { navigator.clipboard.writeText(s).catch(()=>{}); return; }
const ta=document.createElement("textarea"); ta.value=s; document.body.appendChild(ta); ta.select();
try{ document.execCommand("copy"); }catch(e){} ta.remove();
}
let STD_EDIT=false;
function openStandard(){ STD_EDIT=false; renderStd(); document.getElementById("stdModal").classList.add("open"); }
function closeStandard(){ document.getElementById("stdModal").classList.remove("open"); }
function saveStdDraft(){ try{ localStorage.setItem(STD_KEY, JSON.stringify(STDDATA)); }catch(e){} rebuildTagMeta(); }
function langKey(){ return LANG==="zh" ? "zh" : "en"; }
function renderStd(){
const ed=STD_EDIT, ce=ed?' contenteditable="true" spellcheck="false"':'';
const rubric=STDDATA.rubric.map((x,i)=>`<div class="std-row"><span class="std-badge" style="background:${healthColor(x.score)}">${esc(x.grade)}</span><span class="std-range">${esc(x.range)}</span><span class="std-desc"${ce} data-k="rubric" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
const sev=STDDATA.severities.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="sev sev-${esc(x.key)}">${esc(x.key)}</span></span><span class="std-desc"${ce} data-k="sev" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
const tags=STDDATA.tags.map((x,i)=>`<div class="std-row"><span class="std-key"><span class="tg ${x.bad===false?'ok':'bad'}"${ce} data-k="taglabel" data-i="${i}">${esc(LANG==="zh"?(x.labelZh||x.label):x.label)}</span></span><span class="std-desc"${ce} data-k="tag" data-i="${i}">${esc(stdText(x))}</span>${ed?`<button class="xrm" data-rm="${i}" title="remove">✕</button>`:''}</div>`).join("");
const coup=STDDATA.coupling.map((x,i)=>`<div class="std-row"><span class="std-key" style="font-family:var(--mono);font-size:11.5px;color:var(--ink)">${esc(x.key)}</span><span class="std-desc"${ce} data-k="coup" data-i="${i}">${esc(stdText(x))}</span></div>`).join("");
document.getElementById("stdModal").innerHTML=`<div class="sheet">
<div class="xbtn" style="display:flex;gap:6px">
<button class="btn ${ed?'active':''}" id="stdEdit">${ed?tl('stdDone'):tl('stdEditBtn')}</button>
${ed?`<button class="btn" id="stdExport">${tl('stdExport')}</button><button class="btn" id="stdReset">${tl('stdReset')}</button>`:''}
<button class="btn" id="stdClose">${tl('close')}</button>
</div>
<h2>${tl('stdTitle')}</h2><div class="intro">${ed?tl('stdEditHint'):tl('stdIntro')}</div>
<div class="std-h4">${tl('stdRubric')}</div>${rubric}
<div class="std-h4">${tl('stdSeverity')}</div>${sev}
<div class="std-h4">${tl('stdTags')}${ed?` <button class="btn" id="stdAddTag" style="margin-left:8px;padding:2px 8px">${tl('stdAddTag')}</button>`:''}</div>${tags}
<div class="std-h4">${tl('stdCoupling')}</div>${coup}</div>`;
document.getElementById("stdClose").onclick=closeStandard;
document.getElementById("stdEdit").onclick=()=>{STD_EDIT=!STD_EDIT; renderStd();};
if(ed){
document.getElementById("stdExport").onclick=()=>{
const blob=new Blob([JSON.stringify(STDDATA,null,1)],{type:"application/json"});
const a=document.createElement("a"); a.href=URL.createObjectURL(blob); a.download="standard.json"; a.click();
toast(tl('stdExported'));
};
document.getElementById("stdReset").onclick=()=>{
try{localStorage.removeItem(STD_KEY);}catch(e){}
STDDATA=JSON.parse(JSON.stringify(DATA.standard||BUILTIN_STD)); rebuildTagMeta(); renderStd();
};
document.getElementById("stdAddTag").onclick=()=>{
const id=(prompt(tl('stdNewTagId'))||"").trim().toLowerCase().replace(/\s+/g,"-");
if(!id || STDDATA.tags.some(t=>t.id===id)) return;
STDDATA.tags.push({id,label:id,labelZh:id,bad:true,en:"",zh:""}); saveStdDraft(); renderStd();
};
document.querySelectorAll("#stdModal [data-rm]").forEach(b=>b.onclick=()=>{STDDATA.tags.splice(+b.dataset.rm,1); saveStdDraft(); renderStd();});
document.querySelectorAll("#stdModal [data-k]").forEach(el=>el.addEventListener("input",()=>{
const i=+el.dataset.i, v=el.textContent, lk=langKey();
const k=el.dataset.k;
if(k==="rubric") STDDATA.rubric[i][lk]=v;
else if(k==="sev") STDDATA.severities[i][lk]=v;
else if(k==="coup") STDDATA.coupling[i][lk]=v;
else if(k==="tag") STDDATA.tags[i][lk]=v;
else if(k==="taglabel") STDDATA.tags[i][LANG==="zh"?"labelZh":"label"]=v;
saveStdDraft();
}));
}
}
function toggleHealth(){
const on=!board.classList.contains("show-health");
board.classList.toggle("show-health",on);
const b=document.getElementById("healthBtn");
b.classList.toggle("active",on); b.textContent=on?tl('colorHealth'):tl('colorCoupling');
}
/* ---------- filtering: search + grade threshold + issue tag (combined, AND) ---------- */
let filterGrade=null, filterTag=null, searchQuery="";
function filtersActive(){return filterGrade!=null||filterTag!=null||searchQuery!=="";}
function matchesFilter(m){
if(filterGrade!=null && !(m.score!=null && m.score<filterGrade)) return false;
if(filterTag && !(m.tags||[]).includes(filterTag)) return false;
if(searchQuery){const q=searchQuery;
if(!(m.label.toLowerCase().includes(q)||m.id.toLowerCase().includes(q)||(m.desc||"").toLowerCase().includes(q))) return false;}
return true;
}
function runFilter(){
if(!filtersActive()){document.getElementById("fcount").textContent="";clearSel();return;}
current=null; spineOn=false; document.getElementById("spineBtn").classList.remove("active");
clearEdges(); resetClasses(); board.classList.add("has-sel");
document.getElementById("gradeFilter").classList.toggle("on",filterGrade!=null);
document.getElementById("tagFilter").classList.toggle("on",!!filterTag);
let n=0;
M.forEach(m=>{ if(matchesFilter(m)){cardEl[m.id].classList.add("lit");n++;} });
document.getElementById("fcount").textContent=n+" / "+M.length;
const matches=M.filter(matchesFilter).sort((a,b)=>(a.score??999)-(b.score??999));
const gl={90:"≤ B",75:"≤ C",60:"≤ D",40:"F"}[filterGrade];
const crit=[gl||"",filterTag?("#"+esc(tagLabel(filterTag))):"",searchQuery?('"'+esc(searchQuery)+'"'):""].filter(Boolean).join(" · ");
detail.innerHTML=`<div class="detail"><div class="kicker">${tl('filter')}</div>
<h2>${n} ${tl('modulesWord')}</h2><div class="path">${crit||tl('allWord')}</div>
<div class="rel">${matches.map(m=>`<button data-go="${esc(m.id)}"><span>${esc(m.label)}</span><span class="bnd" style="color:${healthColor(m.score)}">${m.score!=null?m.score+" · "+esc(m.grade):""}</span></button>`).join("")||`<div class="none">${tl('noMatches')}</div>`}</div>
<button class="btn clearbtn" id="clearFilterBtn">${tl('clearFilters')}</button></div>`;
bindGo(); document.getElementById("clearFilterBtn").addEventListener("click",resetFilters);
}
function resetFilters(){
filterGrade=null; filterTag=null; searchQuery="";
document.getElementById("gradeFilter").value=""; document.getElementById("gradeFilter").classList.remove("on");
document.getElementById("tagFilter").value=""; document.getElementById("tagFilter").classList.remove("on");
document.getElementById("search").value=""; document.getElementById("fcount").textContent="";
clearSel();
}
/* populate the issue-tag dropdown from the negative tags present, by frequency */
(function(){
const cnt={}; M.forEach(m=>(m.tags||[]).forEach(t=>{if(BAD_TAGS.has(t))cnt[t]=(cnt[t]||0)+1;}));
const sel=document.getElementById("tagFilter");
Object.entries(cnt).sort((a,b)=>b[1]-a[1]).forEach(([t,c])=>{
const o=document.createElement("option"); o.value=t; o.textContent=`${tagLabel(t)} (${c})`; sel.appendChild(o);});
})();
document.getElementById("search").addEventListener("input",e=>{searchQuery=e.target.value.trim().toLowerCase();runFilter();});
document.getElementById("search").addEventListener("keydown",e=>{
if(e.key==="Enter"){const lit=board.querySelector(".node.lit");if(lit)select(lit.dataset.id);}});
document.getElementById("gradeFilter").addEventListener("change",e=>{filterGrade=e.target.value?Number(e.target.value):null;runFilter();});
document.getElementById("tagFilter").addEventListener("change",e=>{filterTag=e.target.value||null;runFilter();});
document.getElementById("healthBtn").addEventListener("click",toggleHealth);
document.getElementById("reportBtn").addEventListener("click",renderReport);
document.getElementById("spineBtn").addEventListener("click",()=>{spineOn?clearSel():showSpine();});
document.getElementById("stdBtn").addEventListener("click",openStandard);
document.getElementById("stdModal").addEventListener("click",e=>{ if(e.target.id==="stdModal") closeStandard(); });
window.addEventListener("keydown",e=>{ if(e.key==="Escape") closeStandard(); });
board.addEventListener("click",()=>{filtersActive()?runFilter():clearSel();});
let rt; window.addEventListener("resize",()=>{clearTimeout(rt);rt=setTimeout(()=>{const c=current;if(c){current=null;select(c);}else if(spineOn)showSpine();},120);});
board.classList.add("show-health");
document.getElementById("healthBtn").classList.add("active");
document.getElementById("healthBtn").textContent=tl("colorHealth");
renderIntro();
</script>
</body>
</html>