@fcon-tech/portolan 0.4.5

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +110 -0
  3. package/adapters/README.md +226 -0
  4. package/adapters/omp/portolan-mcp +19 -0
  5. package/adapters/opencode/expedition-launcher +70 -0
  6. package/adapters/opencode/install.test.ts +105 -0
  7. package/adapters/opencode/install.ts +357 -0
  8. package/adapters/pi/portolan-mcp +19 -0
  9. package/adapters/scheduling/night-watch.cron +23 -0
  10. package/core/schema/chart.schema.json +154 -0
  11. package/core/src/bin/portolan.ts +84 -0
  12. package/core/src/chart-io.rollback-fixture.ts +55 -0
  13. package/core/src/chart-io.ts +121 -0
  14. package/core/src/chart-store.ts +137 -0
  15. package/core/src/chartroom/cli.ts +63 -0
  16. package/core/src/chartroom/render.ts +213 -0
  17. package/core/src/chartroom/review-template.html +232 -0
  18. package/core/src/chartroom/review.ts +109 -0
  19. package/core/src/chartroom/template.html +1090 -0
  20. package/core/src/fan-in.ts +84 -0
  21. package/core/src/harbor/chat-format.ts +154 -0
  22. package/core/src/harbor/cli.ts +178 -0
  23. package/core/src/harbor/errors.ts +22 -0
  24. package/core/src/harbor/fingerprint.ts +29 -0
  25. package/core/src/harbor/history.ts +178 -0
  26. package/core/src/harbor/launcher.ts +155 -0
  27. package/core/src/harbor/night-policy.ts +64 -0
  28. package/core/src/harbor/proposals.ts +324 -0
  29. package/core/src/harbor/run.ts +72 -0
  30. package/core/src/harbor/settings.ts +108 -0
  31. package/core/src/harbor/snapshot.ts +187 -0
  32. package/core/src/harbor/watch.ts +103 -0
  33. package/core/src/index.ts +28 -0
  34. package/core/src/notices.ts +117 -0
  35. package/core/src/perimeter.ts +44 -0
  36. package/core/src/server/adapter-boundary.ts +66 -0
  37. package/core/src/server/main.ts +27 -0
  38. package/core/src/server/registry.ts +609 -0
  39. package/core/src/server/server.ts +123 -0
  40. package/core/src/server/test-harness.ts +161 -0
  41. package/core/src/sheets.ts +151 -0
  42. package/core/src/staleness.ts +203 -0
  43. package/core/src/tools/log.ts +215 -0
  44. package/core/src/tools/manifests.ts +912 -0
  45. package/core/src/tools/neighborhood.ts +423 -0
  46. package/core/src/tools/shared.ts +72 -0
  47. package/core/src/tools/sound.ts +634 -0
  48. package/core/src/tools/sweep.ts +198 -0
  49. package/core/src/tools/symbols.ts +176 -0
  50. package/core/src/tools/trust-report.ts +193 -0
  51. package/core/src/types.ts +162 -0
  52. package/core/src/validate.ts +106 -0
  53. package/package.json +34 -0
  54. package/skill/SKILL.md +279 -0
  55. package/skill/examples/sailing-directions-example.md +35 -0
  56. package/skill/sailing-directions.template.md +59 -0
  57. package/skill/verify/checks.ts +476 -0
  58. package/skill/verify/dry-run.ts +738 -0
  59. package/skill/verify/fixture.ts +128 -0
