refactor: break down single html file

Scaffold project structure with vite.

src/
  types.ts    ← Node, Connection, ParseResult, PaletteEntry, BlockInfo, AppState
  parser.ts   ← parsePuml()
  colors.ts   ← PALETTE, guessColor()
  svg.ts      ← makeBlock(), makeArrow(), makeLine() (pure SVG helpers)
  main.ts     ← state, drawLevel(), updateNav(), render(), setBugData(), event wiring
  style.css   ← all styles
index.html    ← shell + default PlantUML example in textarea
package.json  ← vite + typescript devDeps
tsconfig.json ← strict mode, ESNext, bundler resolution
This commit is contained in:
Luís Murta 2026-03-14 14:17:16 +00:00
parent 1ff6a55078
commit f7dac370b4
Signed by: satprog
GPG Key ID: 169EF1BBD7049F94
12 changed files with 1692 additions and 645 deletions

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

95
index.html Normal file
View File

@ -0,0 +1,95 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PlantUML &#x2192; Isometric 3D Renderer</title>
</head>
<body>
<h1>PlantUML &#x2192; 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 id="btn-render" class="primary">Render &#x2192;</button>
<button id="btn-reset">Reset example</button>
</div>
<div id="err"></div>
<div class="panel-label" style="margin-top:16px">Bug counts (JSON &#x2014; alias: count)</div>
<textarea id="bugJson" spellcheck="false" rows="3"
placeholder='{"ConA":3,"AdpA":1,"L":7}'></textarea>
<div class="btn-row">
<button id="btn-apply-bugs" class="primary">Apply bugs</button>
<button id="btn-clear-bugs">Clear</button>
</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 &#x2014; 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 type="module" src="/src/main.ts"></script>
</body>
</html>

895
package-lock.json generated Normal file
View File

@ -0,0 +1,895 @@
{
"name": "vite-scaffold",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vite-scaffold",
"version": "0.0.0",
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz",
"integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",
"integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
"integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@oxc-project/runtime": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz",
"integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.115.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz",
"integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz",
"integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz",
"integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz",
"integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz",
"integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz",
"integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz",
"integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==",
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz",
"integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz",
"integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==",
"dev": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.32.0",
"lightningcss-darwin-arm64": "1.32.0",
"lightningcss-darwin-x64": "1.32.0",
"lightningcss-freebsd-x64": "1.32.0",
"lightningcss-linux-arm-gnueabihf": "1.32.0",
"lightningcss-linux-arm64-gnu": "1.32.0",
"lightningcss-linux-arm64-musl": "1.32.0",
"lightningcss-linux-x64-gnu": "1.32.0",
"lightningcss-linux-x64-musl": "1.32.0",
"lightningcss-win32-arm64-msvc": "1.32.0",
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.9",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz",
"integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.115.0",
"@rolldown/pluginutils": "1.0.0-rc.9"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.9",
"@rolldown/binding-darwin-x64": "1.0.0-rc.9",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.9",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.9",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.9",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.9",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/vite": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz",
"integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/runtime": "0.115.0",
"lightningcss": "^1.32.0",
"picomatch": "^4.0.3",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.9",
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.0.0-alpha.31",
"esbuild": "^0.27.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
}
}
}

15
package.json Normal file
View File

@ -0,0 +1,15 @@
{
"name": "vite-scaffold",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"typescript": "~5.9.3",
"vite": "^8.0.0"
}
}

View File

