structural_design_env / server /interactive_demo.html
Ayush-Singh's picture
minior fixes
e60ffc0
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StructuralDesignEnv Interactive Demo</title>
<style>
:root {
color-scheme: dark;
--bg: #07131d;
--bg-soft: #0d1d29;
--panel: rgba(13, 29, 41, 0.92);
--panel-strong: rgba(18, 38, 52, 0.96);
--line: rgba(255, 255, 255, 0.1);
--line-strong: rgba(255, 255, 255, 0.18);
--text: #f4efe6;
--muted: #98acb8;
--accent: #e9a24f;
--accent-soft: rgba(233, 162, 79, 0.16);
--secondary: #68c3b3;
--secondary-soft: rgba(104, 195, 179, 0.16);
--good: #74c77b;
--warn: #f2c462;
--danger: #ef6b5b;
--shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
--radius: 22px;
--radius-sm: 14px;
--heading-font: Georgia, "Times New Roman", serif;
--body-font: "Avenir Next", "Trebuchet MS", sans-serif;
--mono-font: "SFMono-Regular", "Consolas", monospace;
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
min-height: 100vh;
font-family: var(--body-font);
color: var(--text);
background:
radial-gradient(circle at top left, rgba(104, 195, 179, 0.16), transparent 28%),
radial-gradient(circle at top right, rgba(233, 162, 79, 0.18), transparent 24%),
linear-gradient(180deg, #08131d 0%, #091925 45%, #07131d 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.4), transparent 88%);
}
a {
color: inherit;
}
button,
select {
font: inherit;
}
.page-shell {
width: min(1500px, calc(100vw - 32px));
margin: 0 auto;
padding: 28px 0 36px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(320px, 0.9fr);
gap: 24px;
align-items: stretch;
margin-bottom: 24px;
}
.hero-copy,
.hero-card,
.panel {
position: relative;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.hero-copy {
padding: 32px;
background:
radial-gradient(circle at 0% 0%, rgba(233, 162, 79, 0.14), transparent 36%),
radial-gradient(circle at 100% 30%, rgba(104, 195, 179, 0.16), transparent 34%),
linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(9, 21, 31, 0.94));
}
.hero-card {
padding: 26px;
background:
linear-gradient(180deg, rgba(14, 31, 44, 0.98), rgba(11, 24, 34, 0.95));
}
.eyebrow,
.section-label {
margin: 0 0 10px;
color: var(--secondary);
font-size: 0.77rem;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
h3 {
margin: 0;
font-family: var(--heading-font);
font-weight: 700;
letter-spacing: -0.02em;
}
h1 {
max-width: 12ch;
font-size: clamp(2.8rem, 5vw, 4.8rem);
line-height: 0.95;
}
h2 {
font-size: 1.65rem;
line-height: 1.05;
}
p {
margin: 0;
line-height: 1.55;
}
.lede {
max-width: 58ch;
margin-top: 18px;
font-size: 1.06rem;
color: var(--muted);
}
.hero-actions,
.top-meta,
.mode-strip,
.control-grid,
.selection-actions,
.legend,
.footer-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.hero-actions {
margin-top: 24px;
}
.top-meta {
margin-top: 26px;
gap: 14px;
}
.hero-stat {
min-width: 140px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
background: rgba(255, 255, 255, 0.03);
}
.hero-stat-label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.hero-stat-value {
font-size: 1rem;
font-weight: 700;
}
.btn,
.mode-button,
.task-card,
.member-row {
transition:
transform 160ms ease,
border-color 160ms ease,
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.btn,
.mode-button,
.selection-actions button,
.footer-links a {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 0 16px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--text);
text-decoration: none;
background: rgba(255, 255, 255, 0.03);
cursor: pointer;
}
.btn:hover,
.mode-button:hover,
.selection-actions button:hover,
.footer-links a:hover,
.task-card:hover,
.member-row:hover {
transform: translateY(-1px);
border-color: var(--line-strong);
background: rgba(255, 255, 255, 0.06);
}
.btn.primary,
.selection-actions button.primary {
background: linear-gradient(135deg, rgba(233, 162, 79, 0.96), rgba(239, 107, 91, 0.92));
color: #1a0f07;
border-color: transparent;
font-weight: 700;
}
.btn.secondary {
background: rgba(104, 195, 179, 0.1);
color: #d8fbf4;
}
.btn:disabled,
.mode-button:disabled,
.selection-actions button:disabled,
select:disabled {
opacity: 0.46;
cursor: not-allowed;
transform: none;
}
.hero-card p {
margin-top: 10px;
color: var(--muted);
}
.task-cards {
display: grid;
gap: 12px;
margin-top: 18px;
}
.task-card {
width: 100%;
padding: 16px;
border: 1px solid var(--line);
border-radius: 18px;
background: rgba(255, 255, 255, 0.03);
color: inherit;
text-align: left;
cursor: pointer;
}
.task-card.active {
border-color: rgba(233, 162, 79, 0.6);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18);
}
.task-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
}
.task-pill,
.status-pill,
.floor-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
}
.task-pill.easy,
.status-pill.good {
color: #daf5dd;
background: rgba(116, 199, 123, 0.16);
border-color: rgba(116, 199, 123, 0.28);
}
.task-pill.medium,
.status-pill.warn {
color: #fff3cc;
background: rgba(242, 196, 98, 0.16);
border-color: rgba(242, 196, 98, 0.28);
}
.task-pill.hard,
.status-pill.danger {
color: #ffd8d3;
background: rgba(239, 107, 91, 0.16);
border-color: rgba(239, 107, 91, 0.28);
}
.status-pill.idle {
color: var(--muted);
}
.task-card-meta {
margin-top: 10px;
font-size: 0.84rem;
color: var(--muted);
}
.workspace {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.88fr);
gap: 24px;
}
.panel {
padding: 24px;
background:
linear-gradient(180deg, rgba(13, 29, 41, 0.98), rgba(10, 22, 31, 0.94));
}
.panel + .panel {
margin-top: 18px;
}
.panel-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 18px;
}
.session-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--muted);
font-size: 0.84rem;
max-width: 100%;
}
.session-pill code,
.meta-row code {
overflow-wrap: anywhere;
font-family: var(--mono-font);
color: #fbd8ae;
}
.toolbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
label.control {
display: flex;
flex-direction: column;
gap: 8px;
color: var(--muted);
font-size: 0.88rem;
}
select {
width: 100%;
min-height: 46px;
padding: 0 14px;
border: 1px solid var(--line);
border-radius: 14px;
color: var(--text);
background: rgba(255, 255, 255, 0.04);
outline: none;
}
.mode-strip {
margin-bottom: 14px;
}
.mode-button.active,
.floor-button.active {
border-color: rgba(104, 195, 179, 0.52);
color: #dcfffa;
background: var(--secondary-soft);
box-shadow: inset 0 0 0 1px rgba(104, 195, 179, 0.16);
}
.control-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
margin-bottom: 14px;
}
.control-actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 14px;
}
.notice {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
font-size: 0.92rem;
color: var(--text);
background: rgba(255, 255, 255, 0.04);
}
.notice.info {
background: rgba(104, 195, 179, 0.1);
border-color: rgba(104, 195, 179, 0.28);
}
.notice.warn {
background: rgba(242, 196, 98, 0.12);
border-color: rgba(242, 196, 98, 0.28);
}
.notice.error {
background: rgba(239, 107, 91, 0.12);
border-color: rgba(239, 107, 91, 0.3);
}
.floor-strip {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.floor-button {
cursor: pointer;
}
.canvas-wrap {
position: relative;
min-height: 420px;
border-radius: 22px;
border: 1px solid var(--line);
background:
linear-gradient(180deg, rgba(14, 29, 40, 0.94), rgba(9, 20, 29, 0.98));
overflow: hidden;
}
#gridSvg {
display: block;
width: 100%;
height: auto;
min-height: 420px;
}
.grid-empty-state {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
text-align: center;
color: var(--muted);
font-size: 1rem;
background: linear-gradient(180deg, rgba(7, 19, 29, 0.24), rgba(7, 19, 29, 0.7));
}
.legend {
align-items: center;
gap: 10px 14px;
margin-top: 14px;
color: var(--muted);
font-size: 0.84rem;
}
.legend-chip {
display: inline-flex;
align-items: center;
gap: 8px;
}
.legend-swatch {
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.16);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric {
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
}
.metric-label {
display: block;
color: var(--muted);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 6px;
}
.metric-value {
font-size: 1.12rem;
font-weight: 700;
}
.summary-copy {
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
white-space: pre-wrap;
}
.meta-list {
display: grid;
gap: 10px;
}
.meta-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding-bottom: 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: var(--muted);
font-size: 0.92rem;
}
.meta-row:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.meta-row strong {
color: var(--text);
text-align: right;
max-width: 68%;
overflow-wrap: anywhere;
}
.selected-card {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
min-height: 158px;
}
.selected-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 14px;
}
.selected-field {
padding: 10px 12px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.selected-field span {
display: block;
margin-bottom: 4px;
color: var(--muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.selected-field strong {
font-size: 0.96rem;
}
.selection-actions {
margin-top: 14px;
}
pre.code-box {
margin: 14px 0 0;
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(4, 11, 17, 0.72);
color: #d8e9f0;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--mono-font);
font-size: 0.86rem;
line-height: 1.55;
min-height: 78px;
}
.impact-box {
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
color: var(--muted);
font-size: 0.9rem;
min-height: 54px;
}
.member-list {
display: grid;
gap: 10px;
}
.member-row {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 16px;
color: inherit;
background: rgba(255, 255, 255, 0.03);
text-align: left;
cursor: pointer;
}
.member-row.is-selected {
border-color: rgba(233, 162, 79, 0.54);
background: var(--accent-soft);
box-shadow: inset 0 0 0 1px rgba(233, 162, 79, 0.18);
}
.member-copy {
min-width: 0;
}
.member-title {
font-weight: 700;
overflow-wrap: anywhere;
}
.member-subtitle {
margin-top: 4px;
color: var(--muted);
font-size: 0.84rem;
}
.member-ur {
min-width: 78px;
text-align: right;
font-weight: 700;
}
.server-message {
min-height: 180px;
margin: 0;
}
.empty-copy {
color: var(--muted);
min-height: 72px;
display: flex;
align-items: center;
}
.ur-good {
color: var(--good);
}
.ur-warn {
color: var(--warn);
}
.ur-danger {
color: var(--danger);
}
footer.panel {
margin-top: 24px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
color: var(--muted);
}
.footer-links a {
min-height: 40px;
border-radius: 12px;
}
@media (max-width: 1180px) {
.hero,
.workspace {
grid-template-columns: 1fr;
}
}
@media (max-width: 860px) {
.page-shell {
width: min(100vw - 20px, 100%);
padding-top: 18px;
}
.hero-copy,
.hero-card,
.panel {
padding: 18px;
}
.toolbar,
.control-grid,
.metric-grid,
.selected-grid {
grid-template-columns: 1fr;
}
.panel-head,
footer.panel,
.meta-row {
flex-direction: column;
align-items: flex-start;
}
.meta-row strong {
max-width: 100%;
text-align: left;
}
}
</style>
</head>
<body>
<div class="page-shell">
<header class="hero">
<section class="hero-copy">
<p class="eyebrow">OpenEnv / Hugging Face Docker Space</p>
<h1>Design a steel frame live in the browser.</h1>
<p class="lede">
This Space now includes a real interactive simulation. Start an episode, click the grid to place columns,
connect them with beams or shear walls, and watch the structural metrics update after every API-backed step.
</p>
<div class="hero-actions">
<button id="heroStartButton" class="btn primary" type="button" data-start-episode>New Episode</button>
<a class="btn secondary" href="/docs">API Docs</a>
<a class="btn secondary" href="/tasks">Tasks JSON</a>
<a class="btn secondary" href="/action_schema">Action Schema</a>
</div>
<div class="top-meta">
<div class="hero-stat">
<span class="hero-stat-label">API</span>
<span class="hero-stat-value" id="apiStatus">Loading</span>
</div>
<div class="hero-stat">
<span class="hero-stat-label">Episode</span>
<span class="hero-stat-value" id="episodeStatus">Not started</span>
</div>
<div class="hero-stat">
<span class="hero-stat-label">Solver</span>
<span class="hero-stat-value" id="solverStatus">Idle</span>
</div>
</div>
</section>
<aside class="hero-card">
<p class="section-label">Live Tasks</p>
<h2>Pick a scenario and build directly on the site grid.</h2>
<p>
The task list below is loaded from the API, so the demo stays aligned with the current environment config.
</p>
<div class="task-cards" id="taskCards"></div>
</aside>
</header>
<main class="workspace">
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Interactive Simulation</p>
<h2>Site Grid</h2>
</div>
<div class="session-pill">Session <code id="sessionIdDisplay">not started</code></div>
</div>
<div class="toolbar">
<label class="control">
Task
<select id="taskSelect"></select>
</label>
<label class="control">
Floor
<div class="floor-strip" id="floorButtons"></div>
</label>
</div>
<div class="mode-strip">
<button id="modeInspect" class="mode-button" type="button">Inspect</button>
<button id="modeColumn" class="mode-button" type="button">Place Column</button>
<button id="modeBeam" class="mode-button" type="button">Place Beam</button>
<button id="modeWall" class="mode-button" type="button">Add Wall</button>
</div>
<div class="control-grid">
<label class="control">
Column section
<select id="columnSection"></select>
</label>
<label class="control">
Beam section
<select id="beamSection"></select>
</label>
<label class="control">
Wall thickness
<select id="wallThickness">
<option value="0.2">0.2 m</option>
<option value="0.3">0.3 m</option>
</select>
</label>
<div class="control">
Actions
<div class="control-actions">
<button id="workspaceStartButton" class="btn secondary" type="button" data-start-episode>New Episode</button>
<button id="doneButton" class="btn primary" type="button">Finish Design</button>
</div>
</div>
</div>
<div class="notice info" id="noticeBar">Loading API metadata...</div>
<div class="canvas-wrap">
<svg id="gridSvg" viewBox="0 0 800 520" aria-label="Structural grid"></svg>
<div class="grid-empty-state" id="gridEmptyState">
Start a new episode to activate the interactive simulation.
</div>
</div>
<div class="legend">
<span class="legend-chip"><span class="legend-swatch" style="background:#74c77b"></span>UR &lt; 0.60</span>
<span class="legend-chip"><span class="legend-swatch" style="background:#f2c462"></span>0.60 to 1.00</span>
<span class="legend-chip"><span class="legend-swatch" style="background:#ef6b5b"></span>UR &gt;= 1.00</span>
<span class="legend-chip"><span class="legend-swatch" style="background:#68c3b3"></span>Selected member</span>
<span class="legend-chip"><span class="legend-swatch" style="background:#d6dbe1"></span>Column node</span>
</div>
</section>
<aside>
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Physics</p>
<h2>Live Results</h2>
</div>
<span class="status-pill idle" id="statusBadge">Idle</span>
</div>
<div class="metric-grid">
<div class="metric">
<span class="metric-label">Validity</span>
<span class="metric-value" id="metricValidity">-</span>
</div>
<div class="metric">
<span class="metric-label">Max UR</span>
<span class="metric-value" id="metricUR">-</span>
</div>
<div class="metric">
<span class="metric-label">Drift Ratio</span>
<span class="metric-value" id="metricDrift">-</span>
</div>
<div class="metric">
<span class="metric-label">Deflection</span>
<span class="metric-value" id="metricDeflection">-</span>
</div>
<div class="metric">
<span class="metric-label">Steel Mass</span>
<span class="metric-value" id="metricMass">-</span>
</div>
<div class="metric">
<span class="metric-label">Carbon</span>
<span class="metric-value" id="metricCarbon">-</span>
</div>
<div class="metric">
<span class="metric-label">Frame Type</span>
<span class="metric-value" id="metricFrameType">-</span>
</div>
<div class="metric">
<span class="metric-label">Last Reward</span>
<span class="metric-value" id="metricReward">-</span>
</div>
<div class="metric">
<span class="metric-label">Final Score</span>
<span class="metric-value" id="metricScore">-</span>
</div>
</div>
<div class="summary-copy" id="summaryMessage">
Episode-level feedback will appear here after the first action.
</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Episode</p>
<h2>Session Details</h2>
</div>
</div>
<div class="meta-list" id="episodeMeta"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Selection</p>
<h2>Element Inspector</h2>
</div>
</div>
<div class="selected-card" id="selectedElementCard">
<div class="empty-copy">Click a rendered member in Inspect mode to inspect forces and run edits.</div>
</div>
<div class="selection-actions">
<button id="clearSelectionButton" type="button">Clear Selection</button>
<button id="upgradeButton" type="button">Upgrade</button>
<button id="downgradeButton" type="button">Downgrade</button>
<button id="removeButton" type="button">Remove</button>
<button id="whatIfButton" type="button">What-If Remove</button>
</div>
<pre class="code-box" id="forcesBox">No member selected.</pre>
<div class="impact-box" id="impactBox">Counterfactual impact will appear here.</div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Critical Members</p>
<h2>Top Checks</h2>
</div>
</div>
<div class="member-list" id="criticalMembersList"></div>
</section>
<section class="panel">
<div class="panel-head">
<div>
<p class="section-label">Server Summary</p>
<h2>Observation Message</h2>
</div>
</div>
<pre class="code-box server-message" id="serverMessage">Reset the environment to see the backend summary.</pre>
</section>
</aside>
</main>
<footer class="panel">
<p>
StructuralDesignEnv couples a live editing UI with the existing reset, step, query, and what-if endpoints so users can explore the environment directly inside the Space.
</p>
<div class="footer-links">
<a href="/demo">/demo</a>
<a href="/docs">/docs</a>
<a href="/health">/health</a>
</div>
</footer>
</div>
<script>
const SVG_NS = "http://www.w3.org/2000/svg";
const fallbackSections = {
columns: ["HEB140", "HEB160", "HEB200", "HEB240", "HEB300", "HEB360", "HEB400"],
beams: ["IPE200", "IPE240", "IPE300", "IPE360", "IPE400", "IPE450", "IPE500"]
};
const state = {
health: null,
schema: null,
tasks: [],
sessionId: null,
observation: null,
selectedFloor: 0,
selectedElementId: null,
selectedElementForces: null,
selectedElementImpact: null,
mode: "inspect",
pendingPoint: null,
busy: false,
busyLabel: "",
lastReward: null,
lastInfo: null,
episodeDone: false,
notice: { text: "Loading API metadata...", tone: "info" }
};
const $ = (id) => document.getElementById(id);
document.addEventListener("DOMContentLoaded", () => {
bindEvents();
bootstrap();
});
function bindEvents() {
document.querySelectorAll("[data-start-episode]").forEach((button) => {
button.addEventListener("click", () => {
if (!state.busy) {
startEpisode();
}
});
});
$("taskSelect").addEventListener("change", () => {
if (!state.observation) {
state.selectedFloor = 0;
}
renderAll();
});
$("modeInspect").addEventListener("click", () => setMode("inspect"));
$("modeColumn").addEventListener("click", () => setMode("column"));
$("modeBeam").addEventListener("click", () => setMode("beam"));
$("modeWall").addEventListener("click", () => setMode("wall"));
$("doneButton").addEventListener("click", () => {
if (!state.busy) {
finalizeEpisode();
}
});
$("clearSelectionButton").addEventListener("click", () => {
clearSelection();
});
$("upgradeButton").addEventListener("click", () => {
if (!state.busy) {
sendElementMutation("upgrade_section");
}
});
$("downgradeButton").addEventListener("click", () => {
if (!state.busy) {
sendElementMutation("downgrade_section");
}
});
$("removeButton").addEventListener("click", () => {
if (!state.busy) {
sendElementMutation("remove_element");
}
});
$("whatIfButton").addEventListener("click", () => {
if (!state.busy) {
runWhatIfRemove();
}
});
}
async function bootstrap() {
setBusy(true, "Loading");
try {
const [health, tasksResponse, schema] = await Promise.all([
api("/health"),
api("/tasks"),
api("/action_schema")
]);
state.health = health;
state.tasks = Array.isArray(tasksResponse.tasks) ? tasksResponse.tasks : [];
state.schema = schema || {};
populateTaskSelect();
populateSectionSelects();
setNotice("The demo is ready. Start a new episode to begin placing members.", "info");
} catch (error) {
setNotice(error.message, "error");
} finally {
setBusy(false);
renderAll();
}
}
async function startEpisode() {
const taskId = selectedTaskId();
setBusy(true, "Resetting");
try {
const payload = await api("/reset", {
method: "POST",
body: JSON.stringify({ task_id: taskId })
});
state.sessionId = payload.session_id;
state.lastReward = null;
state.lastInfo = null;
state.episodeDone = false;
state.selectedFloor = 0;
state.selectedElementId = null;
state.selectedElementForces = null;
state.selectedElementImpact = null;
state.pendingPoint = null;
applyObservation(payload.observation);
setMode("column");
setNotice("Episode ready. Click the grid to place columns on floor 0.", "info");
} catch (error) {
setNotice(error.message, "error");
} finally {
setBusy(false);
renderAll();
}
}
async function finalizeEpisode() {
if (!state.sessionId || !state.observation || state.episodeDone) {
return;
}
await sendAction({ action_type: "done" }, "Final grading");
}
async function sendElementMutation(actionType) {
const element = getSelectedElement();
if (!element || !state.sessionId || state.episodeDone) {
return;
}
await sendAction({ action_type: actionType, element_id: element.id }, "Updating section");
}
async function sendAction(action, busyLabel) {
if (!state.sessionId) {
setNotice("Start an episode before sending actions.", "warn");
renderAll();
return;
}
setBusy(true, busyLabel || "Sending action");
try {
const payload = await api("/step", {
method: "POST",
body: JSON.stringify({
session_id: state.sessionId,
message: JSON.stringify(action)
})
});
state.lastReward = typeof payload.reward === "number" ? payload.reward : null;
state.lastInfo = payload.info || {};
state.episodeDone = Boolean(payload.done);
state.pendingPoint = null;
applyObservation(payload.observation);
if (state.observation && state.observation.last_action_result === "INVALID") {
setNotice(state.observation.last_action_error || "The backend rejected that action.", "warn");
} else if (state.episodeDone && typeof state.lastInfo.graded_score === "number") {
setNotice("Episode finished. Final score: " + formatNumber(state.lastInfo.graded_score, 4), "info");
} else {
setNotice(actionSummary(action), "info");
}
await refreshSelectedElementDetails();
} catch (error) {
setNotice(error.message, "error");
} finally {
setBusy(false);
renderAll();
}
}
async function runWhatIfRemove() {
const element = getSelectedElement();
if (!element || !state.sessionId) {
return;
}
setBusy(true, "Running what-if");
try {
state.selectedElementImpact = await api("/what_if_remove", {
method: "POST",
body: JSON.stringify({
session_id: state.sessionId,
element_id: element.id
})
});
setNotice("Counterfactual complete for " + element.id + ".", "info");
} catch (error) {
state.selectedElementImpact = { error: error.message };
setNotice(error.message, "warn");
} finally {
setBusy(false);
renderAll();
}
}
function applyObservation(observation) {
state.observation = observation || null;
if (state.observation) {
const availableFloors = Math.max(1, state.observation.n_floors || 1);
if (state.selectedFloor >= availableFloors) {
state.selectedFloor = availableFloors - 1;
}
} else {
state.selectedFloor = 0;
}
if (state.selectedElementId && !getSelectedElement()) {
state.selectedElementId = null;
state.selectedElementForces = null;
state.selectedElementImpact = null;
}
}
async function selectElement(elementId) {
if (!state.observation) {
return;
}
state.selectedElementId = elementId;
state.selectedElementForces = null;
state.selectedElementImpact = null;
renderAll();
await refreshSelectedElementDetails();
}
async function refreshSelectedElementDetails() {
const element = getSelectedElement();
if (!element || !state.sessionId) {
state.selectedElementForces = null;
return;
}
try {
state.selectedElementForces = await api(
"/query_forces?session_id=" + encodeURIComponent(state.sessionId) +
"&element_id=" + encodeURIComponent(element.id)
);
} catch (error) {
state.selectedElementForces = { error: error.message };
}
renderAll();
}
function clearSelection() {
state.selectedElementId = null;
state.selectedElementForces = null;
state.selectedElementImpact = null;
state.pendingPoint = null;
setNotice(modeHint(), "info");
renderAll();
}
function populateTaskSelect() {
const select = $("taskSelect");
const active = select.value || "task1_warehouse";
select.innerHTML = state.tasks.map((task) => (
"<option value=\"" + escapeHtml(task.id) + "\"" +
(task.id === active ? " selected" : "") + ">" +
escapeHtml(task.name) + " (" + escapeHtml(task.id) + ")" +
"</option>"
)).join("");
if (!select.value && state.tasks.length) {
select.value = state.tasks[0].id;
}
}
function populateSectionSelects() {
const schemaSections = state.schema && state.schema.sections ? state.schema.sections : fallbackSections;
populateSelectWithOptions($("columnSection"), schemaSections.columns || fallbackSections.columns);
populateSelectWithOptions($("beamSection"), schemaSections.beams || fallbackSections.beams);
$("columnSection").value = "HEB200";
$("beamSection").value = "IPE300";
}
function populateSelectWithOptions(select, options) {
select.innerHTML = options.map((value) => (
"<option value=\"" + escapeHtml(value) + "\">" + escapeHtml(value) + "</option>"
)).join("");
}
function selectedTaskId() {
return $("taskSelect").value || "task1_warehouse";
}
function currentTaskId() {
return state.observation ? state.observation.task_id : selectedTaskId();
}
function currentTaskMeta() {
return state.tasks.find((task) => task.id === currentTaskId()) || null;
}
function setMode(mode) {
state.mode = mode;
state.pendingPoint = null;
setNotice(modeHint(), "info");
renderAll();
}
function setBusy(isBusy, label) {
state.busy = isBusy;
state.busyLabel = label || "";
}
function setNotice(text, tone) {
state.notice = { text: text, tone: tone || "info" };
}
function renderAll() {
renderHeader();
renderTaskCards();
renderFloorButtons();
renderModeButtons();
renderNotice();
renderGrid();
renderMetrics();
renderEpisodeMeta();
renderSelectedElement();
renderCriticalMembers();
renderServerMessage();
refreshControlStates();
}
function renderHeader() {
const health = state.health;
$("apiStatus").textContent = health ? (health.status + " / v" + health.version) : "Unavailable";
$("episodeStatus").textContent = state.observation
? ("Step " + state.observation.step_count + " / " + state.observation.max_steps)
: "Not started";
$("solverStatus").textContent = state.busy
? state.busyLabel || "Working"
: solverLabel();
$("sessionIdDisplay").textContent = state.sessionId || "not started";
}
function renderTaskCards() {
const wrapper = $("taskCards");
if (!state.tasks.length) {
wrapper.innerHTML = "<div class=\"empty-copy\">No tasks available.</div>";
return;
}
const activeId = selectedTaskId();
wrapper.innerHTML = state.tasks.map((task) => {
const difficultyClass = difficultyTone(task.difficulty);
return (
"<button type=\"button\" class=\"task-card " + (task.id === activeId ? "active" : "") + "\" data-task-id=\"" + escapeHtml(task.id) + "\">" +
"<div class=\"task-card-head\">" +
"<strong>" + escapeHtml(task.name) + "</strong>" +
"<span class=\"task-pill " + difficultyClass + "\">" + escapeHtml(task.difficulty) + "</span>" +
"</div>" +
"<p>" + escapeHtml(task.description || "") + "</p>" +
"<div class=\"task-card-meta\">" +
escapeHtml(task.id) + " | " +
escapeHtml(String(task.n_floors)) + " floor(s) | " +
escapeHtml(String(task.max_steps)) + " steps" +
"</div>" +
"</button>"
);
}).join("");
wrapper.querySelectorAll("[data-task-id]").forEach((button) => {
button.addEventListener("click", () => {
if (state.busy) {
return;
}
$("taskSelect").value = button.getAttribute("data-task-id");
if (!state.observation) {
state.selectedFloor = 0;
}
renderAll();
});
});
}
function renderFloorButtons() {
const wrapper = $("floorButtons");
const floorCount = state.observation
? Math.max(1, state.observation.n_floors || 1)
: Math.max(1, currentTaskMeta() ? currentTaskMeta().n_floors : 1);
if (state.selectedFloor >= floorCount) {
state.selectedFloor = floorCount - 1;
}
wrapper.innerHTML = Array.from({ length: floorCount }, (_, floor) => (
"<button type=\"button\" class=\"floor-button " + (floor === state.selectedFloor ? "active" : "") + "\" data-floor=\"" + floor + "\">" +
"Floor " + floor +
"</button>"
)).join("");
wrapper.querySelectorAll("[data-floor]").forEach((button) => {
button.addEventListener("click", () => {
state.selectedFloor = Number(button.getAttribute("data-floor"));
state.pendingPoint = null;
renderAll();
});
});
}
function renderModeButtons() {
const mapping = {
inspect: $("modeInspect"),
column: $("modeColumn"),
beam: $("modeBeam"),
wall: $("modeWall")
};
Object.entries(mapping).forEach(([mode, button]) => {
button.classList.toggle("active", state.mode === mode);
});
}
function renderNotice() {
const notice = $("noticeBar");
const tone = state.notice ? state.notice.tone : "info";
notice.className = "notice " + tone;
notice.textContent = state.busy ? (state.busyLabel + "...") : (state.notice ? state.notice.text : modeHint());
}
function renderGrid() {
const svg = $("gridSvg");
const emptyState = $("gridEmptyState");
svg.innerHTML = "";
if (!state.observation) {
emptyState.style.display = "flex";
return;
}
emptyState.style.display = "none";
const width = Math.max(1, Math.round(state.observation.site_width_m));
const depth = Math.max(1, Math.round(state.observation.site_depth_m));
const cell = Math.max(24, Math.floor(620 / Math.max(width, depth)));
const padding = 56;
const viewWidth = padding * 2 + width * cell;
const viewHeight = padding * 2 + depth * cell;
svg.setAttribute("viewBox", "0 0 " + viewWidth + " " + viewHeight);
svg.style.aspectRatio = viewWidth + " / " + viewHeight;
const centerX = (x) => padding + (x * cell) + (cell / 2);
const centerY = (y) => padding + ((depth - y - 1) * cell) + (cell / 2);
const cellLeft = (x) => padding + (x * cell);
const cellTop = (y) => padding + ((depth - y - 1) * cell);
svg.appendChild(createSvg("rect", {
x: padding,
y: padding,
width: width * cell,
height: depth * cell,
rx: 18,
fill: "rgba(9, 20, 29, 0.84)",
stroke: "rgba(255, 255, 255, 0.12)",
"stroke-width": 1.5
}));
for (let x = 0; x <= width; x += 1) {
svg.appendChild(createSvg("line", {
x1: padding + x * cell,
y1: padding,
x2: padding + x * cell,
y2: padding + depth * cell,
stroke: "rgba(255, 255, 255, 0.08)",
"stroke-width": 1
}));
}
for (let y = 0; y <= depth; y += 1) {
svg.appendChild(createSvg("line", {
x1: padding,
y1: padding + y * cell,
x2: padding + width * cell,
y2: padding + y * cell,
stroke: "rgba(255, 255, 255, 0.08)",
"stroke-width": 1
}));
}
for (let x = 0; x < width; x += 1) {
svg.appendChild(createSvg("text", {
x: centerX(x),
y: viewHeight - 20,
"text-anchor": "middle",
fill: "rgba(255, 255, 255, 0.48)",
"font-size": 12,
"font-family": "monospace"
}, String(x)));
}
for (let y = 0; y < depth; y += 1) {
svg.appendChild(createSvg("text", {
x: 28,
y: centerY(y) + 4,
"text-anchor": "middle",
fill: "rgba(255, 255, 255, 0.48)",
"font-size": 12,
"font-family": "monospace"
}, String(y)));
}
svg.appendChild(createSvg("text", {
x: padding,
y: 28,
fill: "#dfeaf0",
"font-size": 14,
"font-family": "\"Avenir Next\", \"Trebuchet MS\", sans-serif"
}, currentTaskId() + " / floor " + state.selectedFloor));
for (let x = 0; x < width; x += 1) {
for (let y = 0; y < depth; y += 1) {
const hit = createSvg("rect", {
x: cellLeft(x),
y: cellTop(y),
width: cell,
height: cell,
fill: "transparent",
cursor: state.busy ? "wait" : "pointer"
});
hit.addEventListener("click", () => handleGridPointClick(x, y));
svg.appendChild(hit);
}
}
const elements = state.observation.placed_elements || [];
elements
.filter((element) => element.type !== "column" && elementVisibleOnFloor(element, state.selectedFloor))
.forEach((element) => {
const start = parseNodeId(element.node_i);
const end = parseNodeId(element.node_j);
if (!start || !end) {
return;
}
const ur = urForElement(element.id);
const selected = state.selectedElementId === element.id;
const stroke = selected ? "#68c3b3" : urColor(ur);
const widthValue = element.type === "wall" ? Math.max(12, cell * 0.3) : Math.max(6, cell * 0.16);
if (selected) {
svg.appendChild(createSvg("line", {
x1: centerX(start.x),
y1: centerY(start.y),
x2: centerX(end.x),
y2: centerY(end.y),
stroke: "rgba(104, 195, 179, 0.18)",
"stroke-width": widthValue + 8,
"stroke-linecap": "round"
}));
}
const line = createSvg("line", {
x1: centerX(start.x),
y1: centerY(start.y),
x2: centerX(end.x),
y2: centerY(end.y),
stroke: stroke,
"stroke-width": widthValue,
"stroke-linecap": "round",
opacity: 0.96,
cursor: "pointer"
});
line.addEventListener("click", (event) => {
event.stopPropagation();
if (state.mode === "inspect") {
selectElement(element.id);
}
});
svg.appendChild(line);
});
elements
.filter((element) => element.type === "column" && elementVisibleOnFloor(element, state.selectedFloor))
.forEach((element) => {
const node = parseNodeId(element.node_i);
if (!node) {
return;
}
const ur = urForElement(element.id);
const selected = state.selectedElementId === element.id;
const circle = createSvg("circle", {
cx: centerX(node.x),
cy: centerY(node.y),
r: Math.max(8, cell * 0.2),
fill: selected ? "#68c3b3" : urColor(ur),
stroke: selected ? "#dff9f4" : "#f6ede1",
"stroke-width": selected ? 2.5 : 1.2,
cursor: "pointer"
});
circle.addEventListener("click", (event) => {
event.stopPropagation();
if (state.mode === "inspect") {
selectElement(element.id);
} else {
handleGridPointClick(node.x, node.y);
}
});
svg.appendChild(circle);
});
if (state.pendingPoint) {
svg.appendChild(createSvg("circle", {
cx: centerX(state.pendingPoint.x),
cy: centerY(state.pendingPoint.y),
r: Math.max(12, cell * 0.28),
fill: "transparent",
stroke: "#f6ede1",
"stroke-width": 2.5,
"stroke-dasharray": "6 4"
}));
}
}
function handleGridPointClick(x, y) {
if (state.busy) {
return;
}
if (!state.observation) {
setNotice("Start a new episode first.", "warn");
renderAll();
return;
}
if (state.episodeDone) {
setNotice("This episode is finished. Start a new one to continue editing.", "warn");
renderAll();
return;
}
if (state.mode === "inspect") {
state.selectedElementId = null;
state.selectedElementForces = null;
state.selectedElementImpact = null;
setNotice("Inspect mode is active. Click a rendered member to inspect it.", "info");
renderAll();
return;
}
if (state.mode === "column") {
sendAction({
action_type: "place_column",
grid_x: x,
grid_y: y,
floor: state.selectedFloor,
section: $("columnSection").value
}, "Placing column");
return;
}
if (!columnExistsAt(x, y, state.selectedFloor)) {
setNotice("Beam and wall endpoints must already have columns on this floor.", "warn");
renderAll();
return;
}
if (!state.pendingPoint) {
state.pendingPoint = { x: x, y: y };
setNotice(
"Start point locked at (" + x + ", " + y + "). Choose an axis-aligned end point.",
"info"
);
renderAll();
return;
}
if (state.pendingPoint.x === x && state.pendingPoint.y === y) {
state.pendingPoint = null;
setNotice(modeHint(), "info");
renderAll();
return;
}
if (state.pendingPoint.x !== x && state.pendingPoint.y !== y) {
setNotice("Only axis-aligned members are supported here. Choose a point sharing x or y.", "warn");
renderAll();
return;
}
const action = state.mode === "beam"
? {
action_type: "place_beam",
from_node_x: state.pendingPoint.x,
from_node_y: state.pendingPoint.y,
to_node_x: x,
to_node_y: y,
floor: state.selectedFloor,
section: $("beamSection").value,
orientation: state.pendingPoint.y === y ? "x" : "y"
}
: {
action_type: "add_wall",
from_node_x: state.pendingPoint.x,
from_node_y: state.pendingPoint.y,
to_node_x: x,
to_node_y: y,
floor: state.selectedFloor,
thickness_m: Number($("wallThickness").value),
orientation: state.pendingPoint.y === y ? "x" : "y"
};
sendAction(action, state.mode === "beam" ? "Placing beam" : "Adding wall");
}
function renderMetrics() {
const observation = state.observation;
if (!observation) {
$("statusBadge").className = "status-pill idle";
$("statusBadge").textContent = state.busy ? state.busyLabel : "Idle";
$("metricValidity").textContent = "-";
$("metricUR").textContent = "-";
$("metricDrift").textContent = "-";
$("metricDeflection").textContent = "-";
$("metricMass").textContent = "-";
$("metricCarbon").textContent = "-";
$("metricFrameType").textContent = "-";
$("metricReward").textContent = "-";
$("metricScore").textContent = "-";
$("summaryMessage").textContent = "Episode-level feedback will appear here after the first action.";
return;
}
const validity = observation.is_structurally_valid ? "Valid" : (observation.n_elements_placed ? "Invalid" : "Awaiting frame");
const maxUr = observation.critical_members && observation.critical_members.length
? observation.critical_members[0].max_UR
: null;
const score = state.lastInfo && typeof state.lastInfo.graded_score === "number"
? state.lastInfo.graded_score
: null;
$("statusBadge").className = "status-pill " + statusTone(observation);
$("statusBadge").textContent = state.busy ? state.busyLabel : solverLabel();
$("metricValidity").textContent = validity;
$("metricUR").textContent = maxUr === null ? "-" : formatNumber(maxUr, 3);
$("metricUR").className = "metric-value " + urToneClass(maxUr);
$("metricDrift").textContent = observation.n_elements_placed
? formatNumber(observation.max_lateral_drift_ratio, 3)
: "-";
$("metricDeflection").textContent = observation.n_elements_placed
? formatNumber(observation.max_deflection_mm, 2) + " mm"
: "-";
$("metricMass").textContent = observation.n_elements_placed
? Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg"
: "-";
$("metricCarbon").textContent = observation.n_elements_placed
? Math.round(observation.carbon_kg).toLocaleString() + " kg"
: "-";
$("metricFrameType").textContent = observation.is_braced_frame ? "Braced" : "Unbraced";
$("metricReward").textContent = typeof state.lastReward === "number" ? formatNumber(state.lastReward, 4) : "-";
$("metricScore").textContent = typeof score === "number" ? formatNumber(score, 4) : "-";
$("summaryMessage").textContent = summaryCopy(observation);
}
function renderEpisodeMeta() {
const wrapper = $("episodeMeta");
const observation = state.observation;
const rows = [
{ label: "Task", value: observation ? observation.task_id : currentTaskId() },
{ label: "Episode ID", value: observation ? observation.episode_id : "-" },
{ label: "Session ID", value: state.sessionId ? "<code>" + escapeHtml(state.sessionId) + "</code>" : "-" },
{ label: "Steps", value: observation ? (observation.step_count + " / " + observation.max_steps) : "-" },
{ label: "Elements", value: observation ? String(observation.n_elements_placed) : "-" },
{ label: "Last action", value: observation ? observation.last_action_result : "-" },
{ label: "Error", value: observation && observation.last_action_error ? observation.last_action_error : "None" },
{ label: "Done", value: state.episodeDone ? "Yes" : "No" }
];
if (state.lastInfo && typeof state.lastInfo.graded_score === "number") {
rows.push({ label: "Final score", value: formatNumber(state.lastInfo.graded_score, 4) });
}
wrapper.innerHTML = rows.map((row) => (
"<div class=\"meta-row\"><span>" + escapeHtml(row.label) + "</span><strong>" + row.value + "</strong></div>"
)).join("");
}
function renderSelectedElement() {
const wrapper = $("selectedElementCard");
const element = getSelectedElement();
if (!element) {
wrapper.innerHTML = "<div class=\"empty-copy\">Click a rendered member in Inspect mode to inspect forces and run edits.</div>";
$("forcesBox").textContent = "No member selected.";
$("impactBox").textContent = "Counterfactual impact will appear here.";
return;
}
const critical = criticalMemberById(element.id);
const floor = elementDisplayFloor(element);
wrapper.innerHTML =
"<strong>" + escapeHtml(element.id) + "</strong>" +
"<div class=\"selected-grid\">" +
selectedField("Type", element.type) +
selectedField("Section", element.section || "wall") +
selectedField("Floor", String(floor)) +
selectedField("Length", formatNumber(element.length_m, 2) + " m") +
selectedField("Max UR", critical ? formatNumber(critical.max_UR, 3) : "n/a") +
selectedField("Orientation", element.orientation || "-") +
"</div>";
$("forcesBox").textContent = forceSummary();
$("impactBox").textContent = impactSummary();
}
function renderCriticalMembers() {
const wrapper = $("criticalMembersList");
const observation = state.observation;
if (!observation || !observation.critical_members || !observation.critical_members.length) {
wrapper.innerHTML = "<div class=\"empty-copy\">Critical members appear after the solver has a connected frame to analyze.</div>";
return;
}
wrapper.innerHTML = observation.critical_members.slice(0, 8).map((member) => (
"<button type=\"button\" class=\"member-row " + (state.selectedElementId === member.id ? "is-selected" : "") + "\" data-member-id=\"" + escapeHtml(member.id) + "\">" +
"<span class=\"member-copy\">" +
"<span class=\"member-title\">" + escapeHtml(member.id) + "</span>" +
"<span class=\"member-subtitle\">" +
escapeHtml(member.type) + " | " + escapeHtml(member.section) + " | L=" + escapeHtml(formatNumber(member.length_m, 2)) + " m" +
"</span>" +
"</span>" +
"<span class=\"member-ur " + urToneClass(member.max_UR) + "\">" + escapeHtml(formatNumber(member.max_UR, 3)) + "</span>" +
"</button>"
)).join("");
wrapper.querySelectorAll("[data-member-id]").forEach((button) => {
button.addEventListener("click", () => {
if (!state.busy) {
selectElement(button.getAttribute("data-member-id"));
}
});
});
}
function renderServerMessage() {
$("serverMessage").textContent = state.observation
? state.observation.message
: "Reset the environment to see the backend summary.";
}
function refreshControlStates() {
const hasEpisode = Boolean(state.observation && state.sessionId);
const selectedElement = getSelectedElement();
const selectedIsWall = Boolean(selectedElement && selectedElement.type === "wall");
$("taskSelect").disabled = state.busy;
$("columnSection").disabled = state.busy || !hasEpisode || state.episodeDone;
$("beamSection").disabled = state.busy || !hasEpisode || state.episodeDone;
$("wallThickness").disabled = state.busy || !hasEpisode || state.episodeDone;
$("doneButton").disabled = state.busy || !hasEpisode || state.episodeDone;
$("clearSelectionButton").disabled = state.busy || (!selectedElement && !state.pendingPoint);
$("upgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone;
$("downgradeButton").disabled = state.busy || !selectedElement || selectedIsWall || state.episodeDone;
$("removeButton").disabled = state.busy || !selectedElement || state.episodeDone;
$("whatIfButton").disabled = state.busy || !selectedElement;
document.querySelectorAll("[data-start-episode]").forEach((button) => {
button.disabled = state.busy;
});
}
function createSvg(tag, attrs, text) {
const element = document.createElementNS(SVG_NS, tag);
Object.entries(attrs || {}).forEach(([key, value]) => {
element.setAttribute(key, String(value));
});
if (text !== undefined) {
element.textContent = text;
}
return element;
}
function getSelectedElement() {
return state.observation && state.selectedElementId
? (state.observation.placed_elements || []).find((element) => element.id === state.selectedElementId) || null
: null;
}
function criticalMemberById(elementId) {
return state.observation
? (state.observation.critical_members || []).find((member) => member.id === elementId) || null
: null;
}
function urForElement(elementId) {
const member = criticalMemberById(elementId);
return member ? member.max_UR : null;
}
function columnExistsAt(x, y, floor) {
return Boolean(state.observation && (state.observation.placed_elements || []).some((element) => {
if (element.type !== "column") {
return false;
}
const node = parseNodeId(element.node_i);
return Boolean(node && node.x === x && node.y === y && node.floor === floor);
}));
}
function parseNodeId(nodeId) {
const match = /^n_(\d+)_(\d+)_(\d+)$/.exec(nodeId || "");
if (!match) {
return null;
}
return {
x: Number(match[1]),
y: Number(match[2]),
floor: Number(match[3])
};
}
function elementVisibleOnFloor(element, floor) {
const node = parseNodeId(element.node_i);
if (!node) {
return false;
}
if (element.type === "column") {
return node.floor === floor;
}
return node.floor === floor + 1;
}
function elementDisplayFloor(element) {
const node = parseNodeId(element.node_i);
if (!node) {
return 0;
}
return element.type === "column" ? node.floor : Math.max(0, node.floor - 1);
}
function solverLabel() {
if (!state.observation) {
return "Idle";
}
if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") {
return "Graded";
}
if (!state.observation.n_elements_placed) {
return "Awaiting frame";
}
if (!state.observation.critical_members.length) {
return "Disconnected";
}
return state.observation.is_structurally_valid ? "Valid" : "Invalid";
}
function statusTone(observation) {
if (!observation || !observation.n_elements_placed) {
return "idle";
}
if (observation.is_structurally_valid) {
return "good";
}
if (observation.critical_members.length) {
return "danger";
}
return "warn";
}
function difficultyTone(difficulty) {
const value = String(difficulty || "").toLowerCase();
if (value === "easy") {
return "easy";
}
if (value === "hard") {
return "hard";
}
return "medium";
}
function urColor(ur) {
if (typeof ur !== "number") {
return "#d6dbe1";
}
if (ur < 0.6) {
return "#74c77b";
}
if (ur < 1.0) {
return "#f2c462";
}
return "#ef6b5b";
}
function urToneClass(ur) {
if (typeof ur !== "number") {
return "";
}
if (ur < 0.6) {
return "ur-good";
}
if (ur < 1.0) {
return "ur-warn";
}
return "ur-danger";
}
function summaryCopy(observation) {
if (!observation) {
return "Episode-level feedback will appear here after the first action.";
}
if (observation.last_action_result === "INVALID" && observation.last_action_error) {
return "Invalid action: " + observation.last_action_error;
}
if (state.episodeDone && state.lastInfo && typeof state.lastInfo.graded_score === "number") {
return "Episode complete. Final score " + formatNumber(state.lastInfo.graded_score, 4) +
". Structural validity: " + (observation.is_structurally_valid ? "pass" : "fail") + ".";
}
if (!observation.n_elements_placed) {
return "No members placed yet. Use Place Column mode to seed the frame.";
}
if (!observation.critical_members.length) {
return "The frame has members, but the solver has not produced full member checks yet. Add connectivity and supports.";
}
return (
(observation.is_structurally_valid ? "Current frame passes the available checks." : "Current frame has " + observation.n_code_violations + " code violation(s).") +
" Max UR " + formatNumber(observation.critical_members[0].max_UR, 3) +
", drift " + formatNumber(observation.max_lateral_drift_ratio, 3) +
", mass " + Math.round(observation.total_steel_mass_kg).toLocaleString() + " kg."
);
}
function forceSummary() {
const element = getSelectedElement();
if (!element) {
return "No member selected.";
}
if (!state.selectedElementForces) {
return "Loading member forces...";
}
if (state.selectedElementForces.error) {
return "Member forces unavailable: " + state.selectedElementForces.error;
}
const forces = state.selectedElementForces.forces || {};
return [
"Element: " + element.id,
"Section: " + (state.selectedElementForces.section || element.section || "wall"),
"Length: " + formatNumber(state.selectedElementForces.length_m, 2) + " m",
"N: " + formatNumber(forces.N_kN, 3) + " kN",
"V: " + formatNumber(forces.V_kN, 3) + " kN",
"Mmax: " + formatNumber(forces.M_max_kNm, 3) + " kNm",
"Delta: " + formatNumber(forces.delta_max_mm, 3) + " mm"
].join("\n");
}
function impactSummary() {
const impact = state.selectedElementImpact;
if (!impact) {
return "Counterfactual impact will appear here.";
}
if (impact.error) {
return "What-if failed: " + impact.error;
}
return [
"Verdict: " + impact.verdict,
"Current max UR: " + safeFormat(impact.current_max_UR, 4),
"Without member: " + safeFormat(impact.counterfactual_max_UR, 4),
"Delta UR: " + safeFormat(impact.delta_UR, 4)
].join(" | ");
}
function actionSummary(action) {
if (!action || !action.action_type) {
return modeHint();
}
if (action.action_type === "place_column") {
return "Column placed at (" + action.grid_x + ", " + action.grid_y + ") on floor " + action.floor + ".";
}
if (action.action_type === "place_beam") {
return "Beam placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ").";
}
if (action.action_type === "add_wall") {
return "Wall placed between (" + action.from_node_x + ", " + action.from_node_y + ") and (" + action.to_node_x + ", " + action.to_node_y + ").";
}
if (action.action_type === "remove_element") {
return "Removed " + action.element_id + ".";
}
if (action.action_type === "upgrade_section") {
return "Upgraded " + action.element_id + ".";
}
if (action.action_type === "downgrade_section") {
return "Downgraded " + action.element_id + ".";
}
if (action.action_type === "done") {
return "Final grading requested.";
}
return modeHint();
}
function modeHint() {
if (state.mode === "column") {
return "Place Column mode: click any valid grid point on the current floor.";
}
if (state.mode === "beam") {
return state.pendingPoint
? "Beam mode: choose the second endpoint on the same x or y line."
: "Beam mode: click two existing columns on the current floor.";
}
if (state.mode === "wall") {
return state.pendingPoint
? "Wall mode: choose the second endpoint on the same x or y line."
: "Wall mode: click two existing columns to add a shear wall.";
}
return "Inspect mode: click rendered members to view forces, upgrade sections, remove them, or run what-if analysis.";
}
function selectedField(label, value) {
return (
"<div class=\"selected-field\">" +
"<span>" + escapeHtml(label) + "</span>" +
"<strong>" + escapeHtml(value) + "</strong>" +
"</div>"
);
}
async function api(url, options) {
const request = Object.assign(
{
headers: { "Content-Type": "application/json" }
},
options || {}
);
if (!request.body) {
delete request.headers["Content-Type"];
}
const response = await fetch(url, request);
const text = await response.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (error) {
payload = text;
}
}
if (!response.ok) {
if (payload && typeof payload === "object" && payload.detail) {
throw new Error(typeof payload.detail === "string" ? payload.detail : JSON.stringify(payload.detail));
}
throw new Error(typeof payload === "string" ? payload : ("Request failed with status " + response.status));
}
return payload;
}
function formatNumber(value, digits) {
return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "-";
}
function safeFormat(value, digits) {
return typeof value === "number" && Number.isFinite(value) ? value.toFixed(digits) : "n/a";
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
</script>
</body>
</html>