3d-components/plantuml-3d-renderer.html
Luís Murta 8d3b5cccf9
feat: 3D renderer of plantuml diagram
Adds initial conversation with Claude along with the .html diagram
renderer.
2026-03-14 10:22:58 +00:00

595 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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>