@ -1,645 +0,0 @@
<!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="panel-label" style="margin-top:16px">Bug counts (JSON — alias: count)</div>
<textarea id="bugJson" spellcheck="false" rows="3"
placeholder='{"ConA":3,"AdpA":1,"L":7}'></textarea>
<div class="btn-row">
<button class="primary" onclick="applyBugs()">Apply bugs</button>
<button onclick="clearBugs()">Clear</button>
</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, bugCount = 0) {
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);
}
if (bugCount > 0) {
const bx = x + hw - 8, by = y + 8;
const circle = ns('circle');
circle.setAttribute('cx', bx); circle.setAttribute('cy', by);
circle.setAttribute('r', '9');
circle.setAttribute('fill', '#d32f2f');
circle.setAttribute('stroke', '#fff'); circle.setAttribute('stroke-width', '1.5');
g.appendChild(circle);
const bt = ns('text');
bt.setAttribute('x', bx); bt.setAttribute('y', by);
bt.setAttribute('text-anchor', 'middle');
bt.setAttribute('dominant-baseline', 'central');
bt.setAttribute('fill', '#fff');
bt.setAttribute('font-size', '8');
bt.setAttribute('font-weight', 'bold');
bt.textContent = bugCount > 99 ? '99+' : String(bugCount);
g.appendChild(bt);
}
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: [], bugData: {}, currentNode: null };
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 setBugData(data) {
state.bugData = data || {};
if (state.currentNode) drawLevel(state.currentNode);
}
window.setBugData = setBugData;
function applyBugs() {
const raw = document.getElementById('bugJson').value.trim();
document.getElementById('err').textContent = '';
if (!raw) { setBugData({}); return; }
try {
setBugData(JSON.parse(raw));
} catch (e) {
document.getElementById('err').textContent = 'Bug JSON error: ' + e.message;
}
}
function clearBugs() {
document.getElementById('bugJson').value = '';
setBugData({});
}
function drawLevel(node) {
state.currentNode = 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 bugs = state.bugData[child.id] || 0;
const blk = makeBlock(svg, x, y, W, H, pal, child.label, sub, canZoom, bugs);
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>

18
src/colors.ts Normal file
View File

@ -0,0 +1,18 @@
import type { Node, PaletteEntry } from './types.ts';
export const PALETTE: Record<string, PaletteEntry> = {
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' },
};
export function guessColor(node: Node): PaletteEntry {
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']!;
}

180
src/main.ts Normal file
View File

@ -0,0 +1,180 @@
import './style.css';
import type { AppState, Node } from './types.ts';
import { parsePuml } from './parser.ts';
import { guessColor } from './colors.ts';
import { makeArrow, makeBlock, makeLine } from './svg.ts';
const EXAMPLE = (document.getElementById('puml') as HTMLTextAreaElement).value;
const state: AppState = {
parsed: null,
viewStack: [],
bugData: {},
currentNode: null,
};
// ── Rendering ────────────────────────────────────────────────────────────────
function drawLevel(node: Node): void {
state.currentNode = node;
const svg = document.getElementById('iso') as unknown as SVGSVGElement;
svg.innerHTML = '';
makeArrow(svg);
const { children, conns } = node;
if (children.length === 0) {
svg.setAttribute('viewBox', '0 0 680 100');
const t = document.createElementNS('http://www.w3.org/2000/svg', '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);
svg.setAttribute('viewBox', `0 0 680 ${rows * 160 + 80}`);
const startX = 340 - (cols - 1) * 90;
const positions: Record<string, ReturnType<typeof makeBlock>> = {};
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 bugs = state.bugData[child.id] ?? 0;
const blk = makeBlock(svg, x, y, W, H, pal, child.label, sub, canZoom, bugs);
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(): void {
const nav = document.getElementById('nav')!;
nav.innerHTML = '';
const { viewStack: stack } = state;
const sep = (): HTMLSpanElement => {
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]!;
if (cur.children.length > 0) {
const el = document.createElement('span');
el.style.color = 'var(--txt3)';
el.textContent = cur.children[0]?.parent?.label ?? cur.label;
nav.appendChild(el);
}
}
// ── Public API ────────────────────────────────────────────────────────────────
export function setBugData(data: Record<string, number>): void {
state.bugData = data;
if (state.currentNode) drawLevel(state.currentNode);
}
// ── Event handlers ────────────────────────────────────────────────────────────
function render(): void {
const src = (document.getElementById('puml') as HTMLTextAreaElement).value;
(document.getElementById('err') as HTMLElement).textContent = '';
try {
state.parsed = parsePuml(src);
state.viewStack = [];
drawLevel(state.parsed.root);
} catch (e) {
(document.getElementById('err') as HTMLElement).textContent =
'Parse error: ' + (e as Error).message;
}
}
function resetSource(): void {
(document.getElementById('puml') as HTMLTextAreaElement).value = EXAMPLE;
render();
}
function applyBugs(): void {
const raw = (document.getElementById('bugJson') as HTMLTextAreaElement).value.trim();
(document.getElementById('err') as HTMLElement).textContent = '';
if (!raw) { setBugData({}); return; }
try {
setBugData(JSON.parse(raw) as Record<string, number>);
} catch (e) {
(document.getElementById('err') as HTMLElement).textContent =
'Bug JSON error: ' + (e as Error).message;
}
}
function clearBugs(): void {
(document.getElementById('bugJson') as HTMLTextAreaElement).value = '';
setBugData({});
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.getElementById('btn-render')!.addEventListener('click', render);
document.getElementById('btn-reset')!.addEventListener('click', resetSource);
document.getElementById('btn-apply-bugs')!.addEventListener('click', applyBugs);
document.getElementById('btn-clear-bugs')!.addEventListener('click', clearBugs);
// Expose setBugData for JIRA integration
(window as unknown as Record<string, unknown>)['setBugData'] = setBugData;
render();

42
src/parser.ts Normal file
View File

@ -0,0 +1,42 @@
import type { Node, ParseResult } from './types.ts';
export function parsePuml(src: string): ParseResult {
const lines = src
.split('\n')
.map(s => s.trim())
.filter(s => s && !s.startsWith("'") && s !== '@startuml' && s !== '@enduml');
const root: Node = { id: '__root__', label: 'root', children: [], conns: [], parent: undefined };
const stack: Node[] = [root];
const byId: Record<string, Node> = {};
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(): Node {
return stack[stack.length - 1]!;
}
for (const line of lines) {
let m: RegExpExecArray | null;
if ((m = aliasRe.exec(line))) {
const node: 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: 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 };
}

242
src/style.css Normal file
View File

@ -0,0 +1,242 @@
*, *::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;
}
#bugJson {
height: auto;
}
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; }

111
src/svg.ts Normal file
View File

@ -0,0 +1,111 @@
import type { BlockInfo, PaletteEntry } from './types.ts';
const SVG_NS = 'http://www.w3.org/2000/svg';
function ns(tag: string): SVGElement {
return document.createElementNS(SVG_NS, tag) as SVGElement;
}
function makePoly(pts: [number, number][], cssVar: string): SVGPolygonElement {
const el = ns('polygon') as SVGPolygonElement;
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;
}
export function makeBlock(
svg: SVGSVGElement,
x: number, y: number,
W: number, H: number,
pal: PaletteEntry,
label: string,
sublabel: string | null,
clickable: boolean,
bugCount = 0,
): BlockInfo {
const g = ns('g') as SVGGElement;
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') as SVGTextElement;
tx.setAttribute('x', String(x)); tx.setAttribute('y', String(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') as SVGTextElement;
ts.setAttribute('x', String(x)); ts.setAttribute('y', String(y + hh + 15));
ts.setAttribute('text-anchor', 'middle');
ts.setAttribute('class', 'iso-sub');
ts.textContent = sublabel;
g.appendChild(ts);
}
if (bugCount > 0) {
const bx = x + hw - 8, by = y + 8;
const circle = ns('circle') as SVGCircleElement;
circle.setAttribute('cx', String(bx)); circle.setAttribute('cy', String(by));
circle.setAttribute('r', '9');
circle.setAttribute('fill', '#d32f2f');
circle.setAttribute('stroke', '#fff'); circle.setAttribute('stroke-width', '1.5');
g.appendChild(circle);
const bt = ns('text') as SVGTextElement;
bt.setAttribute('x', String(bx)); bt.setAttribute('y', String(by));
bt.setAttribute('text-anchor', 'middle');
bt.setAttribute('dominant-baseline', 'central');
bt.setAttribute('fill', '#fff');
bt.setAttribute('font-size', '8');
bt.setAttribute('font-weight', 'bold');
bt.textContent = bugCount > 99 ? '99+' : String(bugCount);
g.appendChild(bt);
}
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,
};
}
export function makeArrow(svg: SVGSVGElement): void {
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') as SVGPathElement;
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);
}
export function makeLine(svg: SVGSVGElement, x1: number, y1: number, x2: number, y2: number): void {
const p = ns('path') as SVGPathElement;
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);
}

44
src/types.ts Normal file
View File

@ -0,0 +1,44 @@
export interface Connection {
from: string;
to: string;
label: string;
}
export interface Node {
id: string;
label: string;
children: Node[];
conns: Connection[];
parent: Node | undefined;
}
export interface ParseResult {
root: Node;
byId: Record<string, Node>;
}
export interface PaletteEntry {
/** CSS custom property name for the top face */
t: string;
/** CSS custom property name for the left face */
l: string;
/** CSS custom property name for the right face */
r: string;
}
export interface BlockInfo {
g: SVGGElement;
cx: number;
cy: number;
top_y: number;
bot_y: number;
left_x: number;
right_x: number;
}
export interface AppState {
parsed: ParseResult | null;
viewStack: Node[];
bugData: Record<string, number>;
currentNode: Node | null;
}

26
tsconfig.json Normal file
View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}