forma-arch 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -5
- package/bin/forma.mjs +2 -1
- package/lib/check.mjs +48 -1
- package/lib/cluster.mjs +44 -0
- package/lib/describe.mjs +90 -0
- package/lib/doc.mjs +35 -34
- package/lib/enrich.mjs +149 -0
- package/lib/gen.mjs +124 -9
- package/lib/init.mjs +15 -3
- package/lib/render.mjs +74 -0
- package/lib/schema/c4-model.schema.json +27 -0
- package/lib/verify.mjs +79 -0
- package/lib/viewer/c4-hologram.html +120 -32
- package/package.json +1 -1
|
@@ -64,6 +64,7 @@ svg{width:100%;height:100%;display:block}
|
|
|
64
64
|
.edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
|
|
65
65
|
.edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
|
|
66
66
|
.edgehit{stroke:transparent;stroke-width:14;fill:none;cursor:help}
|
|
67
|
+
.elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none}
|
|
67
68
|
@keyframes dash{to{stroke-dashoffset:-24}}
|
|
68
69
|
.nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
|
|
69
70
|
@keyframes pul{0%,100%{filter:drop-shadow(0 0 8px rgba(62,231,255,.5))}50%{filter:drop-shadow(0 0 18px rgba(62,231,255,.95))}}
|
|
@@ -132,12 +133,14 @@ html[data-skin="blueprint"] button.on{text-shadow:none}
|
|
|
132
133
|
<button id="bnow" class="on" type="button"></button>
|
|
133
134
|
<button id="btgt" type="button"></button>
|
|
134
135
|
<button id="brf" type="button"></button>
|
|
136
|
+
<button id="blbl" type="button"></button>
|
|
135
137
|
<button id="brl" type="button"></button>
|
|
136
138
|
<span class="menuwrap">
|
|
137
139
|
<button id="pexp" type="button" aria-haspopup="true" aria-expanded="false"></button>
|
|
138
140
|
<span id="pmenu" role="menu">
|
|
139
141
|
<button id="pexpSvg" type="button" role="menuitem"></button>
|
|
140
142
|
<button id="pexpPng" type="button" role="menuitem"></button>
|
|
143
|
+
<button id="pexpLayout" type="button" role="menuitem"></button>
|
|
141
144
|
<button id="pexpPrint" type="button" role="menuitem"></button>
|
|
142
145
|
</span>
|
|
143
146
|
</span>
|
|
@@ -165,9 +168,10 @@ var STRINGS={
|
|
|
165
168
|
en:{
|
|
166
169
|
subhelp:"click = explain (any level) · double-click or [+] = drill in · drag to move · hover an arrow = label · ESC/BACK",
|
|
167
170
|
back:"← BACK", now:"CURRENT STATE", tgt:"TARGET STATE", reverify:"RE-VERIFY",
|
|
171
|
+
labels:"LABELS", labelsTitle:"arrow labels (auto-off above 14 arrows)",
|
|
168
172
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"re-arrange",
|
|
169
|
-
printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", print:"Print",
|
|
170
|
-
legDone:"DONE", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
|
|
173
|
+
printExport:"PRINT / EXPORT", exportSvg:"Export SVG", exportPng:"Export PNG", exportLayout:"Export layout JSON", print:"Print",
|
|
174
|
+
legDone:"DONE (proven)", legProg:"IN PROGRESS", legNext:"NEXT", legPlan:"PLAN ONLY", legProb:"PROBLEM",
|
|
171
175
|
legHint:"— animated arrow = active flow · dashed = to build",
|
|
172
176
|
crumbRoot:"Context", crumbLevel:"C4", lvlContext:"CONTEXT",
|
|
173
177
|
lvlNames:{context:"Context",container:"Container",component:"Components",leaf:"Leaves"},
|
|
@@ -175,7 +179,7 @@ var STRINGS={
|
|
|
175
179
|
detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
|
|
176
180
|
detTarget:"TARGET",
|
|
177
181
|
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
|
|
178
|
-
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology",
|
|
182
|
+
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
|
|
179
183
|
errEmpty:"Model unreachable or empty.",
|
|
180
184
|
errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
|
|
181
185
|
errUnavail:"Model unavailable."
|
|
@@ -183,9 +187,10 @@ var STRINGS={
|
|
|
183
187
|
it:{
|
|
184
188
|
subhelp:"click = spiega (ogni livello) · doppio-click o [+] = entra · trascina per spostare · hover su una freccia = etichetta · ESC/BACK",
|
|
185
189
|
back:"← BACK", now:"STATO ATTUALE", tgt:"STATO FINITO", reverify:"RE-VERIFICA",
|
|
190
|
+
labels:"ETICHETTE", labelsTitle:"etichette sugli archi (spente sopra i 14 archi)",
|
|
186
191
|
resetLayout:"RESET LAYOUT", resetLayoutTitle:"ridisponi",
|
|
187
|
-
printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", print:"Stampa",
|
|
188
|
-
legDone:"FATTO", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
|
|
192
|
+
printExport:"STAMPA / ESPORTA", exportSvg:"Esporta SVG", exportPng:"Esporta PNG", exportLayout:"Esporta layout JSON", print:"Stampa",
|
|
193
|
+
legDone:"FATTO (provato)", legProg:"IN CORSO", legNext:"PROSSIMO", legPlan:"SOLO PROGETTO", legProb:"PROBLEMA",
|
|
189
194
|
legHint:"— freccia animata = flusso attivo · tratteggio = da costruire",
|
|
190
195
|
crumbRoot:"Contesto", crumbLevel:"C4", lvlContext:"CONTESTO",
|
|
191
196
|
lvlNames:{context:"Contesto",container:"Container",component:"Componenti",leaf:"Foglie"},
|
|
@@ -193,7 +198,7 @@ var STRINGS={
|
|
|
193
198
|
detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
|
|
194
199
|
detTarget:"TARGET",
|
|
195
200
|
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
|
|
196
|
-
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology",
|
|
201
|
+
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
|
|
197
202
|
errEmpty:"Modello non raggiungibile o vuoto.",
|
|
198
203
|
errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
|
|
199
204
|
errUnavail:"Modello non disponibile."
|
|
@@ -207,13 +212,18 @@ function pickLang(model){
|
|
|
207
212
|
return STRINGS[cand]?cand:"en";
|
|
208
213
|
}
|
|
209
214
|
var CATALOG_THRESHOLD=24; // group of same-category siblings above this collapses to one catalogue node
|
|
215
|
+
var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed labels would knot the view
|
|
216
|
+
var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
|
|
210
217
|
var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
|
|
211
218
|
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
|
|
212
|
-
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={};
|
|
219
|
+
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false;
|
|
213
220
|
function $(i){return document.getElementById(i)}
|
|
214
221
|
function esc(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,""")}
|
|
215
222
|
function setSkin(s){var k=SKIN_IDS.indexOf(s)>-1?s:"holo";document.documentElement.dataset.skin=k;var el=$("skin");if(el)el.value=k;}
|
|
216
|
-
|
|
223
|
+
// Layout key = the id of the level's parent ("root" at the top). Stable and documentable: it is
|
|
224
|
+
// exactly the key used by meta.layout hints and by the exported layout JSON.
|
|
225
|
+
function keyOf(){return stack[stack.length-1]||"root";}
|
|
226
|
+
function hintsFor(k){return (M&&M.meta&&M.meta.layout&&M.meta.layout[k])||null;}
|
|
217
227
|
function childrenOf(pid){var o=[];for(var i=0;i<M.nodes.length;i++){var n=M.nodes[i];if((pid==null&&!n.parent)||n.parent===pid)o.push(n);}return o;}
|
|
218
228
|
// FEATURE #4: collapse large homogeneous catalogs.
|
|
219
229
|
// Given real children, returns render items where any group of >CATALOG_THRESHOLD
|
|
@@ -260,8 +270,25 @@ function nameOf(id){var n=node(id);return n?n.name:id;}
|
|
|
260
270
|
function srcOf(n){if(n.verify){if(typeof n.verify==="string")return n.verify;if(n.verify.source)return n.verify.source;if(n.verify.ref)return n.verify.ref;if(n.verify.issue)return "#"+n.verify.issue;}
|
|
261
271
|
var ev=(n.evidence||[]).filter(function(e){return e.type==="doc"||e.type==="path";})[0];return ev?ev.ref:((M.meta&&M.meta.verifyMethod)||"");}
|
|
262
272
|
|
|
263
|
-
|
|
264
|
-
|
|
273
|
+
var NW=228,NH=118; // NH fits 3 description lines without hitting the [+] DRILL
|
|
274
|
+
// Curated coordinates from meta.layout win; everything else keeps the automatic arrangement,
|
|
275
|
+
// placed BELOW the pinned block so a partial hint set can never overlap generated nodes.
|
|
276
|
+
function seedLayout(kids,hints){
|
|
277
|
+
var pinned=[],free=[],i;
|
|
278
|
+
for(i=0;i<kids.length;i++)(hints&&hints[kids[i].id]?pinned:free).push(kids[i]);
|
|
279
|
+
if(!pinned.length)return autoLayout(kids);
|
|
280
|
+
var pos={},maxX=0,maxY=0,h;
|
|
281
|
+
for(i=0;i<pinned.length;i++){var q=hints[pinned[i].id];
|
|
282
|
+
h={x:+q.x||0,y:+q.y||0,w:+q.w||NW,h:+q.h||NH};pos[pinned[i].id]=h;
|
|
283
|
+
maxX=Math.max(maxX,h.x+h.w);maxY=Math.max(maxY,h.y+h.h);}
|
|
284
|
+
var W=Math.max(1020,maxX+64),H=Math.max(560,maxY+58);
|
|
285
|
+
if(free.length){var sub=autoLayout(free),oy=maxY+58;
|
|
286
|
+
for(i=0;i<free.length;i++){var p=sub.pos[free[i].id];pos[free[i].id]={x:p.x,y:oy+p.y,w:p.w,h:p.h};}
|
|
287
|
+
W=Math.max(W,sub.W);H=Math.max(H,oy+sub.H);}
|
|
288
|
+
return {W:W,H:H,pos:pos};
|
|
289
|
+
}
|
|
290
|
+
function autoLayout(kids){
|
|
291
|
+
var pos={},i,hub=null;
|
|
265
292
|
for(i=0;i<kids.length;i++)if(kids[i].kind==="system"){hub=kids[i];break;}
|
|
266
293
|
if(hub&&kids.length<=10){
|
|
267
294
|
var W=1080,H=660,cx=W/2,cy=H/2;
|
|
@@ -280,9 +307,9 @@ function seedLayout(kids){
|
|
|
280
307
|
pos[kids[i].id]={x:offx+c*(NW+gx),y:py+r*(NH+gy),w:NW,h:NH};}
|
|
281
308
|
return {W:W,H:H,pos:pos};
|
|
282
309
|
}
|
|
283
|
-
function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids);
|
|
310
|
+
function layoutFor(kids){var k=keyOf();if(!POS[k])POS[k]=seedLayout(kids,hintsFor(k));
|
|
284
311
|
// make sure every current kid has a slot (model may have changed)
|
|
285
|
-
var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids);POS[k]=s;return s;}return lay;}
|
|
312
|
+
var lay=POS[k];for(var i=0;i<kids.length;i++)if(!lay.pos[kids[i].id]){var s=seedLayout(kids,hintsFor(k));POS[k]=s;return s;}return lay;}
|
|
286
313
|
|
|
287
314
|
function draw(animate){
|
|
288
315
|
if(!M)return;
|
|
@@ -300,11 +327,17 @@ function draw(animate){
|
|
|
300
327
|
var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
301
328
|
+'<defs><marker id="ar" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edge")+'"/></marker>'
|
|
302
329
|
+'<marker id="arp" markerWidth="9" markerHeight="7" refX="9" refY="3.5" orient="auto"><polygon points="0 0,9 3.5,0 7" fill="'+cssv("--edgeplan")+'"/></marker></defs>';
|
|
330
|
+
// fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
|
|
331
|
+
// is always there). A level with 40 arrows would be unreadable with every label painted at once.
|
|
332
|
+
var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
|
|
333
|
+
var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
|
|
303
334
|
for(var i=0;i<edges.length;i++){
|
|
304
335
|
var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
|
|
305
|
-
var
|
|
306
|
-
s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+d+'"/>';
|
|
307
|
-
s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+d+'"/>';
|
|
336
|
+
var e=edgePath(a,b),cl=(edges[i].estatus==="to-build"?"plan":"flow");
|
|
337
|
+
s+='<path class="edge '+cl+'" data-e="'+i+'" d="'+e.d+'"/>';
|
|
338
|
+
s+='<path class="edgehit" data-e="'+i+'" data-label="'+esc(edges[i].label||"")+'" d="'+e.d+'"/>';
|
|
339
|
+
if(showLbl&&edges[i].label){var lb=String(edges[i].label);if(lb.length>28)lb=lb.slice(0,27)+"…";
|
|
340
|
+
s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
|
|
308
341
|
}
|
|
309
342
|
for(var j=0;j<kids.length;j++){
|
|
310
343
|
var n=kids[j],p=pos[n.id];
|
|
@@ -313,18 +346,21 @@ function draw(animate){
|
|
|
313
346
|
var isCat=!!n.__catalog,dr=!isCat&&hasKids(n.id),cls="nd s-"+st+(dr?" drill":"")+(isCat?" cat":"")+(pulse?" pulse":"");
|
|
314
347
|
var desc=isCat?(n.count+" "+STR.rosterCount):(mode==="target"?(n.target||n.func||""):(n.current||n.func||n.tech||""));
|
|
315
348
|
var badge=isCat?String(n.count):(mode==="target"?STR.detTarget:(n.statusWord||(n.completion!=null?n.completion+"%":(n.tech||""))));
|
|
316
|
-
|
|
349
|
+
// the title runs until the badge, so reserve the badge's actual width (9px text ≈ 5.4px/char)
|
|
350
|
+
// instead of a flat 70px — a fixed reserve clipped names that had all the room they needed
|
|
351
|
+
var tmax=Math.floor((p.w-34-String(badge).length*5.4)/6.7),tt=n.name;
|
|
352
|
+
if(String(tt).length>tmax)tt=String(tt).slice(0,Math.max(1,tmax-1))+"…";
|
|
317
353
|
s+='<g class="'+cls+'" data-id="'+esc(n.id)+'">'
|
|
318
354
|
+'<rect x="'+p.x+'" y="'+p.y+'" width="'+p.w+'" height="'+p.h+'" rx="10"/>'
|
|
319
355
|
+'<text class="kk" x="'+(p.x+13)+'" y="'+(p.y+16)+'">'+esc(n.category||n.kind||"")+'</text>'
|
|
320
356
|
+'<text class="tt" x="'+(p.x+13)+'" y="'+(p.y+33)+'">'+esc(tt)+'</text>'
|
|
321
357
|
+'<text class="pp" x="'+(p.x+p.w-13)+'" y="'+(p.y+16)+'" text-anchor="end">'+esc(badge)+'</text>';
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
358
|
+
// Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
|
|
359
|
+
// and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
|
|
360
|
+
// print through [+] DRILL. wrapDesc is pure and clamps every line to the box width.
|
|
361
|
+
var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(3,Math.floor((floorY-51)/12.5)+1);
|
|
362
|
+
var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
|
|
363
|
+
for(var q=0;q<dlines.length;q++)s+='<text class="dd" x="'+(p.x+13)+'" y="'+(p.y+51+q*12.5)+'">'+esc(dlines[q])+'</text>';
|
|
328
364
|
if(dr)s+='<rect class="drillhit" data-drill="1" x="'+(p.x+p.w-96)+'" y="'+(p.y+p.h-25)+'" width="88" height="20" rx="5"/>'
|
|
329
365
|
+'<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[+] DRILL</text>';
|
|
330
366
|
else if(isCat)s+='<text class="plus" x="'+(p.x+p.w-13)+'" y="'+(p.y+p.h-9)+'" text-anchor="end">[≡] '+esc(String(n.count))+'</text>';
|
|
@@ -337,18 +373,37 @@ function draw(animate){
|
|
|
337
373
|
st2.classList.toggle("anim",animate!==false);
|
|
338
374
|
// breadcrumb
|
|
339
375
|
var chain=[],c=pid;while(c){var nn=node(c);chain.unshift(nn);c=nn?nn.parent:null;}
|
|
340
|
-
var
|
|
341
|
-
var
|
|
376
|
+
var lvlKey=pid?(kids[0]&&kids[0].level):"context";
|
|
377
|
+
var lvl=pid?levelName(lvlKey):STR.lvlContext;
|
|
378
|
+
// C4 notation: number the level by its position in the model's own `levels` list (L1 = context).
|
|
379
|
+
var lvlIdx=((M.levels||[]).indexOf(lvlKey));
|
|
380
|
+
var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
342
381
|
for(var w=0;w<chain.length;w++)cb+=" › "+(w===chain.length-1?"<b>"+esc(chain[w].name)+"</b>":"<a data-d='"+w+"'>"+esc(chain[w].name)+"</a>");
|
|
343
382
|
$("crumb").innerHTML=cb;
|
|
344
383
|
$("back").disabled=stack.length<2;
|
|
345
384
|
$("detail").style.display="none";
|
|
346
385
|
}
|
|
386
|
+
// Greedy word wrap into at most `max` lines of `cpl` characters. Every returned line is clamped to
|
|
387
|
+
// cpl — an unbreakable token (a long class name, a URL) would otherwise paint outside the box, and
|
|
388
|
+
// there is no clip-path on the node. The last line carries the ellipsis when text was dropped,
|
|
389
|
+
// rather than spending a whole line on it (on a short curated box that was half the visible text).
|
|
390
|
+
function wrapDesc(desc,cpl,max){
|
|
391
|
+
if(max<1||cpl<2)return [];
|
|
392
|
+
var words=String(desc==null?"":desc).split(" "),all=[],line="",i;
|
|
393
|
+
for(i=0;i<words.length;i++){if(line&&(line+words[i]).length>cpl){all.push(line.trim());line="";}line+=words[i]+" ";}
|
|
394
|
+
if(line.trim())all.push(line.trim());
|
|
395
|
+
var out=all.slice(0,max),dropped=all.length>out.length;
|
|
396
|
+
if(dropped&&out.length)out[out.length-1]=out[out.length-1]+"…";
|
|
397
|
+
for(i=0;i<out.length;i++)if(out[i].length>cpl)out[i]=out[i].slice(0,cpl-1)+"…";
|
|
398
|
+
return out;
|
|
399
|
+
}
|
|
400
|
+
// Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
|
|
401
|
+
// control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
|
|
347
402
|
function edgePath(a,b){
|
|
348
403
|
var ax=a.x+a.w/2,ay=a.y+a.h/2,bx=b.x+b.w/2,by=b.y+b.h/2,dx=bx-ax,dy=by-ay,L=Math.sqrt(dx*dx+dy*dy)||1;
|
|
349
404
|
var x1=ax+(a.w/2+10)*dx/L,y1=ay+(a.h/2+10)*dy/L,x2=bx-(b.w/2+12)*dx/L,y2=by-(b.h/2+12)*dy/L;
|
|
350
|
-
var
|
|
351
|
-
return "M"+x1+","+y1+" Q"+
|
|
405
|
+
var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
|
|
406
|
+
return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
|
|
352
407
|
}
|
|
353
408
|
function levelName(l){return (STR.lvlNames&&STR.lvlNames[l])||l||"—";}
|
|
354
409
|
function cssv(v){try{return getComputedStyle(document.documentElement).getPropertyValue(v).trim()||"#2b6e8a";}catch(e){return "#2b6e8a";}}
|
|
@@ -456,7 +511,15 @@ function wire(){
|
|
|
456
511
|
document.addEventListener("keydown",function(e){if(e.key==="Escape"&&stack.length>1){stack.pop();draw(true);}});
|
|
457
512
|
$("bnow").addEventListener("click",function(){mode="now";$("bnow").className="on";$("btgt").className="";draw(false);});
|
|
458
513
|
$("btgt").addEventListener("click",function(){mode="target";$("btgt").className="on";$("bnow").className="";draw(false);});
|
|
459
|
-
$("brf").addEventListener("click",function(){
|
|
514
|
+
$("brf").addEventListener("click",function(){
|
|
515
|
+
// Served model ⇒ actually re-read it (a `forma verify` run in the terminal shows up here without
|
|
516
|
+
// losing the level, the layout or the mode). Injected/offline ⇒ re-stamp, and say why.
|
|
517
|
+
if(!FETCHED){stamp(STR.staticSrc);return;}
|
|
518
|
+
fetch("./c4-model.json?ts="+(new Date()).getTime()).then(function(r){return r.json();})
|
|
519
|
+
.then(function(m){if(m&&m.nodes&&m.nodes.length){M=m;stamp("");draw(false);}else stamp(STR.staticSrc);})
|
|
520
|
+
.catch(function(){stamp(STR.staticSrc);});
|
|
521
|
+
});
|
|
522
|
+
$("blbl").addEventListener("click",function(){LABELS=!($("blbl").className==="on");draw(false);});
|
|
460
523
|
$("brl").addEventListener("click",function(){delete POS[keyOf()];draw(true);});
|
|
461
524
|
$("skin").innerHTML=SKINS.map(function(s){return '<option value="'+s[0]+'">'+s[1]+'</option>';}).join("");
|
|
462
525
|
$("skin").addEventListener("change",function(e){setSkin(e.target.value);});
|
|
@@ -467,7 +530,9 @@ function applyStrings(){
|
|
|
467
530
|
function set(id,txt){var e=$(id);if(e)e.textContent=txt;}
|
|
468
531
|
set("subhelp",STR.subhelp);set("back",STR.back);set("bnow",STR.now);set("btgt",STR.tgt);
|
|
469
532
|
set("brf",STR.reverify);set("brl",STR.resetLayout);var brl=$("brl");if(brl)brl.title=STR.resetLayoutTitle;
|
|
470
|
-
set("
|
|
533
|
+
set("blbl",STR.labels);var bl=$("blbl");if(bl)bl.title=STR.labelsTitle;
|
|
534
|
+
set("pexp",STR.printExport);set("pexpSvg",STR.exportSvg);set("pexpPng",STR.exportPng);
|
|
535
|
+
set("pexpLayout",STR.exportLayout);set("pexpPrint",STR.print);
|
|
471
536
|
set("lg-done",STR.legDone);set("lg-prog",STR.legProg);set("lg-next",STR.legNext);
|
|
472
537
|
set("lg-plan",STR.legPlan);set("lg-prob",STR.legProb);set("lg-hint",STR.legHint);
|
|
473
538
|
}
|
|
@@ -486,7 +551,7 @@ function collectSvgCss(){
|
|
|
486
551
|
if(!rules)continue;
|
|
487
552
|
for(j=0;j<rules.length;j++){var r=rules[j],t=r.cssText||"";
|
|
488
553
|
// keep rules that target svg-internal classes/elements + the color vars on :root/html
|
|
489
|
-
if(/\.(nd|edge|edgehit|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
|
|
554
|
+
if(/\.(nd|edge|edgehit|elbl|s-done|s-prog|s-next|s-plan|s-prob|mode-t|tt|kk|dd|pp|plus|cat)\b/.test(t)
|
|
490
555
|
||/(^|[\s,])svg\b/.test(t)||/:root|html\[data-skin/.test(t)){
|
|
491
556
|
// drop animations for a static export (keepdefs stable)
|
|
492
557
|
out.push(t);
|
|
@@ -531,6 +596,17 @@ function triggerDownload(blob,filename){
|
|
|
531
596
|
a.href=url;a.download=filename;document.body.appendChild(a);a.click();
|
|
532
597
|
document.body.removeChild(a);setTimeout(function(){URL.revokeObjectURL(url);},4000);
|
|
533
598
|
}
|
|
599
|
+
// Every level you have arranged, in the exact shape meta.layout expects — drag the boxes, export,
|
|
600
|
+
// paste into the topology's "layout" and `gen` bakes it in. No storage: the repo is the memory.
|
|
601
|
+
function exportLayout(){
|
|
602
|
+
var out={},k,id,p;
|
|
603
|
+
for(k in POS){if(!POS.hasOwnProperty(k))continue;
|
|
604
|
+
var lvl={},any=false;
|
|
605
|
+
for(id in POS[k].pos){if(!POS[k].pos.hasOwnProperty(id))continue;
|
|
606
|
+
p=POS[k].pos[id];lvl[id]={x:Math.round(p.x),y:Math.round(p.y),w:Math.round(p.w),h:Math.round(p.h)};any=true;}
|
|
607
|
+
if(any)out[k]=lvl;}
|
|
608
|
+
triggerDownload(new Blob([JSON.stringify({layout:out},null,2)+"\n"],{type:"application/json"}),exportBaseName()+"-layout.json");
|
|
609
|
+
}
|
|
534
610
|
function exportSvg(){
|
|
535
611
|
var s=serializeSvg();if(!s)return;
|
|
536
612
|
triggerDownload(new Blob([s.xml],{type:"image/svg+xml;charset=utf-8"}),exportBaseName()+".svg");
|
|
@@ -564,23 +640,34 @@ function wireExport(){
|
|
|
564
640
|
document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});
|
|
565
641
|
var sv=$("pexpSvg");if(sv)sv.addEventListener("click",function(){close();exportSvg();});
|
|
566
642
|
var pn=$("pexpPng");if(pn)pn.addEventListener("click",function(){close();exportPng();});
|
|
643
|
+
var lay=$("pexpLayout");if(lay)lay.addEventListener("click",function(){close();exportLayout();});
|
|
567
644
|
var pr=$("pexpPrint");if(pr)pr.addEventListener("click",function(){close();window.print();});
|
|
568
645
|
}
|
|
569
646
|
function moveTip(ev){var st=$("stage").getBoundingClientRect(),t=$("etip");
|
|
570
647
|
var x=ev.clientX-st.left+12,y=ev.clientY-st.top+12;
|
|
571
648
|
t.style.left=Math.min(x,st.width-t.offsetWidth-6)+"px";t.style.top=Math.min(y,st.height-t.offsetHeight-6)+"px";}
|
|
572
649
|
|
|
650
|
+
// Fact-base line. `suffix` marks a RE-VERIFY that could not re-read the model (injected/offline).
|
|
651
|
+
function stamp(suffix){
|
|
652
|
+
var m=(M&&M.meta)||{};
|
|
653
|
+
$("stamp").textContent=STR.stampBase+" "+(m.verifiedAt||(M&&M.generatedAt)||(M&&M.source&&M.source.commit)||"—")
|
|
654
|
+
+" · "+STR.stampMethod+" "+(m.verifyMethod||STR.stampVerify)+(suffix?" "+suffix:"");
|
|
655
|
+
}
|
|
573
656
|
function boot(model){
|
|
574
657
|
M=model;
|
|
575
658
|
LANG=pickLang(M);STR=STRINGS[LANG];
|
|
576
659
|
try{document.documentElement.setAttribute("lang",LANG);}catch(e){}
|
|
577
660
|
applyStrings();
|
|
578
661
|
if(!M||!M.nodes||!M.nodes.length){$("err").style.display="block";$("err").textContent=STR.errEmpty;return;}
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
662
|
+
var ttl=(M.meta&&M.meta.title)||((M.source&&M.source.repo)?"Forma · "+M.source.repo:null);
|
|
663
|
+
if(ttl){$("title").textContent=ttl;try{document.title=ttl;}catch(e){}}
|
|
664
|
+
stamp("");
|
|
665
|
+
setSkin((new URLSearchParams(location.search).get("skin"))||window.__C4_SKIN__||(M.meta&&M.meta.skin)||"holo");
|
|
582
666
|
stack=[null];mode="now";POS={};
|
|
583
667
|
draw(true);
|
|
668
|
+
// Minimal hook for embedders (the Cowork artifact re-verifies through its own MCP bridge and
|
|
669
|
+
// needs to push the result back in without forking the viewer).
|
|
670
|
+
window.__C4_API__={model:function(){return M;},redraw:function(){draw(false);},stamp:function(t){$("stamp").textContent=t;}};
|
|
584
671
|
}
|
|
585
672
|
function start(){
|
|
586
673
|
// Pick language from URL/global up-front so header + errors render even if the model never loads.
|
|
@@ -589,6 +676,7 @@ function start(){
|
|
|
589
676
|
applyStrings();
|
|
590
677
|
wire();
|
|
591
678
|
if(window.__C4_MODEL__){boot(window.__C4_MODEL__);return;}
|
|
679
|
+
FETCHED=true; // the model has a live URL ⇒ RE-VERIFY can re-read it instead of only re-stamping
|
|
592
680
|
try{fetch("./c4-model.json").then(function(r){return r.json();}).then(boot).catch(function(){
|
|
593
681
|
$("err").style.display="block";$("err").textContent=STR.errNotFound;});}
|
|
594
682
|
catch(e){$("err").style.display="block";$("err").textContent=STR.errUnavail;}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forma-arch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Forma — present your architecture instead of slides. An interactive, stack-agnostic C4 explorer (context → container → component → leaf) generated from your code and kept true to it by a deterministic drift check.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"architecture",
|