feat: 3D renderer of plantuml diagram
Adds initial conversation with Claude along with the .html diagram renderer.
This commit is contained in:
commit
8d3b5cccf9
118
conversation-summary.md
Normal file
118
conversation-summary.md
Normal file
@ -0,0 +1,118 @@
|
||||
# Conversation summary — PlantUML isometric 3D renderer
|
||||
|
||||
## Context
|
||||
|
||||
This conversation explored generating interactive 3D/isometric renderings of embedded system architecture diagrams, with a specific focus on an automotive ECU use case. The team uses PlantUML and self-hosts the PlantUML server.
|
||||
|
||||
---
|
||||
|
||||
## System being modelled
|
||||
|
||||
Three top-level ECUs: **A**, **B**, and **C**.
|
||||
|
||||
**ECU B** contains:
|
||||
- A **Connector** and an **Adapter** for each of ECU A and ECU C (two parallel lanes)
|
||||
- Both adapters feed into a single **Logic** component
|
||||
- Inside Logic, a sequential sub-flow: **a → b → c**
|
||||
|
||||
PlantUML source:
|
||||
|
||||
```plantuml
|
||||
@startuml
|
||||
component "ECU A" as A
|
||||
component "ECU C" as C
|
||||
|
||||
component "ECU B" as B {
|
||||
component "Connector A" as ConA
|
||||
component "Adapter A" as AdpA
|
||||
component "Connector C" as ConC
|
||||
component "Adapter C" as AdpC
|
||||
component "Logic" as L {
|
||||
component "a" as La
|
||||
component "b" as Lb
|
||||
component "c" as Lc
|
||||
La --> Lb
|
||||
Lb --> Lc
|
||||
}
|
||||
ConA --> AdpA
|
||||
ConC --> AdpC
|
||||
AdpA --> L
|
||||
AdpC --> L
|
||||
}
|
||||
|
||||
A --> ConA
|
||||
C --> ConC
|
||||
@enduml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What was built
|
||||
|
||||
An interactive isometric 3D renderer that:
|
||||
|
||||
1. **Parses PlantUML component diagram syntax** client-side (no server required for the parser itself)
|
||||
2. **Renders an isometric SVG** — each component is a 3-faced isometric block with color coding by component type
|
||||
3. **Supports drill-down zoom** — clicking a component with nested children transitions to an internal view, with a breadcrumb trail for navigation
|
||||
4. **Color scheme by component type:**
|
||||
- Gray → ECU / top-level components
|
||||
- Teal → Connectors
|
||||
- Amber → Adapters
|
||||
- Purple → Logic components
|
||||
- Coral → Sequence steps (short labels like a, b, c)
|
||||
|
||||
A standalone `plantuml-3d-renderer.html` file was produced — open it directly in any browser or VSCode Live Preview.
|
||||
|
||||
---
|
||||
|
||||
## Architecture decisions
|
||||
|
||||
### Parser approach
|
||||
The client-side parser handles:
|
||||
- `component "Label" as alias { ... }` — with or without braces
|
||||
- `component alias { ... }` — unlabelled alias form
|
||||
- `alias --> alias : optional label` — directed connections
|
||||
- Arbitrary nesting depth
|
||||
|
||||
**For production:** POST the `.puml` source to your self-hosted PlantUML server's `/json` endpoint and drive the renderer from the returned AST instead. This handles edge cases (stereotypes, notes, skinparam, grouped interfaces) that the lightweight client-side parser skips.
|
||||
|
||||
### Rendering
|
||||
- Pure SVG, no dependencies
|
||||
- Isometric projection: each block has top/left/right faces using CSS custom properties for colors
|
||||
- Dark mode automatic via `prefers-color-scheme`
|
||||
- Zoom levels are discrete view transitions (not CSS transforms) — each level is a full redraw of the SVG for the selected node's children
|
||||
|
||||
---
|
||||
|
||||
## DSL alternatives considered
|
||||
|
||||
| Tool | License | Notes |
|
||||
|---|---|---|
|
||||
| **PlantUML** | GPL / LGPL (jar) | Team already uses it. Self-hosting fine for commercial internal use. Get a free Professional Edition key to suppress ads. |
|
||||
| **Mermaid** | MIT | Simplest license, no restrictions. Less expressive for deep nesting. |
|
||||
| **D2** | MPL 2.0 | Clean syntax, good nesting. TALA layout engine requires paid license for commercial use. |
|
||||
| **Structurizr DSL** | Apache 2.0 | C4-model based. Server prebuilt binaries need a license; build from source is free. |
|
||||
| **ArchiMate** | Non-commercial free / annual commercial license | The Archi tool (MIT), but the language spec requires a commercial license from The Open Group for commercial use. |
|
||||
| **SysML** | Open source (OMG derived) | Semantically correct for ECU/embedded systems. Heavy tooling (Cameo, Papyrus). Eclipse SysON is open source. |
|
||||
| **AADL** | SAE standard (spec costs money; OSATE tooling is open source) | Best fit for automotive embedded. Steep learning curve. |
|
||||
|
||||
**Recommendation:** Stay on PlantUML. The team's existing knowledge compounds in value faster than any migration would justify — unless moving toward full MBSE (model-based systems engineering) with SysML or AADL.
|
||||
|
||||
---
|
||||
|
||||
## Next steps / open threads
|
||||
|
||||
- [ ] Wire the renderer to the self-hosted PlantUML `/json` endpoint instead of the client-side parser
|
||||
- [ ] Add animated connection flows (data flowing between components)
|
||||
- [ ] Support PlantUML stereotypes (`<<ECU>>`, `<<connector>>`) as an explicit color override mechanism, removing the label-inference heuristic
|
||||
- [ ] Consider a VSCode extension wrapper: watch `.puml` files, re-render on save, show isometric view in a side panel
|
||||
- [ ] Explore Three.js for true 3D with orbit/zoom controls, for stakeholder presentation use cases
|
||||
|
||||
---
|
||||
|
||||
## How to continue in VSCode
|
||||
|
||||
1. Open `plantuml-3d-renderer.html` with Live Preview or any browser
|
||||
2. Start a new Claude chat (Claude Code CLI or Copilot+Claude) and paste this summary
|
||||
3. The renderer source is self-contained — all logic is in the single HTML file
|
||||
4. Key function to extend: `parsePuml(src)` for parser changes, `drawLevel(node)` for rendering changes, `guessColor(node)` for color mapping changes
|
||||
594
plantuml-3d-renderer.html
Normal file
594
plantuml-3d-renderer.html
Normal file
@ -0,0 +1,594 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PlantUML → Isometric 3D Renderer</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", monospace;
|
||||
--bg: #ffffff;
|
||||
--bg2: #f5f4f0;
|
||||
--bg3: #eeecea;
|
||||
--txt: #1a1a1a;
|
||||
--txts: #555550;
|
||||
--txt3: #888780;
|
||||
--border: rgba(0,0,0,0.12);
|
||||
--border2: rgba(0,0,0,0.22);
|
||||
--info-bg: #e6f1fb;
|
||||
--info-txt: #0c447c;
|
||||
--info-bdr: #378add;
|
||||
--sk: rgba(0,0,0,0.13);
|
||||
--top-gray: #D3D1C7; --left-gray: #888780; --right-gray: #5F5E5A;
|
||||
--top-blue: #B5D4F4; --left-blue: #378ADD; --right-blue: #185FA5;
|
||||
--top-teal: #9FE1CB; --left-teal: #1D9E75; --right-teal: #0F6E56;
|
||||
--top-amber: #FAC775; --left-amber: #BA7517; --right-amber: #854F0B;
|
||||
--top-purple:#CECBF6; --left-purple:#7F77DD; --right-purple:#534AB7;
|
||||
--top-coral: #F5C4B3; --left-coral: #D85A30; --right-coral: #993C1D;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #1e1e1e;
|
||||
--bg2: #2a2a2a;
|
||||
--bg3: #333333;
|
||||
--txt: #e8e6dc;
|
||||
--txts: #9c9a92;
|
||||
--txt3: #666660;
|
||||
--border: rgba(255,255,255,0.1);
|
||||
--border2: rgba(255,255,255,0.2);
|
||||
--info-bg: #042c53;
|
||||
--info-txt: #85b7eb;
|
||||
--info-bdr: #185fa5;
|
||||
--sk: rgba(255,255,255,0.08);
|
||||
--top-gray: #2C2C2A; --left-gray: #444441; --right-gray: #1a1a18;
|
||||
--top-blue: #0C447C; --left-blue: #185FA5; --right-blue: #042C53;
|
||||
--top-teal: #085041; --left-teal: #0F6E56; --right-teal: #04342C;
|
||||
--top-amber: #633806; --left-amber: #854F0B; --right-amber: #412402;
|
||||
--top-purple:#3C3489; --left-purple:#534AB7; --right-purple:#26215C;
|
||||
--top-coral: #712B13; --left-coral: #993C1D; --right-coral: #4A1B0C;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
padding: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--txts);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--bg2);
|
||||
border: 0.5px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-label {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--txt3);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 320px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding: 10px;
|
||||
resize: vertical;
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
border: 0.5px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
textarea:focus { border-color: var(--info-bdr); }
|
||||
|
||||
.btn-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13px;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 0.5px solid var(--border2);
|
||||
background: transparent;
|
||||
color: var(--txt);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: var(--bg3); }
|
||||
button:active { transform: scale(0.98); }
|
||||
|
||||
button.primary {
|
||||
background: var(--info-bg);
|
||||
color: var(--info-txt);
|
||||
border-color: var(--info-bdr);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#err {
|
||||
font-size: 12px;
|
||||
color: #c0392b;
|
||||
margin-top: 6px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
color: var(--txts);
|
||||
}
|
||||
|
||||
.legend-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
border: 0.5px solid var(--border);
|
||||
}
|
||||
|
||||
.hint-box {
|
||||
margin-top: 12px;
|
||||
padding: 8px 10px;
|
||||
background: var(--info-bg);
|
||||
border: 0.5px solid var(--info-bdr);
|
||||
border-radius: var(--radius);
|
||||
font-size: 11px;
|
||||
color: var(--info-txt);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.canvas-panel {
|
||||
background: var(--bg2);
|
||||
border: 0.5px solid var(--border2);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvas-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 0.5px solid var(--border);
|
||||
}
|
||||
|
||||
#nav {
|
||||
font-size: 12px;
|
||||
color: var(--txts);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#nav .crumb {
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
color: var(--info-txt);
|
||||
}
|
||||
|
||||
#nav .crumb:hover { opacity: 0.75; }
|
||||
#nav .sep { opacity: 0.35; }
|
||||
|
||||
#canvas-wrap {
|
||||
padding: 8px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
svg { display: block; }
|
||||
|
||||
.iso-lbl {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 11px;
|
||||
fill: var(--txt, #1a1a1a);
|
||||
}
|
||||
|
||||
.iso-sub {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 9px;
|
||||
fill: var(--txts, #555);
|
||||
}
|
||||
|
||||
.zoomable { cursor: zoom-in; }
|
||||
.zoomable:hover polygon:first-child { opacity: 0.82; }
|
||||
.conn { stroke-width: 1; fill: none; stroke-dasharray: 4 3; }
|
||||
.conn-s { stroke-width: 1.5; fill: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>PlantUML → Isometric 3D Renderer</h1>
|
||||
<p class="subtitle">Paste a PlantUML component diagram and click Render to generate an interactive isometric view.</p>
|
||||
|
||||
<div class="layout">
|
||||
<div class="panel">
|
||||
<div class="panel-label">PlantUML source</div>
|
||||
<textarea id="puml" spellcheck="false">@startuml
|
||||
component "ECU A" as A
|
||||
component "ECU C" as C
|
||||
|
||||
component "ECU B" as B {
|
||||
component "Connector A" as ConA
|
||||
component "Adapter A" as AdpA
|
||||
component "Connector C" as ConC
|
||||
component "Adapter C" as AdpC
|
||||
component "Logic" as L {
|
||||
component "a" as La
|
||||
component "b" as Lb
|
||||
component "c" as Lc
|
||||
La --> Lb
|
||||
Lb --> Lc
|
||||
}
|
||||
ConA --> AdpA
|
||||
ConC --> AdpC
|
||||
AdpA --> L
|
||||
AdpC --> L
|
||||
}
|
||||
|
||||
A --> ConA
|
||||
C --> ConC
|
||||
@enduml</textarea>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="primary" onclick="render()">Render →</button>
|
||||
<button onclick="resetSource()">Reset example</button>
|
||||
</div>
|
||||
<div id="err"></div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:var(--top-gray)"></div> ECU / top-level
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:var(--top-teal)"></div> Connector
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:var(--top-amber)"></div> Adapter
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:var(--top-purple)"></div> Logic
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<div class="legend-swatch" style="background:var(--top-coral)"></div> Sequence step
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hint-box">
|
||||
Color is inferred from the component label.<br>
|
||||
Components with children are clickable — drill in to explore nested structure.
|
||||
<br><br>
|
||||
To connect your self-hosted PlantUML server, POST the source to
|
||||
<code>/json</code> and feed the response AST into the renderer instead of the client-side parser.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-panel">
|
||||
<div class="canvas-header">
|
||||
<div id="nav"><span style="color:var(--txt3)">Overview</span></div>
|
||||
</div>
|
||||
<div id="canvas-wrap">
|
||||
<svg id="iso" width="100%" viewBox="0 0 680 400"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const EXAMPLE = document.getElementById('puml').value;
|
||||
|
||||
const PALETTE = {
|
||||
ecu: { t:'--top-gray', l:'--left-gray', r:'--right-gray' },
|
||||
conn: { t:'--top-teal', l:'--left-teal', r:'--right-teal' },
|
||||
adp: { t:'--top-amber', l:'--left-amber', r:'--right-amber' },
|
||||
logic: { t:'--top-purple', l:'--left-purple', r:'--right-purple' },
|
||||
seq: { t:'--top-coral', l:'--left-coral', r:'--right-coral' },
|
||||
};
|
||||
|
||||
function cv(v) {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(v).trim() || v;
|
||||
}
|
||||
|
||||
function parsePuml(src) {
|
||||
const lines = src.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s && !s.startsWith("'") && s !== '@startuml' && s !== '@enduml');
|
||||
|
||||
const root = { id: '__root__', label: 'root', children: [], conns: [] };
|
||||
const stack = [root];
|
||||
const byId = {};
|
||||
|
||||
const aliasRe = /^component\s+"([^"]+)"\s+as\s+(\w+)\s*(\{)?\s*$/;
|
||||
const aliasNoLblRe = /^component\s+(\w+)\s*(\{)?\s*$/;
|
||||
const connRe = /^(\w+)\s*-->\s*(\w+)(?:\s*:\s*(.*))?$/;
|
||||
const closeRe = /^\}$/;
|
||||
|
||||
function cur() { return stack[stack.length - 1]; }
|
||||
|
||||
for (const line of lines) {
|
||||
let m;
|
||||
if ((m = aliasRe.exec(line))) {
|
||||
const node = { id: m[2], label: m[1], children: [], conns: [], parent: cur() };
|
||||
byId[m[2]] = node;
|
||||
cur().children.push(node);
|
||||
if (m[3]) stack.push(node);
|
||||
} else if ((m = aliasNoLblRe.exec(line))) {
|
||||
const node = { id: m[1], label: m[1], children: [], conns: [], parent: cur() };
|
||||
byId[m[1]] = node;
|
||||
cur().children.push(node);
|
||||
if (m[2]) stack.push(node);
|
||||
} else if ((m = connRe.exec(line))) {
|
||||
cur().conns.push({ from: m[1], to: m[2], label: m[3] || '' });
|
||||
} else if (closeRe.test(line)) {
|
||||
if (stack.length > 1) stack.pop();
|
||||
}
|
||||
}
|
||||
return { root, byId };
|
||||
}
|
||||
|
||||
function guessColor(node) {
|
||||
const l = node.label.toLowerCase();
|
||||
if (l.startsWith('connector')) return PALETTE.conn;
|
||||
if (l.startsWith('adapter')) return PALETTE.adp;
|
||||
if (l === 'logic' || l === 'logic component') return PALETTE.logic;
|
||||
if (node.label.length <= 2) return PALETTE.seq;
|
||||
return PALETTE.ecu;
|
||||
}
|
||||
|
||||
function ns(tag) {
|
||||
return document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
}
|
||||
|
||||
function makePoly(pts, cssVar) {
|
||||
const el = ns('polygon');
|
||||
el.setAttribute('points', pts.map(p => p.join(',')).join(' '));
|
||||
el.style.fill = `var(${cssVar})`;
|
||||
el.style.stroke = 'var(--sk)';
|
||||
el.setAttribute('stroke-width', '0.5');
|
||||
return el;
|
||||
}
|
||||
|
||||
function makeBlock(svg, x, y, W, H, pal, label, sublabel, clickable) {
|
||||
const g = ns('g');
|
||||
if (clickable) g.classList.add('zoomable');
|
||||
|
||||
const hw = W, hh = W * 0.47;
|
||||
const top = makePoly([[x,y],[x+hw,y+hh],[x,y+hh*2],[x-hw,y+hh]], pal.t);
|
||||
const left = makePoly([[x-hw,y+hh],[x,y+hh*2],[x,y+hh*2+H],[x-hw,y+hh+H]], pal.l);
|
||||
const right = makePoly([[x,y+hh*2],[x+hw,y+hh],[x+hw,y+hh+H],[x,y+hh*2+H]], pal.r);
|
||||
g.appendChild(top); g.appendChild(left); g.appendChild(right);
|
||||
|
||||
const tx = ns('text');
|
||||
tx.setAttribute('x', x); tx.setAttribute('y', y + hh + 2);
|
||||
tx.setAttribute('text-anchor', 'middle');
|
||||
tx.setAttribute('dominant-baseline', 'central');
|
||||
tx.setAttribute('class', 'iso-lbl');
|
||||
tx.setAttribute('font-weight', '500');
|
||||
tx.textContent = label;
|
||||
g.appendChild(tx);
|
||||
|
||||
if (sublabel) {
|
||||
const ts = ns('text');
|
||||
ts.setAttribute('x', x); ts.setAttribute('y', y + hh + 15);
|
||||
ts.setAttribute('text-anchor', 'middle');
|
||||
ts.setAttribute('class', 'iso-sub');
|
||||
ts.textContent = sublabel;
|
||||
g.appendChild(ts);
|
||||
}
|
||||
|
||||
svg.appendChild(g);
|
||||
return {
|
||||
g,
|
||||
cx: x,
|
||||
cy: y + hh,
|
||||
top_y: y,
|
||||
bot_y: y + hh * 2 + H,
|
||||
left_x: x - hw,
|
||||
right_x: x + hw,
|
||||
};
|
||||
}
|
||||
|
||||
function makeArrow(svg) {
|
||||
const defs = ns('defs');
|
||||
const m = ns('marker');
|
||||
m.setAttribute('id', 'arr');
|
||||
m.setAttribute('viewBox', '0 0 10 10');
|
||||
m.setAttribute('refX', '8'); m.setAttribute('refY', '5');
|
||||
m.setAttribute('markerWidth', '6'); m.setAttribute('markerHeight', '6');
|
||||
m.setAttribute('orient', 'auto-start-reverse');
|
||||
const p = ns('path');
|
||||
p.setAttribute('d', 'M2 1L8 5L2 9');
|
||||
p.setAttribute('fill', 'none');
|
||||
p.style.stroke = 'var(--left-gray)';
|
||||
p.setAttribute('stroke-width', '1.5');
|
||||
p.setAttribute('stroke-linecap', 'round');
|
||||
m.appendChild(p); defs.appendChild(m); svg.insertBefore(defs, svg.firstChild);
|
||||
}
|
||||
|
||||
function makeLine(svg, x1, y1, x2, y2) {
|
||||
const p = ns('path');
|
||||
const mx = (x1 + x2) / 2, my = Math.min(y1, y2) - 18;
|
||||
p.setAttribute('d', `M${x1},${y1} Q${mx},${my} ${x2},${y2}`);
|
||||
p.classList.add('conn-s');
|
||||
p.style.stroke = 'var(--left-gray)';
|
||||
p.setAttribute('marker-end', 'url(#arr)');
|
||||
svg.appendChild(p);
|
||||
}
|
||||
|
||||
let state = { parsed: null, viewStack: [] };
|
||||
|
||||
function render() {
|
||||
const src = document.getElementById('puml').value;
|
||||
document.getElementById('err').textContent = '';
|
||||
let parsed;
|
||||
try { parsed = parsePuml(src); }
|
||||
catch (e) { document.getElementById('err').textContent = 'Parse error: ' + e.message; return; }
|
||||
state.parsed = parsed;
|
||||
state.viewStack = [];
|
||||
drawLevel(parsed.root);
|
||||
}
|
||||
|
||||
function drawLevel(node) {
|
||||
const svg = document.getElementById('iso');
|
||||
svg.innerHTML = '';
|
||||
makeArrow(svg);
|
||||
|
||||
const children = node.children;
|
||||
const conns = node.conns;
|
||||
|
||||
if (children.length === 0) {
|
||||
svg.setAttribute('viewBox', '0 0 680 100');
|
||||
const t = ns('text');
|
||||
t.setAttribute('x', '340'); t.setAttribute('y', '50');
|
||||
t.setAttribute('text-anchor', 'middle'); t.setAttribute('class', 'iso-sub');
|
||||
t.textContent = 'No nested components.';
|
||||
svg.appendChild(t);
|
||||
updateNav(); return;
|
||||
}
|
||||
|
||||
const W = 80, H = 28;
|
||||
const cols = Math.min(children.length, 4);
|
||||
const rows = Math.ceil(children.length / cols);
|
||||
const vbH = rows * 160 + 80;
|
||||
svg.setAttribute('viewBox', `0 0 680 ${vbH}`);
|
||||
|
||||
const startX = 340 - (cols - 1) * 90;
|
||||
const positions = {};
|
||||
|
||||
children.forEach((child, i) => {
|
||||
const col = i % cols, row = Math.floor(i / cols);
|
||||
const x = startX + col * 180;
|
||||
const y = 50 + row * 155;
|
||||
const pal = guessColor(child);
|
||||
const canZoom = child.children.length > 0;
|
||||
const sub = canZoom ? 'click to expand →' : null;
|
||||
const blk = makeBlock(svg, x, y, W, H, pal, child.label, sub, canZoom);
|
||||
positions[child.id] = blk;
|
||||
|
||||
if (canZoom) {
|
||||
blk.g.addEventListener('click', () => {
|
||||
state.viewStack.push(node);
|
||||
drawLevel(child);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
conns.forEach(c => {
|
||||
const fp = positions[c.from], tp = positions[c.to];
|
||||
if (!fp || !tp) return;
|
||||
makeLine(svg, fp.cx, fp.bot_y, tp.cx, tp.top_y);
|
||||
});
|
||||
|
||||
updateNav();
|
||||
}
|
||||
|
||||
function updateNav() {
|
||||
const nav = document.getElementById('nav');
|
||||
nav.innerHTML = '';
|
||||
const stack = state.viewStack;
|
||||
|
||||
const sep = () => {
|
||||
const s = document.createElement('span');
|
||||
s.className = 'sep';
|
||||
s.textContent = ' › ';
|
||||
return s;
|
||||
};
|
||||
|
||||
if (stack.length === 0) {
|
||||
const s = document.createElement('span');
|
||||
s.style.color = 'var(--txt3)';
|
||||
s.textContent = 'Overview';
|
||||
nav.appendChild(s);
|
||||
return;
|
||||
}
|
||||
|
||||
const overviewEl = document.createElement('span');
|
||||
overviewEl.className = 'crumb';
|
||||
overviewEl.textContent = 'Overview';
|
||||
overviewEl.onclick = () => { state.viewStack = []; drawLevel(state.parsed.root); };
|
||||
nav.appendChild(overviewEl);
|
||||
|
||||
stack.forEach((node, i) => {
|
||||
nav.appendChild(sep());
|
||||
if (i < stack.length - 1) {
|
||||
const el = document.createElement('span');
|
||||
el.className = 'crumb';
|
||||
el.textContent = node.label || node.id;
|
||||
el.onclick = () => { state.viewStack = stack.slice(0, i); drawLevel(node); };
|
||||
nav.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement('span');
|
||||
el.style.color = 'var(--txt3)';
|
||||
el.textContent = node.label || node.id;
|
||||
nav.appendChild(el);
|
||||
}
|
||||
});
|
||||
|
||||
nav.appendChild(sep());
|
||||
const cur = stack[stack.length - 1];
|
||||
const curChildren = cur.children;
|
||||
if (curChildren.length > 0) {
|
||||
const el = document.createElement('span');
|
||||
el.style.color = 'var(--txt3)';
|
||||
el.textContent = curChildren[0]?.parent?.label || cur.label;
|
||||
nav.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
function resetSource() {
|
||||
document.getElementById('puml').value = EXAMPLE;
|
||||
render();
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
x
Reference in New Issue
Block a user