@@ -0,0 +1,1090 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Chart Room</title>
7
+ <style>
8
+ :root{
9
+ --paper:#e7dfc8; --panel:#eadfc4; --ink:#33291c; --ink-soft:#6b5a3e;
10
+ --water:#b9cfc6; --halo1:#cfdcd2; --halo2:#dfe3d3;
11
+ --island:#ecddb0; --island-edge:#8a744e; --island-ink:#4a3b26;
12
+ --danger:#8a3226; --poe:#274a63;
13
+ --lane-measured:#25505e; --lane-charted:#4f7f8d;
14
+ --lane-reported:#5f8886; --lane-doubtful:#98aaa4;
15
+ --frame:#6b5a3e; --accent:#7a2f22;
16
+ --chipbg:#efe6d0; --halotext:#e7dfc8; --sea-tex:#9dbfb4;
17
+ --font-map:Georgia,'Iowan Old Style','Times New Roman',serif;
18
+ --font-ui:Georgia,serif;
19
+ }
20
+ body.eng{
21
+ --paper:#0f151b; --panel:#141c24; --ink:#e7edf1; --ink-soft:#9fb1bd;
22
+ --water:#131c24; --halo1:#1a2833; --halo2:#16222c;
23
+ --island:#20303e; --island-edge:#3d586d; --island-ink:#cfe0ea;
24
+ --danger:#e08573; --poe:#7fb6d9;
25
+ --lane-measured:#4fd0c8; --lane-charted:#35788a;
26
+ --lane-reported:#4f7d8c; --lane-doubtful:#39505c;
27
+ --frame:#3d586d; --accent:#e08573;
28
+ --chipbg:#0f151b; --halotext:#0f151b; --sea-tex:#1b2733;
29
+ --font-map:'Segoe UI',system-ui,sans-serif;
30
+ --font-ui:'Segoe UI',system-ui,sans-serif;
31
+ }
32
+ *{box-sizing:border-box}
33
+ html,body{height:100%}
34
+ body{margin:0;background:var(--paper);color:var(--ink);font-family:var(--font-ui);
35
+ display:flex;flex-direction:column;transition:background .2s}
36
+ header#topbar{display:flex;align-items:baseline;gap:14px;padding:10px 18px 6px;
37
+ border-bottom:2px solid var(--frame)}
38
+ #title{font-variant:small-caps;letter-spacing:.14em;font-weight:700;font-size:19px}
39
+ #province{font-weight:400;letter-spacing:.02em;color:var(--ink-soft);font-size:15px}
40
+ #topbar .spacer{flex:1}
41
+ #filters{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
42
+ #findBox{font:inherit;font-size:13px;padding:4px 10px;border:1.5px solid var(--frame);
43
+ border-radius:3px;background:var(--chipbg);color:var(--ink);width:170px}
44
+ #findBox:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
45
+ .chip{font:inherit;font-size:11.5px;padding:3px 9px;border:1.5px solid var(--frame);
46
+ border-radius:11px;background:transparent;color:var(--ink);cursor:pointer}
47
+ .chip[aria-pressed="true"],.chip.on{background:var(--lane-measured);border-color:var(--lane-measured);
48
+ color:#fff}
49
+ .chip:hover{background:var(--panel)}
50
+ #map .dimmed{opacity:.16}
51
+ #map .dimmed text.lbl{opacity:.35}
52
+ #modeBtn{font:inherit;font-size:13px;padding:5px 14px;border:1.5px solid var(--frame);
53
+ border-radius:3px;background:transparent;color:var(--ink);cursor:pointer}
54
+ #modeBtn:hover{background:var(--panel)}
55
+ #modeBtn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
56
+ #stage{flex:1;display:flex;min-height:0}
57
+ #mapwrap{flex:1;min-width:0;position:relative;cursor:grab}
58
+ #mapwrap.panning{cursor:grabbing}
59
+ svg#map{width:100%;height:100%;display:block}
60
+ aside#panel{width:520px;flex:none;overflow-y:auto;padding:14px 16px 24px;
61
+ border-left:2px solid var(--frame);background:var(--panel)}
62
+ aside#panel h2{margin:2px 0 8px;font-variant:small-caps;letter-spacing:.1em;font-size:16px}
63
+ aside#panel h3{margin:14px 0 6px;font-size:14px;font-variant:small-caps;letter-spacing:.08em;
64
+ border-bottom:1px solid var(--frame);padding-bottom:2px}
65
+ aside#panel p{font-size:13px;line-height:1.5;margin:6px 0}
66
+ aside#panel li{font-size:13px;line-height:1.45;margin:4px 0}
67
+ aside#panel code{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px;
68
+ background:rgba(127,127,127,.12);padding:0 3px;border-radius:2px}
69
+ .trustbar{display:flex;height:14px;border:1px solid var(--frame);border-radius:2px;overflow:hidden;margin:6px 0 2px}
70
+ .trustbar div{height:100%}
71
+ .trustkey{display:flex;flex-wrap:wrap;gap:4px 14px;font-size:11.5px;color:var(--ink-soft);margin-bottom:6px}
72
+ .trustkey b{color:var(--ink);font-weight:600}
73
+ .statline{font-size:12.5px;color:var(--ink-soft);margin:4px 0}
74
+ .dossier-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
75
+ .dossier-head h2{margin:0}
76
+ .closebtn{margin-left:auto;font:inherit;font-size:12px;border:1px solid var(--frame);
77
+ background:transparent;color:var(--ink);border-radius:3px;padding:2px 10px;cursor:pointer}
78
+ .badge{display:inline-block;font-size:11px;letter-spacing:.04em;border:1px solid var(--frame);
79
+ border-radius:9px;padding:1px 8px;color:var(--ink-soft)}
80
+ .badge.trust{border-color:var(--lane-measured);color:var(--lane-measured);font-weight:600}
81
+ .anchors{margin:2px 0 4px;padding:0;list-style:none}
82
+ .anchors li{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:10.5px;color:var(--ink-soft);
83
+ white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
84
+ .fwrow{display:flex;gap:8px;font-size:12.5px;align-items:baseline}
85
+ .fwrow .t{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
86
+ .fwrow .tr{font-size:10.5px;color:var(--ink-soft)}
87
+ .ledger{display:flex;gap:8px;margin:8px 0 2px}
88
+ .lamp{flex:1;border:1.5px solid var(--frame);border-radius:4px;padding:6px 8px;text-align:center}
89
+ .lamp .n{font-size:17px;font-weight:700;display:block;line-height:1.1}
90
+ .lamp .t{font-size:9px;letter-spacing:.1em;display:block;margin-top:2px}
91
+ .lamp.observed{border-color:var(--lane-measured)} .lamp.observed .t{color:var(--lane-measured)}
92
+ .lamp.verified{border-color:#1d6b46} .lamp.verified .t{color:#1d6b46}
93
+ .lamp.declared{border-color:var(--poe)} .lamp.declared .t{color:var(--poe)}
94
+ .lamp.claimed{border-color:var(--accent)} .lamp.claimed .t{color:var(--accent)}
95
+ .ledger-note{font-size:10px;color:var(--ink-soft);margin:3px 0 0}
96
+ .fwrow .snd{font-size:9.5px;color:#1d6b46;font-weight:600}
97
+ .notice{margin:7px 0;border-left:2px solid var(--frame);padding-left:9px}
98
+ .notice .nact{font-size:10.5px;font-weight:700;letter-spacing:.05em;margin-right:6px}
99
+ .notice .nnote{font-size:12px;color:var(--ink-soft);margin-top:2px}
100
+ .notice code{font-size:11px}
101
+ /* map svg */
102
+ .nautical-only{transition:opacity .2s}
103
+ body.eng .nautical-only{opacity:0;pointer-events:none}
104
+ body.eng #world{display:none}
105
+ body.eng .maponly{display:none}
106
+ body:not(.eng) #graphview{display:none}
107
+ body:not(.eng) .engonly{display:none}
108
+ /* graph view */
109
+ .gnode{cursor:pointer}
110
+ .gnode .box{fill:var(--island);stroke:var(--island-edge);stroke-width:1.6}
111
+ .gnode:hover .box{stroke-width:2.8}
112
+ .gnode .gname{fill:var(--island-ink);font-size:13px;font-family:var(--font-map)}
113
+ .gnode .gbadge{fill:var(--ink-soft);font-size:10px;font-family:var(--font-map)}
114
+ .gedge{fill:none;stroke-linecap:round}
115
+ .gedge:hover{stroke-width:4}
116
+ .gnode{transition:opacity .15s}
117
+ #map.blasting .gnode:not(.lit):not(.lit2){opacity:.22}
118
+ #map.blasting .gedge:not(.lit):not(.lit2){opacity:.1}
119
+ text.lbl{font-family:var(--font-map);fill:var(--ink);paint-order:stroke;
120
+ stroke:var(--halotext);stroke-width:3px;stroke-linejoin:round;pointer-events:none}
121
+ text.lbl.big{font-style:italic;font-size:23px;font-weight:600}
122
+ text.lbl.mid{font-style:italic;font-size:17.5px;font-weight:500}
123
+ text.lbl.small{font-size:11.5px;font-weight:600;letter-spacing:.07em;text-transform:uppercase}
124
+ .island{cursor:pointer}
125
+ .island .coast{fill:var(--island);stroke:var(--island-edge);stroke-width:2.2}
126
+ .island:hover .coast{stroke-width:3.6}
127
+ .island.edgeless .coast{stroke-dasharray:1 5;stroke-linecap:round}
128
+ .lane{fill:none;stroke-linecap:round;transition:opacity .15s}
129
+ .lane:hover{stroke-width:4}
130
+ .casing{pointer-events:none}
131
+ #map.blasting .casing{opacity:.25}
132
+ #map.blasting .island:not(.lit):not(.lit2){opacity:.22}
133
+ #map.blasting .island:not(.lit):not(.lit2) text{opacity:.4}
134
+ #map.blasting .lane:not(.lit):not(.lit2){opacity:.1}
135
+ .island{transition:opacity .15s}
136
+ text.marginal{font-family:var(--font-map);fill:var(--ink-soft)}
137
+ text.cartouche-main{font-family:var(--font-map);fill:var(--ink);letter-spacing:.22em;
138
+ font-variant:small-caps;font-weight:700}
139
+ text.dragon{font-family:var(--font-map);font-style:italic;fill:var(--ink-soft);letter-spacing:.12em}
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <header id="topbar">
144
+ <div id="title">Chart Room</div>
145
+ <div id="province"></div>
146
+ <div class="spacer"></div>
147
+ <div id="filters" role="group" aria-label="Filters">
148
+ <input id="findBox" type="search" placeholder="find vessel…" autocomplete="off">
149
+ <span id="trustChips"></span>
150
+ <button class="chip" id="dangerChip" type="button" aria-pressed="false">dangers only</button>
151
+ <button class="chip" id="resetChip" type="button">reset</button>
152
+ </div>
153
+ <button id="modeBtn" type="button">Engineering mode</button>
154
+ </header>
155
+ <div id="stage">
156
+ <div id="mapwrap">
157
+ <svg id="map" viewBox="0 0 1880 1340" role="img" aria-label="Archipelago map of the charted province">
158
+ <defs>
159
+ <filter id="paperTex" x="0" y="0" width="100%" height="100%">
160
+ <feTurbulence type="fractalNoise" baseFrequency="0.012 0.016" numOctaves="4" seed="41" result="n"/>
161
+ <feDiffuseLighting in="n" lighting-color="#ffffff" surfaceScale="2.2">
162
+ <feDistantLight azimuth="235" elevation="58"/>
163
+ </feDiffuseLighting>
164
+ </filter>
165
+ <filter id="coastWobble" x="-8%" y="-8%" width="116%" height="116%">
166
+ <feTurbulence type="fractalNoise" baseFrequency="0.05" numOctaves="2" seed="7" result="w"/>
167
+ <feDisplacementMap in="SourceGraphic" in2="w" scale="2.4"/>
168
+ </filter>
169
+ <pattern id="staleHatch" width="7" height="7" patternTransform="rotate(45)" patternUnits="userSpaceOnUse">
170
+ <line x1="0" y1="0" x2="0" y2="7" stroke="var(--accent)" stroke-width="1.4" opacity=".55"/>
171
+ </pattern>
172
+ <marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5.5" markerHeight="5.5" orient="auto-start-reverse">
173
+ <path d="M0,1 L9,5 L0,9 z" fill="context-stroke"/>
174
+ </marker>
175
+ <clipPath id="frameClip">
176
+ <rect x="42" y="42" width="1796" height="1256"/>
177
+ </clipPath>
178
+ </defs>
179
+ <rect id="sea" x="0" y="0" width="1880" height="1340" fill="var(--water)"/>
180
+ <g clip-path="url(#frameClip)">
181
+ <rect id="seaTex" x="0" y="0" width="1880" height="1340" filter="url(#paperTex)" opacity="0.5" class="nautical-only"/>
182
+ <g id="viewport">
183
+ <g id="world">
184
+ <g id="rhumbs" class="nautical-only"></g>
185
+ <g id="halos"></g>
186
+ <g id="lanes"></g>
187
+ <g id="islands"></g>
188
+ <g id="marks"></g>
189
+ <g id="labels"></g>
190
+ <g id="plate" style="pointer-events:none"></g>
191
+ </g>
192
+ <g id="graphview"></g>
193
+ </g>
194
+ </g>
195
+ <g id="margin"></g>
196
+ </svg>
197
+ </div>
198
+ <aside id="panel">
199
+ <div id="briefing"></div>
200
+ <div id="dossier" hidden></div>
201
+ </aside>
202
+ </div>
203
+ <script>
204
+ "use strict";
205
+ const DATA = __CHART_DATA__;
206
+ const BRIEF = __BRIEF_HTML__;
207
+ const META = __META__;
208
+ const NOTICES = DATA.notices || [];
209
+ const TANGLES = DATA.tangles || [];
210
+
211
+ /* ---------- deterministic randomness ---------- */
212
+ function mulberry32(a){return function(){a|=0;a=a+0x6D2B79F5|0;let t=Math.imul(a^a>>>15,1|a);
213
+ t=t+Math.imul(t^t>>>7,61|t)^t;return((t^t>>>14)>>>0)/4294967296}}
214
+ function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);
215
+ h=Math.imul(h,16777619)}return h>>>0}
216
+ const SEED = hashStr(JSON.stringify(DATA.entries.map(e=>e.id).sort())+DATA.entries.length);
217
+
218
+ /* ---------- lexicon ---------- */
219
+ const LEX = {
220
+ nautical:{vessel:"Vessel",fairway:"Fairway",danger:"Danger",poe:"Port of entry",
221
+ beacon:"Beacon",light:"Light",unsurveyed:"Unsurveyed",hub:"dependents",
222
+ chartroom:"CHART ROOM",blastr:"Blast radius"},
223
+ eng:{vessel:"Component",fairway:"Dependency",danger:"Risk",poe:"Entry point",
224
+ beacon:"Config surface",light:"API contract",unsurveyed:"Unknown",hub:"dependents",
225
+ chartroom:"SYSTEM MAP",blastr:"Impact set"}
226
+ };
227
+ let mode = localStorage.getItem("chartroom.mode") || "nautical";
228
+ function lex(k){return LEX[mode][k]}
229
+
230
+ /* ---------- chart model ---------- */
231
+ const entries = DATA.entries;
232
+ const vessels = entries.filter(e=>e.kind==="vessel");
233
+ const fairways = entries.filter(e=>e.kind==="fairway");
234
+ const dangers = entries.filter(e=>e.kind==="danger");
235
+ const poes = entries.filter(e=>e.kind==="portOfEntry");
236
+ const beacons = entries.filter(e=>e.kind==="beacon");
237
+ const lights = entries.filter(e=>e.kind==="light");
238
+ const byId = new Map(vessels.map(v=>[v.id,v]));
239
+ const TRUSTS = ["measured","charted","reported","doubtful","unsurveyed"];
240
+ const TRUST_RANK = {measured:0,charted:1,reported:2,doubtful:3,unsurveyed:4};
241
+
242
+ const fanIn = new Map(), fanOut = new Map();
243
+ vessels.forEach(v=>{fanIn.set(v.id,0);fanOut.set(v.id,0)});
244
+ fairways.forEach(f=>{if(fanOut.has(f.from))fanOut.set(f.from,fanOut.get(f.from)+1);
245
+ if(fanIn.has(f.to))fanIn.set(f.to,fanIn.get(f.to)+1)});
246
+ const depAdj = new Map(); // reverse: to -> [from]
247
+ fairways.forEach(f=>{if(!depAdj.has(f.to))depAdj.set(f.to,[]);depAdj.get(f.to).push(f.from)});
248
+ function dependents(id){ // transitive
249
+ const seen=new Set(),q=[id];
250
+ while(q.length){const x=q.pop();(depAdj.get(x)||[]).forEach(d=>{if(!seen.has(d)){seen.add(d);q.push(d)}})}
251
+ return seen}
252
+ const GOD_HUB = 5;
253
+ const godHubs = vessels.filter(v=>fanIn.get(v.id)>=GOD_HUB);
254
+ const poeVessels = new Set(poes.map(p=>p.vessel));
255
+ function filesOf(v){return (v.signature&&v.signature.files)||1}
256
+
257
+ /* ---------- trust shares ---------- */
258
+ const trustCount = {};
259
+ entries.forEach(e=>trustCount[e.trust]=(trustCount[e.trust]||0)+1);
260
+
261
+ /* ---------- geometry ---------- */
262
+ const W=1880,H=1340, M=90; // margin for marginalia
263
+ const rnd = mulberry32(SEED);
264
+ function radiusOf(v){return 8+22*Math.log10(Math.max(2,filesOf(v)))}
265
+ vessels.forEach(v=>{
266
+ const n=16,jit=[];
267
+ for(let i=0;i<n;i++)jit.push(0.80+0.38*rnd());
268
+ v._r=radiusOf(v); v._R=v._r*1.24; v._jit=jit; v._rot=rnd()*2*Math.PI;
269
+ });
270
+ function blobPath(cx,cy,r,jit,rot){
271
+ const n=jit.length;const pts=[];
272
+ for(let i=0;i<n;i++){const a=rot+i/n*2*Math.PI;const rr=r*jit[i];
273
+ pts.push([cx+Math.cos(a)*rr,cy+Math.sin(a)*rr])}
274
+ let d="";
275
+ for(let i=0;i<n;i++){
276
+ const p0=pts[(i-1+n)%n],p1=pts[i],p2=pts[(i+1)%n],p3=pts[(i+2)%n];
277
+ const c1=[p1[0]+(p2[0]-p0[0])/6,p1[1]+(p2[1]-p0[1])/6];
278
+ const c2=[p2[0]-(p3[0]-p1[0])/6,p2[1]-(p3[1]-p1[1])/6];
279
+ if(i===0)d+="M"+p1[0].toFixed(1)+","+p1[1].toFixed(1);
280
+ d+="C"+c1[0].toFixed(1)+","+c1[1].toFixed(1)+" "+c2[0].toFixed(1)+","+c2[1].toFixed(1)+" "+p2[0].toFixed(1)+","+p2[1].toFixed(1);
281
+ }
282
+ return d+"Z";
283
+ }
284
+ function vBlob(v,scale){const cx=v._x,cy=v._y;
285
+ return blobPath(cx,cy,v._r*scale,v._jit.map(j=>1+(j-1)*Math.pow(scale,0.7)),v._rot)}
286
+
287
+ /* layout: deterministic force relaxation */
288
+ (function layout(){
289
+ const inner={x0:M+70,y0:M+60,x1:W-M-70,y1:H-M-40};
290
+ const main=vessels.slice().sort((a,b)=>fanIn.get(b.id)-fanIn.get(a.id)||a.id.localeCompare(b.id));
291
+ // seed positions on an ellipse, most-connected toward center
292
+ main.forEach((v,i)=>{
293
+ const t=i/main.length*2*Math.PI-Math.PI/2;
294
+ const depth=i<5?0.34:i<12?0.62:0.9;
295
+ const cx=(inner.x0+inner.x1)/2, cy=(inner.y0+inner.y1)/2;
296
+ v._x=cx+Math.cos(t)*((inner.x1-inner.x0)/2*depth)*(0.85+0.3*rnd());
297
+ v._y=cy+Math.sin(t)*((inner.y1-inner.y0)/2*depth)*(0.85+0.3*rnd());
298
+ });
299
+ // the busiest harbors belong mid-province: most lanes are inbound to them
300
+ {
301
+ const hx=(inner.x0+inner.x1)/2, hy=(inner.y0+inner.y1)/2;
302
+ main.filter(v=>fanIn.get(v.id)>=GOD_HUB).forEach(v=>{
303
+ v._x=hx+(rnd()-0.5)*160; v._y=hy+(rnd()-0.5)*140});
304
+ }
305
+ const links=fairways.filter(f=>byId.has(f.from)&&byId.has(f.to));
306
+ const K=150;
307
+ for(let it=0;it<500;it++){
308
+ const temp=Math.max(3,170*(1-it/500));
309
+ for(let i=0;i<main.length;i++)for(let j=i+1;j<main.length;j++){
310
+ const a=main[i],b=main[j];let dx=b._x-a._x,dy=b._y-a._y;
311
+ let d=Math.sqrt(dx*dx+dy*dy)||0.01;
312
+ let f=Math.min(temp,K*K/(d*d)*d*0.02+d*0.002);
313
+ dx/=d;dy/=d;a._x-=dx*f;a._y-=dy*f;b._x+=dx*f;b._y+=dy*f}
314
+ links.forEach(f=>{const a=byId.get(f.from),b=byId.get(f.to);
315
+ let dx=b._x-a._x,dy=b._y-a._y;const d=Math.hypot(dx,dy)||0.01;
316
+ const target=a._R+b._R+130;let s=(d-target)/d*0.03;
317
+ s=Math.max(-temp,Math.min(temp,s));
318
+ dx*=s;dy*=s;a._x+=dx;a._y+=dy;b._x-=dx;b._y-=dy});
319
+ const cx=(inner.x0+inner.x1)/2,cy=(inner.y0+inner.y1)/2;
320
+ main.forEach(v=>{v._x+=(cx-v._x)*0.0025;v._y+=(cy-v._y)*0.0035});
321
+ }
322
+ // guarantee: resolve island overlaps and clamp inside the frame
323
+ const GAP=54;
324
+ for(let s=0;s<160;s++){
325
+ let any=false;
326
+ for(let i=0;i<main.length;i++)for(let j=i+1;j<main.length;j++){
327
+ const a=main[i],b=main[j];let dx=b._x-a._x,dy=b._y-a._y;
328
+ let d=Math.hypot(dx,dy)||0.01;const min=a._R+b._R+GAP;
329
+ if(d<min){const push=(min-d)/2+0.5;dx/=d;dy/=d;
330
+ a._x-=dx*push;a._y-=dy*push;b._x+=dx*push;b._y+=dy*push;any=true}}
331
+ main.forEach(v=>{
332
+ const nx=Math.min(Math.max(v._x,inner.x0+v._R),inner.x1-v._R);
333
+ const ny=Math.min(Math.max(v._y,inner.y0+v._R),inner.y1-v._R);
334
+ if(nx!==v._x||ny!==v._y)any=true;v._x=nx;v._y=ny});
335
+ if(!any)break;
336
+ }
337
+ // hub anchor: the busiest harbors settle mid-province, where their
338
+ // inbound lanes are shortest; overlap-freeing continues around them
339
+ {
340
+ const hx=(inner.x0+inner.x1)/2, hy=(inner.y0+inner.y1)/2;
341
+ const hubs=main.filter(v=>fanIn.get(v.id)>=GOD_HUB);
342
+ hubs.forEach((v,i)=>{const a=i/hubs.length*2*Math.PI;
343
+ v._x=hx+Math.cos(a)*120-80; v._y=hy+Math.sin(a)*110-40});
344
+ for(let s2=0;s2<50;s2++){
345
+ let moved=false;
346
+ for(let i=0;i<main.length;i++)for(let j=i+1;j<main.length;j++){
347
+ const a=main[i],b=main[j];let dx=b._x-a._x,dy=b._y-a._y;
348
+ const d=Math.hypot(dx,dy)||0.01;const min=a._R+b._R+GAP;
349
+ if(d<min){const push=(min-d)/2+0.5;
350
+ if(!hubs.includes(a)){a._x-=dx/d*push;a._y-=dy/d*push}
351
+ if(!hubs.includes(b)){b._x+=dx/d*push;b._y+=dy/d*push}
352
+ moved=true}}
353
+ main.forEach(v=>{
354
+ const nx=Math.min(Math.max(v._x,inner.x0+v._R),inner.x1-v._R);
355
+ const ny=Math.min(Math.max(v._y,inner.y0+v._R),inner.y1-v._R);
356
+ if(nx!==v._x||ny!==v._y)moved=true;v._x=nx;v._y=ny});
357
+ if(!moved)break;
358
+ }
359
+ }
360
+ })();
361
+
362
+ /* ---------- svg helpers ---------- */
363
+ const SVGNS="http://www.w3.org/2000/svg";
364
+ function el(tag,attrs,parent){const n=document.createElementNS(SVGNS,tag);
365
+ for(const k in attrs)n.setAttribute(k,attrs[k]);if(parent)parent.appendChild(n);return n}
366
+
367
+ /* shared lane styling by trust (map lanes + graph edges) */
368
+ const LANE_STYLE={measured:{stroke:"var(--lane-measured)","stroke-width":2.6},
369
+ charted:{stroke:"var(--lane-charted)","stroke-width":2.2},
370
+ reported:{stroke:"var(--lane-reported)","stroke-width":2,"stroke-dasharray":"7 5"},
371
+ doubtful:{stroke:"var(--lane-doubtful)","stroke-width":1.6,"stroke-dasharray":"3 6"},
372
+ unsurveyed:{stroke:"var(--lane-doubtful)","stroke-width":1.4,"stroke-dasharray":"1 7"}};
373
+
374
+ /* rhumb lines from the rose center */
375
+ (function(){const g=document.getElementById("rhumbs");
376
+ const rx=W-190,ry=205;g.dataset.cx=rx;g.dataset.cy=ry;
377
+ for(let i=0;i<16;i++){const a=i/16*2*Math.PI;
378
+ el("line",{x1:rx,y1:ry,x2:rx+Math.cos(a)*2400,y2:ry+Math.sin(a)*2400,
379
+ stroke:"var(--ink-soft)","stroke-width":i%4===0?1.0:0.55,opacity:0.16},g)}
380
+ })();
381
+
382
+ /* halos: stepped bathymetric bands around each island, band depth = trust of record */
383
+ (function(){const g=document.getElementById("halos");
384
+ const OUTER=["#8fb3a2","#a9c2b0","#c1d1bd","#d6dcc6","#e6ddc4"]; // measured=deepest, unsurveyed=paper
385
+ const INNER=["#7ba18e","#9ab39c","#b8c8ac","#cfd4b8","#eee4c5"];
386
+ vessels.forEach(v=>{
387
+ const depth=TRUST_RANK[v.trust]??4;
388
+ el("path",{d:vBlob(v,1.66),fill:OUTER[depth],stroke:"none",opacity:.94},g);
389
+ el("path",{d:vBlob(v,1.28),fill:INNER[depth],stroke:"none",opacity:.97},g);
390
+ });
391
+ })();
392
+
393
+ /* lanes: paper casing + tapered three-segment stroke + hub fan spread */
394
+ (function(){const g=document.getElementById("lanes");
395
+ // fan index per (to,id): arrows into one hub land side by side, not stacked
396
+ const fan=new Map();
397
+ fairways.forEach(f=>{if(!byId.has(f.to))return;
398
+ if(!fan.has(f.to))fan.set(f.to,[]);
399
+ fan.get(f.to).push(f.id)});
400
+ fan.forEach(list=>list.sort());
401
+ const fanIdx=new Map();
402
+ fan.forEach((list,to)=>list.forEach((id,i)=>fanIdx.set(id,{i,n:list.length})));
403
+ // braid groups: same destination AND same approach sector (8 buckets)
404
+ const sectorOf=(f)=>{
405
+ const a=byId.get(f.from),b=byId.get(f.to);if(!a||!b)return "x";
406
+ const ang=Math.atan2(b._y-a._y,b._x-a._x);
407
+ return f.to+":"+(((Math.round(ang/(Math.PI/4))%8)+8)%8)};
408
+ const braid=new Map();
409
+ fairways.forEach(f=>{const k=sectorOf(f);
410
+ if(!braid.has(k))braid.set(k,[]);braid.get(k).push(f.id)});
411
+ braid.forEach(list=>list.sort());
412
+ const braidIdx=new Map();
413
+ braid.forEach(list=>list.forEach((id,i)=>{if(list.length>1)braidIdx.set(id,i)}));
414
+
415
+ const style={measured:{stroke:"var(--lane-measured)",dash:null,w:[3.4,2.5,1.6]},
416
+ charted:{stroke:"var(--lane-charted)",dash:null,w:[3.0,2.2,1.4]},
417
+ reported:{stroke:"var(--lane-reported)",dash:"7 5",w:[2.7,2.0,1.3]},
418
+ doubtful:{stroke:"var(--lane-doubtful)",dash:"3 6",w:[2.2,1.7,1.2]},
419
+ unsurveyed:{stroke:"var(--lane-doubtful)",dash:"1 7",w:[2.0,1.6,1.1]}};
420
+ const pt=(a,c,b,t)=>{const u=1-t;
421
+ return [u*u*a[0]+2*u*t*c[0]+t*t*b[0], u*u*a[1]+2*u*t*c[1]+t*t*b[1]]};
422
+
423
+ fairways.forEach(f=>{const a=byId.get(f.from),b=byId.get(f.to);if(!a||!b)return;
424
+ const h=hashStr(f.id);
425
+ const mx=(a._x+b._x)/2,my=(a._y+b._y)/2;
426
+ let dx=b._x-a._x,dy=b._y-a._y;const d=Math.hypot(dx,dy)||0.01;
427
+ let bend=(0.22+((h>>>4)%100)/100*0.3)*(((h>>>9)&1)?-1:1);
428
+ if(braidIdx.has(f.id)){
429
+ const k=braidIdx.get(f.id);
430
+ bend=Math.abs(bend)*(1+Math.floor(k/2)*0.45)*((k&1)?-1:1);
431
+ }
432
+ const cx=mx-dy/d*d*bend*0.9, cy=my+dx/d*d*bend*0.9;
433
+ // start / end on the coasts
434
+ let sx=a._x+dx/d*(a._r+5),sy=a._y+dy/d*(a._r+5);
435
+ let ex=b._x-dx/d*(b._r+8),ey=b._y-dy/d*(b._r+8);
436
+ // fan spread at the destination, perpendicular to approach
437
+ const fi=fanIdx.get(f.id)||{i:0,n:1};
438
+ if(fi.n>1){const off=(fi.i-(fi.n-1)/2)*9;
439
+ ex+=-dy/d*off; ey+=dx/d*off}
440
+ const A=[sx,sy];const B=[ex,ey];
441
+ // deterministic obstacle avoidance: if the curve grazes a foreign
442
+ // island, flip/strengthen the bend (up to three attempts)
443
+ const clear=(ctl)=>{
444
+ const probes=[[0.3],[0.55],[0.8]];
445
+ return !probes.some(([t])=>{
446
+ const p=pt(A,ctl,B,t);
447
+ return vessels.some(v=>v!==a&&v!==b&&
448
+ Math.hypot(p[0]-v._x,p[1]-v._y)<v._r*1.14)})};
449
+ let ctl=[cx,cy];
450
+ if(!clear(ctl)){
451
+ for(const cand of [-bend,-bend*1.9,bend*1.9]){
452
+ const cx2=mx-dy/d*d*cand*0.9, cy2=my+dx/d*d*cand*0.9;
453
+ if(clear([cx2,cy2])){ctl=[cx2,cy2];break}}}
454
+ const st=style[f.trust]||style.charted;
455
+ const endTangent=[B[0]-ctl[0],B[1]-ctl[1]];const tl=Math.hypot(...endTangent)||1;
456
+ // arrowhead drawn as a filled wedge aligned with the landing tangent
457
+ const ax=B[0],ay=B[1],ux=endTangent[0]/tl,uy=endTangent[1]/tl;
458
+ const head=el("path",{d:`M${ax},${ay} L${ax-ux*9-uy*3.6},${ay-uy*9+ux*3.6} L${ax-ux*9+uy*3.6},${ay-uy*9-ux*3.6} Z`,
459
+ fill:st.stroke,"stroke":"none",class:"lane","data-fw":f.id},g);
460
+ head.dataset.from=f.from;head.dataset.to=f.to;
461
+ // paper casing then three tapering dash-respecting segments
462
+ el("path",{d:`M${A[0]},${A[1]} Q${ctl[0]},${ctl[1]} ${B[0]},${B[1]}`,
463
+ fill:"none",stroke:"var(--halotext)","stroke-width":st.w[0]+2.6,
464
+ "stroke-linecap":"round",opacity:.65,class:"casing"},g);
465
+ [[0,.42,st.w[0]],[.38,.74,st.w[1]],[.70,1,st.w[2]]].forEach(([t0,t1,w])=>{
466
+ const p0=pt(A,ctl,B,t0),p1=pt(A,ctl,B,(t0+t1)/2),p2=pt(A,ctl,B,Math.min(t1, .999));
467
+ const sp=el("path",{d:`M${p0[0].toFixed(1)},${p0[1].toFixed(1)} Q${p1[0].toFixed(1)},${p1[1].toFixed(1)} ${p2[0].toFixed(1)},${p2[1].toFixed(1)}`,
468
+ fill:"none",class:"lane","data-fw":f.id},
469
+ g);
470
+ sp.setAttribute("stroke",st.stroke);
471
+ sp.setAttribute("stroke-width",w);
472
+ if(st.dash)sp.setAttribute("stroke-dasharray",st.dash);
473
+ sp.dataset.from=f.from;sp.dataset.to=f.to;
474
+ });
475
+ });
476
+ })();
477
+
478
+ /* islands */
479
+ (function(){const g=document.getElementById("islands");
480
+ vessels.forEach(v=>{
481
+ const grp=el("g",{class:"island","data-v":v.id,tabindex:0,
482
+ role:"button","aria-label":v.name||v.id},g);
483
+ if(fanIn.get(v.id)===0&&!poeVessels.has(v.id))grp.classList.add("edgeless");
484
+ el("path",{d:vBlob(v,1),class:"coast",filter:"url(#coastWobble)"},grp);
485
+ if(v.stale)el("path",{d:vBlob(v,1),fill:"url(#staleHatch)",stroke:"none"},grp);
486
+ grp.addEventListener("click",ev=>{ev.stopPropagation();hidePlate();select(v.id)});
487
+ grp.addEventListener("keydown",ev=>{if(ev.key==="Enter"||ev.key===" "){ev.preventDefault();hidePlate();select(v.id)}});
488
+ grp.addEventListener("pointerenter",()=>{if(selected!==v.id)showPlate(v)});
489
+ grp.addEventListener("pointerleave",()=>{if(plateFor===v.id)hidePlate()});
490
+ });
491
+ })();
492
+
493
+ /* marks: dangers, ports of entry, god-hub chips */
494
+ (function(){const g=document.getElementById("marks");
495
+ const ink="var(--danger)";
496
+ function rock(x,y){el("path",{d:`M${x-7},${y} h14 M${x},${y-7} v14`,stroke:ink,"stroke-width":2.4,"stroke-linecap":"round"},g)}
497
+ function shallow(x,y){for(let k=0;k<3;k++){const a=k*Math.PI/3;
498
+ el("line",{x1:x-7*Math.cos(a),y1:y-7*Math.sin(a),x2:x+7*Math.cos(a),y2:y+7*Math.sin(a),
499
+ stroke:ink,"stroke-width":1.8,"stroke-linecap":"round"},g)}}
500
+ function wreck(x,y){el("path",{d:`M${x},${y-8} v16 M${x},${y-6} l-6,-4 M${x},${y-6} l6,-4 M${x},${y-2} l-6,-4 M${x},${y-2} l6,-4`,
501
+ stroke:ink,"stroke-width":1.7,fill:"none","stroke-linecap":"round"},g);
502
+ el("circle",{cx:x,cy:y,r:15,fill:"none",stroke:ink,"stroke-width":1.1,"stroke-dasharray":"2 3"},g)}
503
+ const SYM={rock,wreck,shallow};
504
+ // spread danger marks along each island's coast, away from the label (bottom)
505
+ const perVessel=new Map();
506
+ dangers.forEach(d=>{if(!perVessel.has(d.vessel))perVessel.set(d.vessel,[]);
507
+ perVessel.get(d.vessel).push(d)});
508
+ const drnd=mulberry32(SEED^0xfeedbee);
509
+ perVessel.forEach((list,vid)=>{const v=byId.get(vid);if(!v)return;
510
+ list.forEach((d,i)=>{const a=-2.35+i*0.55+(drnd()-0.5)*0.2;
511
+ const x=v._x+Math.cos(a)*(v._r+22),y=v._y+Math.sin(a)*(v._r+22);
512
+ (SYM[d.category]||rock)(x,y);d._x=x;d._y=y})});
513
+ // ports of entry: anchor on the coast away from the label (labels sit
514
+ // below), protocol as a small letterspaced chip just outside the glyph
515
+ function anchor(x,y){
516
+ el("path",{d:`M${x},${y-9} v15 M${x-6},${y-2} h12 M${x},${y+6} a6,6 0 1,0 -6,-6 M${x},${y-9} a3.4,3.4 0 0,1 6,0`,
517
+ stroke:"var(--poe)","stroke-width":2.2,fill:"none","stroke-linecap":"round"},g)}
518
+ poes.forEach(p=>{const v=byId.get(p.vessel);if(!v)return;
519
+ const a=-Math.PI/2+((hashStr(p.id)%40)-20)/70; // near top, deterministic spread
520
+ const x=v._x+Math.cos(a)*(v._r+20),y=v._y+Math.sin(a)*(v._r+20);
521
+ anchor(x,y);
522
+ const t=el("text",{x,y:y-(v._r>26?26:20),"text-anchor":"middle",class:"marginal",
523
+ "font-size":9,"font-weight":700,"letter-spacing":".14em",fill:"var(--poe)"},g);
524
+ t.textContent=(p.protocol||"").toUpperCase()||"ENTRY"});
525
+ // god-hub chips
526
+ godHubs.forEach(v=>{const x=v._x+v._r*0.75,y=v._y-v._r*0.85;
527
+ el("circle",{cx:x,cy:y,r:11,fill:"var(--chipbg)",stroke:"var(--frame)","stroke-width":1.4},g);
528
+ const t=el("text",{x,y:y+4.5,"text-anchor":"middle","font-size":12,"font-weight":700,
529
+ fill:"var(--ink)",class:"marginal"},g);t.textContent=fanIn.get(v.id)});
530
+ // whirlpools: one spiral glyph per tangle at its members' centroid
531
+ TANGLES.forEach((group,ti)=>{
532
+ const cx=group.reduce((s,id)=>s+byId.get(id)._x,0)/group.length;
533
+ const cy=group.reduce((s,id)=>s+byId.get(id)._y,0)/group.length;
534
+ const wp=el("g",{class:"whirl","data-t":ti},g);
535
+ for(let arm=0;arm<3;arm++){
536
+ let d="",a=arm*2*Math.PI/3;
537
+ for(let s=0;s<=14;s++){
538
+ const r=4+s*1.15,th=a+s*0.42;
539
+ d+=(s?"L":"M")+(cx+Math.cos(th)*r).toFixed(1)+","+(cy+Math.sin(th)*r).toFixed(1)}
540
+ el("path",{d,fill:"none",stroke:"var(--danger)","stroke-width":1.6,
541
+ "stroke-linecap":"round",opacity:.85},wp)}
542
+ });
543
+ const t=document.createElementNS(SVGNS,"title");
544
+ })();
545
+
546
+ /* labels */
547
+ (function(){const g=document.getElementById("labels");
548
+ const ranked=vessels.slice().sort((a,b)=>filesOf(b)-filesOf(a));
549
+ ranked.forEach((v,i)=>{
550
+ const cls=i<5?"big":i<13?"mid":"small";
551
+ const t=el("text",{x:v._x,y:v._y+v._r+16,"text-anchor":"middle",
552
+ class:"lbl "+cls,"data-v":v.id},g);
553
+ t.textContent=(v.name||v.id).replace(/^Apache /,"");
554
+ });
555
+ })();
556
+
557
+ /* label plane: two deterministic passes push overlapping labels apart */
558
+ (function(){const g=document.getElementById("labels");
559
+ const boxes=[...g.querySelectorAll("text.lbl")].map(t=>{
560
+ const b=t.getBBox();return {t,x:b.x,y:b.y,w:b.width,h:b.height}});
561
+ boxes.sort((a,b)=>b.w-a.w);
562
+ for(let pass=0;pass<2;pass++){
563
+ for(let i=0;i<boxes.length;i++)for(let j=0;j<boxes.length;j++){
564
+ if(i===j)continue;
565
+ const A=boxes[i],B=boxes[j];
566
+ if(Math.abs(A.x-B.x)<(A.w+B.w)/2&&Math.abs(A.y-B.y)<(A.h+B.h)/2){
567
+ A.y+=B.y<=A.y?8:-8;
568
+ A.t.setAttribute("y",A.y+A.h-3)}
569
+ }
570
+ }
571
+ })();
572
+
573
+ /* ---------- island atlas plates (chart-room: island-atlas-plates) ---------- */
574
+ const PLATE_W=264;
575
+ function roleOf(v){
576
+ const fi=fanIn.get(v.id)||0,fo=fanOut.get(v.id)||0;
577
+ if(!poeVessels.has(v.id)&&fi===0)return mode==="eng"?"ISOLATED":"DERELICT";
578
+ if(fi>=GOD_HUB)return mode==="eng"?"HUB":"HARBOR HUB";
579
+ if(fi>=3&&fo>=3)return mode==="eng"?"GATEWAY":"GATEWAY";
580
+ if(fo===0)return mode==="eng"?"LEAF":"ANCHORAGE";
581
+ return null}
582
+ function buildPlate(v){
583
+ const g=document.getElementById("plate");
584
+ while(g.firstChild)g.removeChild(g.firstChild);
585
+ const el=(t,at,txt)=>{const n=document.createElementNS(SVGNS,t);
586
+ for(const k in at)n.setAttribute(k,at[k]);
587
+ if(txt!==undefined)n.textContent=txt;g.appendChild(n);return n};
588
+ const din=fairways.filter(f=>f.to===v.id).length;
589
+ const dout=fairways.filter(f=>f.from===v.id).length;
590
+ const np=poes.filter(p=>p.vessel===v.id).length;
591
+ const nb=beacons.filter(b=>b.vessel===v.id).length;
592
+ const nl=lights.filter(l=>l.vessel===v.id).length;
593
+ const nd=dangers.filter(d=>d.vessel===v.id);
594
+ const notice=NOTICES.find(n=>n.key==="vessel/"+v.id);
595
+ const role=roleOf(v);
596
+ let rows=5+(notice?1:0)+(role?1:0)+(v.stale?1:0)+(nd.length?1:0);
597
+ const h=74+rows*15+8;
598
+ // anchor: right of the island, flip if overflowing the neatline
599
+ let px=v._x+v._R+18, py=Math.max(v._y-h/2,46);
600
+ if(px+PLATE_W>W-52)px=Math.max(52,v._x-v._R-18-PLATE_W);
601
+ py=Math.min(py,H-70-h);
602
+ el("rect",{x:px,y:py,width:PLATE_W,height:h,fill:"var(--paper)",
603
+ stroke:"var(--frame)","stroke-width":1.6});
604
+ el("rect",{x:px+4,y:py+4,width:PLATE_W-8,height:h-8,fill:"none",
605
+ stroke:"var(--frame)","stroke-width":.6});
606
+ const trustColor={measured:"var(--lane-measured)",charted:"var(--lane-charted)",
607
+ reported:"var(--lane-reported)",doubtful:"var(--lane-doubtful)",
608
+ unsurveyed:"#b9ab84"}[v.trust]||"var(--lane-charted)";
609
+ el("rect",{x:px,y:py,width:7,height:h,fill:trustColor});
610
+ let y=py+24;
611
+ const title=el("text",{x:px+16,y:y,fill:"var(--ink)"});
612
+ title.setAttribute("style","font-family:var(--font-map);font-size:14px;font-weight:700;font-style:italic");
613
+ title.textContent=(v.name||v.id);
614
+ y+=13;
615
+ el("text",{x:px+16,y,fill:"var(--ink-soft)","font-size":9.5},v.id+
616
+ (filesOf(v)?" · "+filesOf(v).toLocaleString()+" files":""));
617
+ y+=11;
618
+ el("text",{x:px+16,y,fill:"var(--ink-soft)","font-size":9.5},
619
+ ((v.paths&&v.paths.length)||0)+" source path"+(((v.paths&&v.paths.length)===1)?"":"s"));
620
+ y+=17;
621
+ // counters row
622
+ const counts=[
623
+ [mode==="eng"?"in":"in", din],[mode==="eng"?"out":"out", dout],
624
+ [lexShort("poe"),np],[lexShort("beacon"),nb],[lexShort("light"),nl],
625
+ [lexShort("danger"),nd.length]];
626
+ let cx0=px+16;
627
+ counts.forEach(([lab,val])=>{
628
+ el("text",{x:cx0,y,"font-size":10.5,"font-weight":700,fill:"var(--ink)"},String(val));
629
+ el("text",{x:cx0+((val>=10)?13:8),y,"font-size":8.6,fill:"var(--ink-soft)"},lab);
630
+ cx0+=((val>=10)?14:9)+lab.length*4.6+10});
631
+ y+=17;
632
+ // behavior line
633
+ const behav=v.behavior
634
+ ? (mode==="eng"?"behavior charted":"behavior measured")
635
+ : (mode==="eng"?"runtime unknown":"runtime unsurveyed");
636
+ el("text",{x:px+16,y,"font-size":10,fill:v.behavior?"var(--lane-measured)":"var(--accent)"},
637
+ behav);
638
+ y+=15;
639
+ if(v.stale){el("text",{x:px+16,y,"font-size":10,"font-weight":700,
640
+ fill:"var(--accent)"},"PENDING CORRECTION");y+=15}
641
+ if(notice){el("text",{x:px+16,y,"font-size":10,fill:"var(--ink-soft)"},
642
+ (mode==="eng"?"last change: ":"latest notice: ")+notice.action+" · "+notice.key.split("/")[1]);y+=15}
643
+ if(role){el("text",{x:px+16,y,"font-size":10,"font-weight":700,
644
+ "letter-spacing":".12em",fill:"var(--accent)"},role);y+=15}
645
+ y+=2;
646
+ el("text",{x:px+16,y,"font-size":8.8,fill:"var(--ink-soft)"},
647
+ (mode==="eng"?"Click for details + impact set":"Click for dossier & blast radius"));
648
+ function lexShort(k){return LEX[mode][k]}
649
+ }
650
+ let plateFor=null;
651
+ function showPlate(v){
652
+ if(selected===v.id){hidePlate();return}
653
+ plateFor=v.id;buildPlate(v)}
654
+ function hidePlate(){plateFor=null;
655
+ const g=document.getElementById("plate");
656
+ while(g.firstChild)g.removeChild(g.firstChild)}
657
+
658
+ /* graph view (engineering mode): layered dependency diagram */
659
+ (function buildGraph(){
660
+ const g=document.getElementById("graphview");
661
+ const NH=40; // node height
662
+ const disp=v=>(v.name||v.id).replace(/^Apache /,"");
663
+ // rank = longest dependency chain into the node (dependents above, foundations below)
664
+ const rank=new Map();vessels.forEach(v=>rank.set(v.id,0));
665
+ for(let k=0;k<60;k++){let ch=false;
666
+ fairways.forEach(f=>{if(rank.has(f.from)&&rank.has(f.to)){
667
+ const nr=rank.get(f.from)+1;
668
+ if(nr>rank.get(f.to)){rank.set(f.to,nr);ch=true}}});
669
+ if(!ch)break}
670
+ const byRank=new Map();
671
+ vessels.forEach(v=>{const r=rank.get(v.id);
672
+ if(!byRank.has(r))byRank.set(r,[]);byRank.get(r).push(v)});
673
+ const ranks=[...byRank.keys()].sort((a,b)=>a-b);
674
+ ranks.forEach(r=>byRank.get(r).sort((a,b)=>
675
+ fanIn.get(b.id)-fanIn.get(a.id)||a.id.localeCompare(b.id)));
676
+ // barycenter sweeps to reduce crossings (deterministic)
677
+ const ord=new Map(); // id -> current index in its rank
678
+ const neigh=(v,down)=>fairways.filter(f=>down?f.from===v.id:f.to===v.id)
679
+ .map(f=>down?f.to:f.from).filter(id=>ord.has(id));
680
+ for(let sweep=0;sweep<14;sweep++){
681
+ const down=sweep%2===0;
682
+ const seq=down?ranks:ranks.slice().reverse();
683
+ seq.forEach(r=>{
684
+ const row=byRank.get(r);
685
+ const keyed=row.map((v,i)=>{
686
+ const nb=neigh(v,down);
687
+ const bc=nb.length?nb.reduce((s,id)=>s+ord.get(id),0)/nb.length:i;
688
+ return [v,bc+i*1e-4]});
689
+ keyed.sort((a,b)=>a[1]-b[1]);
690
+ byRank.set(r,keyed.map(x=>x[0]));
691
+ byRank.get(r).forEach((v,i)=>ord.set(v.id,i));
692
+ });
693
+ }
694
+ // place
695
+ const rowH=Math.min(148,(H-360)/Math.max(1,ranks.length-1));
696
+ const top=170,left=140,right=W-140,gap=40;
697
+ const nodeW=v=>Math.max(96,disp(v).length*8.6+64);
698
+ const gp=new Map();
699
+ ranks.forEach(r=>{
700
+ const row=byRank.get(r);
701
+ const avail=right-left;
702
+ const rawW=row.map(v=>nodeW(v));
703
+ let rowW=rawW.reduce((s,w)=>s+w+gap,0)-gap;
704
+ const k=rowW>avail?Math.max(0.45,avail/rowW):1; // squeeze wide rows
705
+ const ws=rawW.map(w=>Math.max(64,w*k));
706
+ rowW=ws.reduce((s,w)=>s+w+gap,0)-gap;
707
+ const rgap=rowW>avail?Math.max(8,gap-(rowW-avail)/Math.max(1,row.length-1)):gap;
708
+ if(rowW>avail)rowW=ws.reduce((s,w)=>s+w+rgap,0)-rgap;
709
+ let x=left+Math.max(10,(avail-rowW)/2);
710
+ row.forEach((v,i)=>{const w=ws[i];
711
+ gp.set(v.id,{x:x+w/2,y:top+r*rowH,w,sq:k<1});x+=w+rgap})});
712
+ // edges first
713
+ const ge=el("g",{},g);
714
+ fairways.forEach(f=>{const a=gp.get(f.from),b=gp.get(f.to);if(!a||!b)return;
715
+ const sy=a.y+NH/2,ty=b.y-NH/2,dy=ty-sy;
716
+ const p=el("path",Object.assign({d:`M${a.x},${sy} C${a.x},${sy+dy*0.55} ${b.x},${ty-dy*0.55} ${b.x},${ty-6}`,
717
+ class:"gedge","data-fw":f.id,"marker-end":"url(#arrow)"},LANE_STYLE[f.trust]||LANE_STYLE.charted),ge);
718
+ p.dataset.from=f.from;p.dataset.to=f.to});
719
+ // nodes
720
+ const TRUST_DOT={measured:"var(--lane-measured)",charted:"var(--lane-charted)",
721
+ reported:"var(--lane-reported)",doubtful:"var(--lane-doubtful)",unsurveyed:"#8a8a8a"};
722
+ const gn=el("g",{},g);
723
+ vessels.forEach(v=>{const p=gp.get(v.id);
724
+ const grp=el("g",{class:"gnode","data-v":v.id,tabindex:0,role:"button",
725
+ "aria-label":v.name||v.id},gn);
726
+ el("rect",{x:p.x-p.w/2,y:p.y-NH/2,width:p.w,height:NH,rx:7,class:"box"},grp);
727
+ el("circle",{cx:p.x-p.w/2+14,cy:p.y,r:5,fill:TRUST_DOT[v.trust]||"#8a8a8a"},grp);
728
+ const name=el("text",{x:p.x+6,y:p.y+4.5,"text-anchor":"middle",class:"gname"},grp);
729
+ if(p.sq){name.setAttribute("textLength",Math.max(40,p.w-40));
730
+ name.setAttribute("lengthAdjust","spacingAndGlyphs")}
731
+ name.textContent=disp(v);
732
+ const badges=[];
733
+ if(poeVessels.has(v.id))badges.push("entry");
734
+ const nd=dangers.filter(d=>d.vessel===v.id).length;if(nd)badges.push("⚠"+nd);
735
+ if(fanIn.get(v.id)>=GOD_HUB)badges.push("×"+fanIn.get(v.id));
736
+ if(badges.length){const bt=el("text",{x:p.x,y:p.y-NH/2-6,"text-anchor":"middle",
737
+ class:"gbadge"},grp);bt.textContent=badges.join(" ")}
738
+ grp.addEventListener("click",ev=>{ev.stopPropagation();select(v.id)});
739
+ grp.addEventListener("keydown",ev=>{if(ev.key==="Enter"||ev.key===" "){ev.preventDefault();select(v.id)}});
740
+ });
741
+ // rank axis note
742
+ const ax=el("text",{x:70,y:H-90,class:"marginal","font-size":11,
743
+ transform:`rotate(-90 70 ${H-90})`},g);
744
+ ax.textContent="dependents above → foundations below";
745
+ })();
746
+
747
+ /* marginalia: frame, cartouche, rose, legend, scale ladder, dragon */
748
+ (function(){const g=document.getElementById("margin");
749
+ el("rect",{x:26,y:26,width:W-52,height:H-52,fill:"none",stroke:"var(--frame)","stroke-width":2.6},g);
750
+ el("rect",{x:36,y:36,width:W-72,height:H-72,fill:"none",stroke:"var(--frame)","stroke-width":0.8},g);
751
+ const maponly={class:"maponly"};
752
+ // cartouche
753
+ const cx=64,cy=64;
754
+ const cart=el("g",maponly,g);
755
+ el("rect",{x:cx,y:cy,width:388,height:96,fill:"var(--paper)",stroke:"var(--frame)","stroke-width":1.6},cart);
756
+ el("rect",{x:cx+5,y:cy+5,width:378,height:86,fill:"none",stroke:"var(--frame)","stroke-width":0.7},cart);
757
+ let t=el("text",{x:cx+20,y:cy+32,class:"cartouche-main","font-size":21},cart);
758
+ t.dataset.lex="chartroom";
759
+ t=el("text",{x:cx+20,y:cy+54,class:"marginal","font-size":14.5,fill:"var(--ink)"},cart);
760
+ t.textContent=META.province;
761
+ t=el("text",{x:cx+20,y:cy+74,class:"marginal","font-size":12},cart);
762
+ t.textContent=`Expedition ${META.expedition} · ${vessels.length} ${lex("vessel").toLowerCase()}s · ${fairways.length} ${lex("fairway").toLowerCase()}s · ${dangers.length} ${lex("danger").toLowerCase()}s`;
763
+ t.dataset.redynamic="counts";
764
+ // graph header (engineering)
765
+ const gh=el("g",{class:"engonly"},g);
766
+ let gt=el("text",{x:64,y:96,class:"cartouche-main","font-size":21},gh);
767
+ gt.dataset.lex="chartroom";
768
+ gt=el("text",{x:64,y:118,class:"marginal","font-size":14.5,fill:"var(--ink)"},gh);
769
+ gt.textContent=META.province;
770
+ gt=el("text",{x:64,y:136,class:"marginal","font-size":12},gh);
771
+ gt.textContent=`Expedition ${META.expedition} · layered by dependency depth`;
772
+ // compass rose
773
+ const rx=W-190,ry=205;
774
+ const rose=el("g",{class:"nautical-only"},g);
775
+ el("circle",{cx:rx,cy:ry,r:44,fill:"none",stroke:"var(--ink-soft)","stroke-width":1},rose);
776
+ el("circle",{cx:rx,cy:ry,r:34,fill:"none",stroke:"var(--ink-soft)","stroke-width":0.5},rose);
777
+ for(let i=0;i<8;i++){const a=i*Math.PI/4;const long=i%2===0;
778
+ const r1=long?58:44;
779
+ el("line",{x1:rx,y1:ry,x2:rx+Math.cos(a)*r1,y2:ry+Math.sin(a)*r1,
780
+ stroke:"var(--ink-soft)","stroke-width":long?2:1},rose);
781
+ if(long){const w=a+Math.PI/8;
782
+ el("path",{d:`M${rx+Math.cos(a)*54},${ry+Math.sin(a)*54} L${rx+Math.cos(w)*8},${ry+Math.sin(w)*8} L${rx+Math.cos(a+Math.PI/8+0.35)*16},${ry+Math.sin(a+Math.PI/8+0.35)*16} Z`,
783
+ fill:"var(--ink-soft)",opacity:.8},rose)}}
784
+ const nt=el("text",{x:rx,y:ry-64,"text-anchor":"middle","font-size":12,class:"marginal"},rose);
785
+ nt.textContent="N";
786
+ // map legend (nautical)
787
+ const mleg=el("g",maponly,g);
788
+ const lx=56,ly=H-268;
789
+ el("rect",{x:lx,y:ly,width:360,height:226,fill:"var(--paper)",stroke:"var(--frame)","stroke-width":1.4},mleg);
790
+ let lt=el("text",{x:lx+16,y:ly+26,class:"cartouche-main","font-size":13},mleg);lt.textContent="LEGEND";
791
+ // trust of record (water depth)
792
+ const bands=["#7ba18e","#9ab39c","#b8c8ac","#cfd4b8","#eee4c5"];
793
+ TRUSTS.forEach((tr,i)=>{const y=ly+46+i*20;
794
+ el("rect",{x:lx+16,y:y-10,width:30,height:13,fill:bands[i],stroke:"var(--island-edge)","stroke-width":.8},mleg);
795
+ const t2=el("text",{x:lx+60,y:y+1,"font-size":11.5,class:"marginal",fill:"var(--ink)"},mleg);
796
+ t2.textContent=tr});
797
+ // lane styles
798
+ const lstyles={measured:["var(--lane-measured)","none"],charted:["var(--lane-charted)","none"],
799
+ reported:["var(--lane-reported)","7 5"],doubtful:["var(--lane-doubtful)","3 6"]};
800
+ let yy=ly+158;
801
+ const lt2=el("text",{x:lx+16,y:yy,"font-size":11,class:"marginal"},mleg);
802
+ lt2.textContent="Fairway trust →";
803
+ Object.keys(lstyles).forEach((tr,i)=>{const y=yy+16+i*15;
804
+ el("line",{x1:lx+16,y1:y-3,x2:lx+50,y2:y-3,stroke:lstyles[tr][0],"stroke-width":2.2,
805
+ "stroke-dasharray":lstyles[tr][1],fill:"none"},mleg);
806
+ const t3=el("text",{x:lx+60,y:y,"font-size":11,class:"marginal",fill:"var(--ink)"},mleg);
807
+ t3.textContent=tr});
808
+ // graph legend (engineering)
809
+ const gleg=el("g",{class:"engonly"},g);
810
+ const gx=56,gy=H-268;
811
+ el("rect",{x:gx,y:gy,width:360,height:226,fill:"var(--paper)",stroke:"var(--frame)","stroke-width":1.4},gleg);
812
+ let gl=el("text",{x:gx+16,y:gy+26,class:"cartouche-main","font-size":13},gleg);gl.textContent="LEGEND";
813
+ let gy2=gy+48;
814
+ gl=el("text",{x:gx+16,y:gy2,"font-size":11,class:"marginal",fill:"var(--ink)"},gleg);
815
+ gl.textContent="Edge trust →";
816
+ Object.keys(lstyles).forEach((tr,i)=>{const y=gy2+16+i*15;
817
+ el("line",{x1:gx+16,y1:y-3,x2:gx+50,y2:y-3,stroke:lstyles[tr][0],"stroke-width":2.2,
818
+ "stroke-dasharray":lstyles[tr][1],fill:"none"},gleg);
819
+ const t3=el("text",{x:gx+60,y:y,"font-size":11,class:"marginal",fill:"var(--ink)"},gleg);
820
+ t3.textContent=tr});
821
+ const notes=["● node dot = component record trust",
822
+ "entry = port of entry · ⚠n = risks · ×n = dependents",
823
+ "click a node: impact set (transitive dependents)"];
824
+ notes.forEach((s,i)=>{const t4=el("text",{x:gx+16,y:gy2+16+4*15+14+i*15,
825
+ "font-size":10.5,class:"marginal",fill:"var(--ink)"},gleg);t4.textContent=s});
826
+ // size ladder
827
+ const ladder=el("g",maponly,g);
828
+ const sx=W-320,sy=H-96;
829
+ [[34,"~25k files"],[22,"~1k"],[12,"~10"]].forEach(([r,lab],i)=>{
830
+ el("circle",{cx:sx+i*86,cy:sy,r,fill:"var(--island)",stroke:"var(--island-edge)","stroke-width":1.6},ladder);
831
+ const t4=el("text",{x:sx+i*86,y:sy+r+14,"text-anchor":"middle","font-size":10.5,class:"marginal"},ladder);
832
+ t4.textContent=lab});
833
+ // one dragon
834
+ const drag=el("g",maponly,g);
835
+ const dx=W-150,dy=H-190;
836
+ el("path",{d:`M${dx-60},${dy} q10,-14 20,0 q10,-14 20,0 q10,14 22,2 q6,-5 10,2`,
837
+ stroke:"var(--ink-soft)",fill:"none","stroke-width":1.4,opacity:.7},drag);
838
+ const dt=el("text",{x:dx+8,y:dy+26,"text-anchor":"middle",class:"dragon","font-size":12},g);
839
+ dt.textContent="hic svnt dracones";
840
+ })();
841
+
842
+ /* ---------- panel: briefing ---------- */
843
+ function trustBar(){
844
+ const colors={measured:"#2c5a68",charted:"#5d8895",reported:"#6b8f8f",doubtful:"#a4b6b1",unsurveyed:"#d9d2b6"};
845
+ let bar='<div class="trustbar">';
846
+ TRUSTS.forEach(tr=>{const n=trustCount[tr]||0;
847
+ if(n)bar+=`<div style="width:${n/DATA.entries.length*100}%;background:${colors[tr]}" title="${tr}: ${n}"></div>`});
848
+ bar+="</div><div class='trustkey'>"+TRUSTS.map(tr=>
849
+ `<span><b>${trustCount[tr]||0}</b> ${tr}</span>`).join("")+"</div>";
850
+ return bar;
851
+ }
852
+ /* Chart-controlled strings are data, never markup: every interpolation of a
853
+ chart field into HTML passes through esc, so a charted note like
854
+ `<img src=x onerror=...>` renders as text (stored-XSS guard — the room is
855
+ published, not just viewed locally). */
856
+ const esc=s=>String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
857
+ function renderBriefing(){
858
+ const b=document.getElementById("briefing"),d=document.getElementById("dossier");
859
+ d.hidden=true;b.hidden=false;
860
+ b.innerHTML=`<h2>${lex("chartroom")}</h2>
861
+ <p class="statline">${vessels.length} ${mode==="eng"?"components":"vessels"} · ${fairways.length} ${mode==="eng"?"dependencies":"fairways"} ·
862
+ ${poes.length} ${mode==="eng"?"entry points":"ports of entry"} · ${dangers.length} ${mode==="eng"?"risks":"dangers"} ·
863
+ ${beacons.length} ${mode==="eng"?"config surfaces":"beacons"} · ${lights.length} ${mode==="eng"?"API contracts":"lights"}</p>
864
+ <h3>Trust of chart</h3>${trustBar()}
865
+ ${tanglesPanel()}
866
+ ${noticesPanel()}
867
+ ${godHubs.length?`<h3>Hubs</h3><ul>${godHubs.map(v=>
868
+ `<li><b>${esc(v.name||v.id)}</b> — ${fanIn.get(v.id)} ${lex("hub")}</li>`).join("")}</ul>`:""}
869
+ <h3>Sailing Directions</h3>${BRIEF}
870
+ <p class="statline" style="margin-top:14px">Click an island for the dossier and ${lex("blastr").toLowerCase()}.</p>`;
871
+ }
872
+ /* Dependency tangles panel (chart-room: chart-room-tangles) */
873
+ function tanglesPanel(){
874
+ if(!TANGLES.length)
875
+ return `<h3>${mode==="eng"?"Dependency cycles":"Tangles"}</h3>
876
+ <p class="statline">${mode==="eng"
877
+ ?"No dependency cycles — the graph is acyclic (verified)."
878
+ :"No tangles — these waters run clear, no whirlpools charted."}</p>`;
879
+ const rows=TANGLES.map((group,i)=>`
880
+ <div class="notice"><span class="nact" style="color:var(--danger)">${mode==="eng"?"CYCLE":"WHIRLPOOL"} ${i+1}</span>
881
+ ${group.map(id=>`<code>${esc(id)}</code>`).join(" ")}</div>`).join("");
882
+ return `<h3>${mode==="eng"?`Dependency cycles (${TANGLES.length})`:`Whirlpools (${TANGLES.length})`}</h3>${rows}`;
883
+ }
884
+ /* Notices to Mariners panel (chart-room: notices-panel) */
885
+ function noticesPanel(){
886
+ const title=mode==="eng"?"Change log":"Notices to Mariners";
887
+ if(!NOTICES.length)
888
+ return `<h3>${title}</h3><p class="statline">${mode==="eng"
889
+ ?"No changes recorded — the chart is current."
890
+ :"No outstanding notices — the chart is current."}</p>`;
891
+ const colors={added:"#2c7a4f",corrected:"#2c5a68",markedStale:"#9a6a1e",retired:"#8a3226"};
892
+ const names={added:"ADDED",corrected:"CORRECTED",markedStale:"MARKED STALE",retired:"RETIRED"};
893
+ const rows=NOTICES.map(n=>`
894
+ <div class="notice"><span class="nact" style="color:${colors[n.action]||'var(--ink-soft)'}">${names[n.action]}</span>
895
+ <code>${esc(n.key)}</code>${n.note?`<div class="nnote">${esc(n.note)}</div>`:""}
896
+ ${n.anchors.length?`<ul class="anchors">${n.anchors.map(a=>`<li>${esc(a)}</li>`).join("")}</ul>`:""}
897
+ </div>`).join("");
898
+ return `<h3>${title} (${NOTICES.length})</h3>${rows}`;
899
+ }
900
+
901
+ /* ---------- dossier ---------- */
902
+ function fmtAnchor(a){if(!a)return"";
903
+ if(a.type==="manifest")return a.path+"#"+a.key;
904
+ return a.path+(a.line?":"+a.line:"")}
905
+ function trustBadge(tr){return `<span class="badge trust">${tr}</span>`}
906
+ function renderDossier(v){
907
+ const b=document.getElementById("briefing"),d=document.getElementById("dossier");
908
+ b.hidden=true;d.hidden=false;
909
+ const din=dangers.filter(x=>x.vessel===v.id);
910
+ const fin=fairways.filter(f=>f.to===v.id);
911
+ const fout=fairways.filter(f=>f.from===v.id);
912
+ const vp=poes.filter(p=>p.vessel===v.id);
913
+ const vb=beacons.filter(x=>x.vessel===v.id);
914
+ const vl=lights.filter(x=>x.vessel===v.id);
915
+ const blast=dependents(v.id);
916
+ const sec=(title,rows)=>rows.length?`<h3>${title}</h3>${rows.join("")}`:"";
917
+ const own=[v,...din,...fin,...fout,...vp,...vb,...vl];
918
+ const hasReceipt=own.some(e=>(e.anchors||[]).some(a=>a.type==="receipt"))||!!v.behavior;
919
+ const obs=hasReceipt?1:0;
920
+ const scope=[...own];
921
+ const byTrust=t=>scope.filter(e=>e.trust===t).length;
922
+ const ver=byTrust("measured");
923
+ const dec=byTrust("charted");
924
+ const cla=scope.filter(e=>e.trust==="reported"||e.trust==="doubtful")
925
+ .map(e=>`${e.kind}/${e.id}`);
926
+ d.innerHTML=`
927
+ <div class="dossier-head"><h2>${esc(v.name||v.id)}</h2>${trustBadge(v.trust)}
928
+ <button class="closebtn" type="button">✕ Close</button></div>
929
+ <p class="statline"><code>${esc(v.id)}</code> · ${filesOf(v).toLocaleString()} files
930
+ ${fanIn.get(v.id)?` · ${fanIn.get(v.id)} ${lex("hub")}`:" · no known dependents (edge of the charted waters)"}
931
+ ${v.stale?' · <b style="color:var(--accent)">pending correction</b>':""}</p>
932
+ ${v.note?`<p>${esc(v.note)}</p>`:""}
933
+ <h3>${mode==="eng"?"How we know":"How this is known"}</h3>
934
+ <div class="ledger">
935
+ <span class="lamp observed"><span class="n">${obs}</span><span class="t">OBSERVED</span></span>
936
+ <span class="lamp verified"><span class="n">${ver}</span><span class="t">VERIFIED</span></span>
937
+ <span class="lamp declared"><span class="n">${dec}</span><span class="t">DECLARED</span></span>
938
+ <span class="lamp claimed"><span class="n">${cla.length}</span><span class="t">CLAIMED</span></span>
939
+ </div>
940
+ <p class="ledger-note">observed = execution receipts · verified = source-read (measured) · declared = manifests & packaging (charted) · claimed = report or doubtful${cla.length?" — rides on "+cla.map(c=>`<code>${esc(c)}</code>`).join(", "):""}</p>
941
+ <h3>${lex("blastr")}</h3>
942
+ <p>${blast.size} ${lex("vessel").toLowerCase()}s depend on this transitively
943
+ ${blast.size?[...blast].map(id=>`<code>${esc(id)}</code>`).join(" "):""}.</p>
944
+ ${sec(mode==="eng"?"Entry points":"Ports of entry",vp.map(p=>`<p><b>${esc(p.protocol||"")}</b> — ${esc(p.note||"")}
945
+ <ul class="anchors">${(p.anchors||[]).map(a=>`<li>${esc(fmtAnchor(a))}</li>`).join("")}</ul></p>`))}
946
+ ${sec(lex("danger")+"s",din.map(x=>`<p><b>${esc(x.category)}</b> — ${esc(x.note||"")}
947
+ <ul class="anchors">${(x.anchors||[]).map(a=>`<li>${esc(fmtAnchor(a))}</li>`).join("")}</ul></p>`))}
948
+ ${sec(lex("fairway")+"s in ("+fin.length+")",fin.map(f=>`<div class="fwrow">
949
+ <span class="t"><b>${esc(f.from)}</b> <code>${esc(f.id)}</code></span><span class="tr">${esc(f.trust)}${/sounded:\s*confirmed/i.test(f.note||"")?' · <span class="snd">sounded ✓</span>':""}</span></div>`))}
950
+ ${sec(lex("fairway")+"s out ("+fout.length+")",fout.map(f=>`<div class="fwrow">
951
+ <span class="t"><b>${esc(f.to)}</b> <code>${esc(f.id)}</code></span><span class="tr">${esc(f.trust)}${/sounded:\s*confirmed/i.test(f.note||"")?' · <span class="snd">sounded ✓</span>':""}</span></div>`))}
952
+ ${sec(lex("beacon")+"s",vb.map(x=>`<p><code>${esc(x.key)}</code> (${esc(x.surface)}) — ${esc(x.note||"")}</p>`))}
953
+ ${sec(lex("light")+"s",vl.map(x=>`<p><b>${esc(x.name)}</b> — ${esc(x.note||"")}</p>`))}
954
+ ${TANGLES.some(t=>t.includes(v.id))?(()=>{const g=TANGLES.find(t=>t.includes(v.id))||[];
955
+ return sec("In a tangle with",[
956
+ `<p>${g.filter(id=>id!==v.id).map(id=>`<code>${esc(id)}</code>`).join(" ")}</p>`])})():""}
957
+ <h3>${lex("unsurveyed")}</h3>
958
+ <p>${v.behavior?"":"Runtime behavior "+lex("unsurveyed").toLowerCase()+" — vessel <code>behavior</code> is unset (nothing observed running)."}</p>`;
959
+ d.querySelector(".closebtn").addEventListener("click",()=>clearSelect());
960
+ }
961
+
962
+ /* ---------- find & focus filters (chart-room: chart-room-search) ---------- */
963
+ /* Filters dim attention, never remove data: dimmed vessels keep labels and stay clickable. */
964
+ const filterState={trust:new Set(),dangerOnly:false};
965
+ const dangerVessels=new Set(dangers.map(d=>d.vessel));
966
+ (function buildFilterUI(){
967
+ const chips=document.getElementById("trustChips");
968
+ TRUSTS.forEach(tr=>{
969
+ const b=document.createElement("button");
970
+ b.type="button";b.className="chip";b.textContent=tr;b.setAttribute("aria-pressed","false");
971
+ b.addEventListener("click",()=>{
972
+ filterState.trust.has(tr)?filterState.trust.delete(tr):filterState.trust.add(tr);
973
+ b.setAttribute("aria-pressed",filterState.trust.has(tr)?"true":"false");
974
+ applyFilters()});
975
+ chips.appendChild(b)});
976
+ const dc=document.getElementById("dangerChip");
977
+ dc.addEventListener("click",()=>{filterState.dangerOnly=!filterState.dangerOnly;
978
+ dc.setAttribute("aria-pressed",String(filterState.dangerOnly));applyFilters()});
979
+ document.getElementById("resetChip").addEventListener("click",()=>{
980
+ filterState.trust.clear();filterState.dangerOnly=false;
981
+ document.querySelectorAll("#filters .chip[aria-pressed]").forEach(c=>c.setAttribute("aria-pressed","false"));
982
+ applyFilters()});
983
+ document.getElementById("findBox").addEventListener("change",ev=>{
984
+ const q=ev.target.value.trim().toLowerCase();if(!q)return;
985
+ const best=vessels.find(v=>v.id.toLowerCase()===q)
986
+ ||vessels.find(v=>(v.name||"").toLowerCase().startsWith(q)||v.id.toLowerCase().startsWith(q))
987
+ ||vessels.find(v=>(v.name||"").toLowerCase().includes(q)||v.id.toLowerCase().includes(q));
988
+ if(best)select(best.id);ev.target.value=""});
989
+ })();
990
+ function vesselBright(id){
991
+ const v=byId.get(id);if(!v)return true;
992
+ if(filterState.trust.size&&!filterState.trust.has(v.trust))return false;
993
+ if(filterState.dangerOnly&&!dangerVessels.has(id))return false;
994
+ return true}
995
+ function applyFilters(){
996
+ if(!filterState.trust.size&&!filterState.dangerOnly){
997
+ document.querySelectorAll("#map .dimmed").forEach(n=>n.classList.remove("dimmed"));
998
+ return}
999
+ const anyActive=selected!==null; // interplay handled by blasting rules on top
1000
+ document.querySelectorAll(".island").forEach(g=>
1001
+ g.classList.toggle("dimmed",anyActive?false:!vesselBright(g.dataset.v)));
1002
+ document.querySelectorAll(".gnode").forEach(g=>
1003
+ g.classList.toggle("dimmed",!vesselBright(g.dataset.v)));
1004
+ }
1005
+
1006
+ /* ---------- selection & blast radius ---------- */
1007
+ let selected=null;
1008
+ function select(id){
1009
+ hidePlate();selected=id;const map=document.getElementById("map");
1010
+ map.classList.add("blasting");
1011
+ const blast=dependents(id);blast.add(id);
1012
+ document.querySelectorAll(".island,.gnode").forEach(g=>{
1013
+ g.classList.toggle("lit",g.dataset.v===id);
1014
+ g.classList.toggle("lit2",blast.has(g.dataset.v)&&g.dataset.v!==id)});
1015
+ document.querySelectorAll(".lane,.gedge").forEach(p=>{
1016
+ const f=p.dataset.from,t=p.dataset.to;
1017
+ p.classList.toggle("lit",t===id&&blast.has(f)||(f===id));
1018
+ p.classList.toggle("lit2",blast.has(f)&&blast.has(t));
1019
+ p.style.opacity=blast.has(f)&&blast.has(t)?"": (f===id?"":"0.15")});
1020
+ renderDossier(byId.get(id));
1021
+ }
1022
+ function clearSelect(){
1023
+ selected=null;const map=document.getElementById("map");
1024
+ map.classList.remove("blasting");
1025
+ document.querySelectorAll(".lit,.lit2").forEach(g=>g.classList.remove("lit","lit2"));
1026
+ document.querySelectorAll(".lane,.gedge").forEach(p=>p.style.opacity="");
1027
+ renderBriefing();
1028
+ }
1029
+ document.getElementById("map").addEventListener("click",ev=>{
1030
+ if(ev.target.id==="sea"||ev.target.id==="seaTex")clearSelect()});
1031
+
1032
+ /* lit styling */
1033
+ const st=document.createElement("style");
1034
+ st.textContent=`.island.lit .coast{stroke:var(--accent);stroke-width:4.4}
1035
+ .island.lit2 .coast{stroke-width:3.4}
1036
+ .gnode.lit .box{stroke:var(--accent);stroke-width:3}
1037
+ .gnode.lit2 .box{stroke-width:2.4}
1038
+ .lane.lit{stroke-width:4!important}.lane.lit2{stroke-width:3!important;opacity:1!important}
1039
+ .gedge.lit{stroke-width:4!important}.gedge.lit2{stroke-width:3!important;opacity:1!important}`;
1040
+ document.head.appendChild(st);
1041
+
1042
+ /* ---------- pan & zoom ---------- */
1043
+ (function(){
1044
+ const world=document.getElementById("viewport"),wrap=document.getElementById("mapwrap");
1045
+ const VB={x:0,y:0,w:1880,h:1340};let scale=1,vx=VB.x,vy=VB.y,vw=VB.w,vh=VB.h;
1046
+ function apply(){world.setAttribute("transform",
1047
+ `translate(${vx},${vy}) scale(${vw/VB.w})`);
1048
+ labelZoom()}
1049
+ function labelZoom(){const s=VB.w/vw;
1050
+ document.querySelectorAll("text.lbl.small").forEach(t=>t.style.display=s<0.55?"none":"");
1051
+ document.querySelectorAll("text.lbl.mid").forEach(t=>t.style.display=s<0.33?"none":"")}
1052
+ wrap.addEventListener("wheel",ev=>{ev.preventDefault();
1053
+ const r=wrap.getBoundingClientRect();
1054
+ const px=vx+(ev.clientX-r.left)/r.width*vw, py=vy+(ev.clientY-r.top)/r.height*vh;
1055
+ const k=ev.deltaY>0?1.12:0.89;
1056
+ const nw=Math.min(Math.max(vw*k,300),2600),nh=nw*VB.h/VB.w;
1057
+ vx=px-(px-vx)*nw/vw;vy=py-(py-vy)*nh/vh;vw=nw;vh=nh;clamp();apply()},{passive:false});
1058
+ function clamp(){vx=Math.min(Math.max(vx,-800),2200);vy=Math.min(Math.max(vy,-600),1600)}
1059
+ let drag=null;
1060
+ wrap.addEventListener("pointerdown",ev=>{if(ev.button!==0)return;
1061
+ drag={x:ev.clientX,y:ev.clientY};wrap.classList.add("panning")});
1062
+ wrap.addEventListener("pointermove",ev=>{if(!drag)return;
1063
+ const r=wrap.getBoundingClientRect();
1064
+ vx-=(ev.clientX-drag.x)/r.width*vw;vy-=(ev.clientY-drag.y)/r.height*vh;
1065
+ drag={x:ev.clientX,y:ev.clientY};clamp();apply()});
1066
+ window.addEventListener("pointerup",()=>{drag=null;wrap.classList.remove("panning")});
1067
+ window.addEventListener("keydown",ev=>{if(ev.key==="Escape")clearSelect()});
1068
+ apply();
1069
+ })();
1070
+
1071
+ /* ---------- mode toggle ---------- */
1072
+ function applyMode(){
1073
+ document.body.classList.toggle("eng",mode==="eng");
1074
+ document.getElementById("modeBtn").textContent=mode==="eng"?"Nautical mode":"Engineering mode";
1075
+ document.getElementById("title").textContent=lex("chartroom");
1076
+ localStorage.setItem("chartroom.mode",mode);
1077
+ document.querySelectorAll("[data-lex]").forEach(t=>{t.textContent=lex(t.dataset.lex)});
1078
+ const c=document.querySelector("[data-redynamic]");
1079
+ if(c)c.textContent=`Expedition ${META.expedition} · ${vessels.length} ${lex("vessel").toLowerCase()}s · ${fairways.length} ${lex("fairway").toLowerCase()}s · ${dangers.length} ${lex("danger").toLowerCase()}s`;
1080
+ if(selected)renderDossier(byId.get(selected));else renderBriefing();
1081
+ }
1082
+ document.getElementById("modeBtn").addEventListener("click",()=>{
1083
+ mode=mode==="nautical"?"eng":"nautical";applyMode()});
1084
+
1085
+ /* ---------- boot ---------- */
1086
+ document.getElementById("province").textContent=META.targetPath;
1087
+ applyMode();
1088
+ </script>
1089
+ </body>
1090
+ </html>