forma-arch 0.4.0 → 0.5.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 +8 -1
- package/lib/viewer/c4-hologram.html +70 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ them.
|
|
|
8
8
|
Forma is the companion to `arbiter`: arbiter governs the process, Forma shows the system and
|
|
9
9
|
guarantees the picture matches reality.
|
|
10
10
|
|
|
11
|
+
**[▶ Live demo](https://LucaDominici.github.io/forma/)** — forma's own architecture, generated by
|
|
12
|
+
forma from this repo on every push to `main`.
|
|
13
|
+
|
|
11
14
|
## Why
|
|
12
15
|
|
|
13
16
|
A hand-drawn architecture diagram is stale the moment code changes. Forma walks your source for the
|
|
@@ -81,7 +84,11 @@ The viewer is a live C4 map, not a static picture:
|
|
|
81
84
|
- **Drag boxes** to lay out the view your way; **RESET LAYOUT** restores the arrangement (your curated hints if the topology has them, the automatic one otherwise). To keep a layout, drag it, pick **Export layout JSON**, and paste the result under `"layout"` in the topology — `gen` carries it into `meta.layout` and the viewer pins those boxes, auto-arranging everything else clear of them.
|
|
82
85
|
- **Arrow labels** are painted on the diagram while the level stays readable (≤14 arrows) and turn off above that; **LABELS** forces them on or off, and hovering an arrow always reveals its label.
|
|
83
86
|
- **PRINT / EXPORT** to SVG or PNG for docs and slides — exported arrows carry their labels.
|
|
84
|
-
- The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`)
|
|
87
|
+
- The breadcrumb names the C4 level you are on (`C4-L1 · CONTEXT` → `C4-L3 · COMPONENTS`) and,
|
|
88
|
+
when the level carries curated state, tallies it: a dot per status with its count plus mean
|
|
89
|
+
completion, so a level reads as a programme board and not just a diagram.
|
|
90
|
+
- **Every level shrink-wraps its own content** — a context with four boxes renders zoomed and
|
|
91
|
+
dense instead of adrift in a fixed canvas.
|
|
85
92
|
|
|
86
93
|
## Skins
|
|
87
94
|
|
|
@@ -29,6 +29,9 @@ button.on{background:rgba(62,231,255,.18);color:var(--cyan);border-color:var(--c
|
|
|
29
29
|
#back[disabled]{opacity:.28;cursor:default;border-color:var(--border);color:var(--ter)}
|
|
30
30
|
select#skin option{background:#08202e;color:var(--nm)}
|
|
31
31
|
#crumb{font-size:11px;color:var(--sec);margin:8px 0}
|
|
32
|
+
#crumb .agg{float:right;color:var(--ter2);letter-spacing:.05em}
|
|
33
|
+
#crumb .agg i{display:inline-block;width:7px;height:7px;border-radius:50%;margin:0 3px 0 8px;vertical-align:baseline}
|
|
34
|
+
#crumb .agg b{color:var(--nm)}
|
|
32
35
|
#crumb b{color:var(--cyan)}
|
|
33
36
|
#crumb a{color:#8fd8ec;cursor:pointer}
|
|
34
37
|
#crumb a:hover{text-decoration:underline}
|
|
@@ -64,7 +67,8 @@ svg{width:100%;height:100%;display:block}
|
|
|
64
67
|
.edge.plan{stroke:var(--edgeplan);stroke-dasharray:3 6;opacity:.6;marker-end:url(#arp)}
|
|
65
68
|
.edge.hot{stroke:var(--cyan);stroke-width:2.6;opacity:1}
|
|
66
69
|
.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
|
|
70
|
+
.elbl{font-size:8.2px;fill:var(--elbl);letter-spacing:.04em;pointer-events:none;
|
|
71
|
+
paint-order:stroke;stroke:var(--stagebg);stroke-width:3px;stroke-linejoin:round}
|
|
68
72
|
@keyframes dash{to{stroke-dashoffset:-24}}
|
|
69
73
|
.nd.pulse rect{animation:pul 2.4s ease-in-out infinite}
|
|
70
74
|
@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))}}
|
|
@@ -178,7 +182,7 @@ var STRINGS={
|
|
|
178
182
|
detClose:"[x] close", detFunc:"What it does", detNow:"Current state (facts)",
|
|
179
183
|
detTgt:"Target state (project)", detSrc:"Verification source", detIssue:"Issue",
|
|
180
184
|
detTarget:"TARGET",
|
|
181
|
-
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match",
|
|
185
|
+
rosterSearch:"filter…", rosterCount:"members", rosterEmpty:"no match", aggProgress:"progress",
|
|
182
186
|
stampBase:"Fact base:", stampMethod:"method:", stampVerify:"code+topology", staticSrc:"(static source)",
|
|
183
187
|
errEmpty:"Model unreachable or empty.",
|
|
184
188
|
errNotFound:"Model not found (./c4-model.json). Open via `forma serve` or inject window.__C4_MODEL__.",
|
|
@@ -197,7 +201,7 @@ var STRINGS={
|
|
|
197
201
|
detClose:"[x] chiudi", detFunc:"Cosa fa", detNow:"Stato attuale (fatti)",
|
|
198
202
|
detTgt:"Stato finito (progetto)", detSrc:"Fonte verifica", detIssue:"Issue",
|
|
199
203
|
detTarget:"TARGET",
|
|
200
|
-
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato",
|
|
204
|
+
rosterSearch:"filtra…", rosterCount:"elementi", rosterEmpty:"nessun risultato", aggProgress:"avanzamento",
|
|
201
205
|
stampBase:"Base fatti:", stampMethod:"metodo:", stampVerify:"code+topology", staticSrc:"(fonte statica)",
|
|
202
206
|
errEmpty:"Modello non raggiungibile o vuoto.",
|
|
203
207
|
errNotFound:"Modello non trovato (./c4-model.json). Apri via `forma serve` o inietta window.__C4_MODEL__.",
|
|
@@ -216,7 +220,8 @@ var LABEL_AUTO_MAX=14; // above this many arrows in the current level, fixed
|
|
|
216
220
|
var LABELS=null; // null = auto (by arrow count); true/false = user override for this session
|
|
217
221
|
var SKINS=[["holo","◆ holo"],["blueprint","▭ blueprint"]], SKIN_IDS=["holo","blueprint"];
|
|
218
222
|
var STMAP={done:"done","in-progress":"prog",next:"next",planned:"plan",problem:"prob"};
|
|
219
|
-
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
223
|
+
var M=null, stack=[null], mode="now", POS={}, drag=null, justDragged=false, CATNODES={}, FETCHED=false
|
|
224
|
+
var VBOX={}; // last fitted viewBox per level — frozen while dragging so the canvas does not rescale
|
|
220
225
|
function $(i){return document.getElementById(i)}
|
|
221
226
|
function esc(s){return String(s==null?"":s).replace(/&/g,"&").replace(/</g,"<").replace(/"/g,""")}
|
|
222
227
|
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;}
|
|
@@ -324,19 +329,44 @@ function draw(animate){
|
|
|
324
329
|
function mapId(id){return memberMap[id]||id;}
|
|
325
330
|
var edges=(M.edges||[]).map(function(e){return {from:mapId(e.from),to:mapId(e.to),label:e.label,estatus:e.estatus};})
|
|
326
331
|
.filter(function(e){return idset[e.from]&&idset[e.to]&&e.from!==e.to;});
|
|
327
|
-
var s='<svg viewBox="0 0 '+lay.W+' '+lay.H+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
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>'
|
|
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
332
|
// fixed arrow labels: on by default while the level stays readable, else hover-only (the tooltip
|
|
331
333
|
// is always there). A level with 40 arrows would be unreadable with every label painted at once.
|
|
332
334
|
var showLbl=(LABELS===null)?(edges.length<=LABEL_AUTO_MAX):LABELS;
|
|
333
335
|
var blbl=$("blbl");if(blbl)blbl.className=showLbl?"on":"";
|
|
334
|
-
|
|
336
|
+
// geometry pre-pass: edge paths first, so the viewBox can shrink-wrap nodes AND label anchors
|
|
337
|
+
var geo=[],i;
|
|
338
|
+
for(i=0;i<edges.length;i++){
|
|
335
339
|
var a=pos[edges[i].from],b=pos[edges[i].to];if(!a||!b)continue;
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
+
geo.push({e:edges[i],i:i,g:edgePath(a,b)});
|
|
341
|
+
}
|
|
342
|
+
// Fit-to-content viewBox: a sparse level renders zoomed and dense instead of lost in a fixed
|
|
343
|
+
// 1020px canvas — the hand-made status boards read well precisely because they waste no space.
|
|
344
|
+
// Minimums stop a single box from becoming comically large; extra room is split evenly so the
|
|
345
|
+
// content stays centred inside the enforced window.
|
|
346
|
+
var bx1=Infinity,by1=Infinity,bx2=-Infinity,by2=-Infinity,j,pp;
|
|
347
|
+
for(j=0;j<kids.length;j++){pp=pos[kids[j].id];if(!pp)continue;
|
|
348
|
+
if(pp.x<bx1)bx1=pp.x;if(pp.y<by1)by1=pp.y;if(pp.x+pp.w>bx2)bx2=pp.x+pp.w;if(pp.y+pp.h>by2)by2=pp.y+pp.h;}
|
|
349
|
+
for(j=0;j<geo.length;j++){var gm=geo[j].g;
|
|
350
|
+
if(gm.mx-14<bx1)bx1=gm.mx-14;if(gm.my-18<by1)by1=gm.my-18;
|
|
351
|
+
if(gm.mx+14>bx2)bx2=gm.mx+14;if(gm.my+6>by2)by2=gm.my+6;}
|
|
352
|
+
if(bx2<bx1){bx1=0;by1=0;bx2=lay.W;by2=lay.H;}
|
|
353
|
+
var VPAD=56,MINW=880,MINH=460;
|
|
354
|
+
var cw=bx2-bx1+2*VPAD,ch=by2-by1+2*VPAD;
|
|
355
|
+
var vw=Math.max(MINW,cw),vh=Math.max(MINH,ch);
|
|
356
|
+
var vx=bx1-VPAD-(vw-cw)/2,vy=by1-VPAD-(vh-ch)/2;
|
|
357
|
+
// …but hold it still while a box is being dragged: draw() runs on every mousemove, so a live
|
|
358
|
+
// refit would rescale the whole canvas under the pointer. The drop refits (see mouseup).
|
|
359
|
+
var vbk=keyOf();
|
|
360
|
+
if(drag&&VBOX[vbk]){vx=VBOX[vbk][0];vy=VBOX[vbk][1];vw=VBOX[vbk][2];vh=VBOX[vbk][3];}
|
|
361
|
+
else VBOX[vbk]=[vx,vy,vw,vh];
|
|
362
|
+
var s='<svg viewBox="'+vx.toFixed(1)+' '+vy.toFixed(1)+' '+vw.toFixed(1)+' '+vh.toFixed(1)+'" preserveAspectRatio="xMidYMid meet" class="'+(mode==="target"?"mode-t":"")+'">'
|
|
363
|
+
+'<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>'
|
|
364
|
+
+'<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>';
|
|
365
|
+
for(j=0;j<geo.length;j++){
|
|
366
|
+
var e=geo[j].g,ed=geo[j].e,cl=(ed.estatus==="to-build"?"plan":"flow");
|
|
367
|
+
s+='<path class="edge '+cl+'" data-e="'+geo[j].i+'" d="'+e.d+'"/>';
|
|
368
|
+
s+='<path class="edgehit" data-e="'+geo[j].i+'" data-label="'+esc(ed.label||"")+'" d="'+e.d+'"/>';
|
|
369
|
+
if(showLbl&&ed.label){var lb=String(ed.label);if(lb.length>28)lb=lb.slice(0,27)+"…";
|
|
340
370
|
s+='<text class="elbl" x="'+e.mx+'" y="'+(e.my-4)+'" text-anchor="middle">'+esc(lb)+'</text>';}
|
|
341
371
|
}
|
|
342
372
|
for(var j=0;j<kids.length;j++){
|
|
@@ -358,7 +388,7 @@ function draw(animate){
|
|
|
358
388
|
// Description lines: how many fit comes from the geometry (text starts at y+51, 12.5px apart,
|
|
359
389
|
// and must clear the footer control at h-25), so a curated layout hint with short boxes cannot
|
|
360
390
|
// 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(
|
|
391
|
+
var floorY=p.h-((dr||isCat)?25:6),MAXL=Math.min(4,Math.floor((floorY-51)/12.5)+1);
|
|
362
392
|
var dlines=wrapDesc(desc,Math.floor((p.w-26)/5.4),MAXL);
|
|
363
393
|
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>';
|
|
364
394
|
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"/>'
|
|
@@ -379,6 +409,21 @@ function draw(animate){
|
|
|
379
409
|
var lvlIdx=((M.levels||[]).indexOf(lvlKey));
|
|
380
410
|
var cb=(lvlIdx>-1?STR.crumbLevel+"-L"+(lvlIdx+1):STR.crumbLevel)+" · "+lvl.toUpperCase()+" <a data-d='-1'>"+esc(STR.crumbRoot)+"</a>";
|
|
381
411
|
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>");
|
|
412
|
+
// Per-level programme tally — the status-board essence in one glance: the status mix of THIS
|
|
413
|
+
// view plus mean completion. Counts use the real children (a collapsed catalogue still counts
|
|
414
|
+
// its members), so the tally never changes when the catalogue threshold does.
|
|
415
|
+
var cnt={},tot=0,sum=0,nc=0,st3;
|
|
416
|
+
for(j=0;j<realKids.length;j++){
|
|
417
|
+
st3=STMAP[realKids[j].status2]||(realKids[j].status==="planned"?"plan":"done");
|
|
418
|
+
cnt[st3]=(cnt[st3]||0)+1;tot++;
|
|
419
|
+
if(realKids[j].completion!=null){sum+=(+realKids[j].completion||0);nc++;}
|
|
420
|
+
}
|
|
421
|
+
if(tot){
|
|
422
|
+
var DOT={done:"#2fe08a",prog:"#ffc74d",next:"#3ee7ff",plan:"#2d5a72",prob:"#ff5d5d"},ord=["done","prog","next","plan","prob"],agg="";
|
|
423
|
+
for(j=0;j<ord.length;j++)if(cnt[ord[j]])agg+='<i style="background:'+DOT[ord[j]]+'"></i>'+cnt[ord[j]];
|
|
424
|
+
if(nc)agg+=' '+esc(STR.aggProgress)+' <b>'+Math.round(sum/nc)+'%</b>';
|
|
425
|
+
cb='<span class="agg">'+agg+'</span>'+cb;
|
|
426
|
+
}
|
|
382
427
|
$("crumb").innerHTML=cb;
|
|
383
428
|
$("back").disabled=stack.length<2;
|
|
384
429
|
$("detail").style.display="none";
|
|
@@ -399,9 +444,15 @@ function wrapDesc(desc,cpl,max){
|
|
|
399
444
|
}
|
|
400
445
|
// Returns the path plus the point ON the curve at t=0.5 — the label anchor. (The quadratic's
|
|
401
446
|
// control point sits at twice the bow, so anchoring a label there would float it off the arrow.)
|
|
447
|
+
// Endpoints are trimmed at the true RECTANGLE border (not a radial guess): with wide boxes the
|
|
448
|
+
// radial trim landed inside the box on shallow angles, dragging the label anchor under the node.
|
|
402
449
|
function edgePath(a,b){
|
|
403
450
|
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;
|
|
404
|
-
var
|
|
451
|
+
var ux=dx/L,uy=dy/L;
|
|
452
|
+
var ta=Math.min(ux?(a.w/2)/Math.abs(ux):Infinity,uy?(a.h/2)/Math.abs(uy):Infinity)+8;
|
|
453
|
+
var tb=Math.min(ux?(b.w/2)/Math.abs(ux):Infinity,uy?(b.h/2)/Math.abs(uy):Infinity)+11;
|
|
454
|
+
if(ta+tb>L-4){ta=(a.w/2+10);tb=(b.w/2+12);} // boxes touch/overlap: fall back to the radial trim
|
|
455
|
+
var x1=ax+ta*ux,y1=ay+ta*uy,x2=bx-tb*ux,y2=by-tb*uy;
|
|
405
456
|
var cx=(x1+x2)/2+(y2-y1)*.12,cy=(y1+y2)/2-(x2-x1)*.12;
|
|
406
457
|
return {d:"M"+x1+","+y1+" Q"+cx+","+cy+" "+x2+","+y2, mx:(x1+2*cx+x2)/4, my:(y1+2*cy+y2)/4};
|
|
407
458
|
}
|
|
@@ -471,7 +522,9 @@ function wire(){
|
|
|
471
522
|
pos[drag.id].y=clamp(p.y-drag.oy,4,lay.H-pos[drag.id].h-4);
|
|
472
523
|
drag.moved=true;draw(false);
|
|
473
524
|
});
|
|
474
|
-
document.addEventListener("mouseup",function(){if(drag)
|
|
525
|
+
document.addEventListener("mouseup",function(){if(!drag)return;var m=drag.moved;drag=null;
|
|
526
|
+
// refit after the drop, deferred so the click that follows mouseup still lands on the live DOM
|
|
527
|
+
if(m){justDragged=true;setTimeout(function(){draw(false);},0);}});
|
|
475
528
|
// edge label on hover (invisible fat hit-path)
|
|
476
529
|
stage.addEventListener("mouseover",function(ev){
|
|
477
530
|
if(ev.target.classList&&ev.target.classList.contains("edgehit")){
|
|
@@ -577,14 +630,14 @@ function serializeSvg(){
|
|
|
577
630
|
clone.setAttribute("xmlns","http://www.w3.org/2000/svg");
|
|
578
631
|
clone.setAttribute("xmlns:xlink","http://www.w3.org/1999/xlink");
|
|
579
632
|
var vb=(svg.getAttribute("viewBox")||"0 0 1080 660").split(/\s+/);
|
|
580
|
-
var w=+vb[2]||1080,h=+vb[3]||660;
|
|
633
|
+
var vbx=+vb[0]||0,vby=+vb[1]||0,w=+vb[2]||1080,h=+vb[3]||660;
|
|
581
634
|
clone.setAttribute("width",w);clone.setAttribute("height",h);
|
|
582
635
|
// Inline the resolved variables on the root so var(--x) resolves standalone,
|
|
583
636
|
// independent of the skin-selector chain that won't exist in the exported file.
|
|
584
637
|
var vars=resolvedVars();if(vars)clone.setAttribute("style",vars);
|
|
585
638
|
// solid dark/light background so the standalone file isn't transparent
|
|
586
639
|
var bg=cssv("--stagebg")||"#050d14";
|
|
587
|
-
var rect='<rect x="
|
|
640
|
+
var rect='<rect x="'+vbx+'" y="'+vby+'" width="'+w+'" height="'+h+'" fill="'+bg+'"/>';
|
|
588
641
|
var style="<style>"+collectSvgCss()+"</style>";
|
|
589
642
|
var inner=clone.innerHTML;
|
|
590
643
|
var attrs='';for(var a=0;a<clone.attributes.length;a++){var at=clone.attributes[a];attrs+=' '+at.name+'="'+String(at.value).replace(/"/g,""")+'"';}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "forma-arch",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|