Luca Celebrano
feat(portal): upgrade HF Space to 5-module Grand Unified Portal with embedded 60 FPS Digital Twin
81ca10f Download simulator.html from spatial-continuum-lab/spatial-oncology-simulator: direct link, hf CLI and curl.
- Browser
- Download file 57.4 kB
-
https://huggingface.co/spaces/spatial-continuum-lab/spatial-oncology-simulator/resolve/main/simulator.html
- Command line
-
hf download hf://spaces/spatial-continuum-lab/spatial-oncology-simulator/simulator.html
-
curl -L -o simulator.html https://huggingface.co/spaces/spatial-continuum-lab/spatial-oncology-simulator/resolve/main/simulator.html
57.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPATIAL-CONTINUUM | In Silico Tumor Sanctuary & SG-ADAURA Digital Twin</title>
<style>
:root {
--bg-base: #070a12;
--bg-card: #0f1523;
--bg-card-hover: #161e31;
--bg-input: #0a0f1a;
--border: #1e293b;
--border-accent: #334155;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--cyan: #38bdf8;
--cyan-glow: rgba(56, 189, 248, 0.25);
--amber: #fbbf24;
--amber-glow: rgba(251, 191, 36, 0.25);
--rose: #f43f5e;
--rose-glow: rgba(244, 63, 94, 0.25);
--emerald: #10b981;
--emerald-glow: rgba(16, 185, 129, 0.25);
--purple: #a855f7;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background-color: var(--bg-base);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Header */
header {
background: linear-gradient(180deg, #111827 0%, #0b1120 100%);
border-bottom: 1px solid var(--border);
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.badge-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background: linear-gradient(135deg, #0284c7 0%, #0f172a 100%);
border: 1px solid var(--cyan);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 15px var(--cyan-glow);
}
.badge-icon svg {
width: 18px;
height: 18px;
stroke: #38bdf8;
fill: none;
}
.title-group h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
}
.tag-nature {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.15);
color: var(--cyan);
border: 1px solid rgba(56, 189, 248, 0.3);
}
.title-group p {
font-size: 11px;
color: var(--text-muted);
margin-top: 1px;
}
.header-actions {
display: flex;
align-items: center;
gap: 10px;
}
.btn {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-main);
padding: 6px 12px;
font-size: 11px;
font-weight: 600;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 5px;
transition: all 0.2s ease;
}
.btn:hover {
background: var(--bg-card-hover);
border-color: var(--border-accent);
transform: translateY(-1px);
}
.btn-primary {
background: linear-gradient(135deg, #0284c7 0%, #0369a1 100%);
border-color: var(--cyan);
box-shadow: 0 0 10px var(--cyan-glow);
}
.btn-primary:hover {
background: linear-gradient(135deg, #0369a1 0%, #0284c7 100%);
}
/* Key Metrics HUD */
.hud-bar {
background: var(--bg-card);
border-bottom: 1px solid var(--border);
padding: 8px 20px;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
@media (max-width: 900px) {
.hud-bar {
grid-template-columns: repeat(2, 1fr);
}
}
.metric-item {
display: flex;
flex-direction: column;
gap: 1px;
}
.metric-label {
font-size: 10px;
color: var(--text-dim);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.metric-value {
font-size: 18px;
font-weight: 700;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
letter-spacing: -0.5px;
}
.val-cyan { color: var(--cyan); }
.val-amber { color: var(--amber); }
.val-rose { color: var(--rose); }
.val-emerald { color: var(--emerald); }
/* Main Container */
.workspace {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 14px;
padding: 12px 20px;
flex: 1;
}
@media (max-width: 1200px) {
.workspace {
grid-template-columns: 1fr;
}
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
.card-header {
padding: 10px 14px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(15, 21, 35, 0.7);
}
.card-header h2 {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.5px;
display: flex;
align-items: center;
gap: 6px;
}
.card-body {
padding: 12px;
flex: 1;
display: flex;
flex-direction: column;
position: relative;
}
/* Spatial Canvas Container */
.canvas-container {
position: relative;
width: 100%;
height: 480px;
background: #040711;
border-radius: 6px;
border: 1px solid var(--border);
overflow: hidden;
cursor: crosshair;
}
.canvas-container canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
.canvas-overlay-hud {
position: absolute;
top: 10px;
left: 10px;
background: rgba(10, 15, 26, 0.92);
border: 1px solid var(--border-accent);
border-radius: 5px;
padding: 6px 10px;
font-size: 10px;
pointer-events: none;
backdrop-filter: blur(6px);
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
z-index: 10;
line-height: 1.4;
}
.canvas-legend {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
background: rgba(10, 15, 26, 0.92);
border: 1px solid var(--border-accent);
border-radius: 5px;
padding: 6px 10px;
display: flex;
justify-content: space-between;
align-items: center;
backdrop-filter: blur(6px);
font-size: 10px;
z-index: 10;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot-cyan { background: var(--cyan); box-shadow: 0 0 5px var(--cyan); }
.dot-amber { background: var(--amber); box-shadow: 0 0 5px var(--amber); }
.dot-rose { background: var(--rose); box-shadow: 0 0 5px var(--rose); }
/* Right Column: Instrument Cards */
.instrument-grid {
display: flex;
flex-direction: column;
gap: 12px;
height: 100%;
}
.mini-chart-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
height: 154px;
display: flex;
flex-direction: column;
box-shadow: 0 3px 12px rgba(0,0,0,0.3);
position: relative;
}
.mini-chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
height: 18px;
}
.mini-chart-header h3 {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.chart-container {
flex: 1;
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.chart-container canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
}
/* Controls Deck */
.control-deck {
background: var(--bg-card);
border-top: 1px solid var(--border);
padding: 12px 20px;
display: grid;
grid-template-columns: 260px 1.2fr 1fr;
gap: 18px;
}
@media (max-width: 1050px) {
.control-deck {
grid-template-columns: 1fr;
}
}
.protocol-select-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.protocol-card {
background: var(--bg-input);
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
flex-direction: column;
gap: 2px;
}
.protocol-card:hover {
border-color: var(--border-accent);
background: #111728;
}
.protocol-card.active {
border-color: var(--cyan);
background: rgba(56, 189, 248, 0.08);
box-shadow: 0 0 10px rgba(56, 189, 248, 0.15);
}
.protocol-card.active-adaura {
border-color: var(--rose);
background: rgba(244, 63, 94, 0.08);
box-shadow: 0 0 10px rgba(244, 63, 94, 0.15);
}
.proto-title {
font-size: 11px;
font-weight: 700;
display: flex;
justify-content: space-between;
align-items: center;
}
.proto-desc {
font-size: 9.5px;
color: var(--text-dim);
line-height: 1.3;
}
.slider-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
}
.slider-item {
display: flex;
flex-direction: column;
gap: 3px;
}
.slider-label {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
}
.slider-val {
font-family: monospace;
font-weight: 700;
color: var(--cyan);
}
input[type=range] {
-webkit-appearance: none;
width: 100%;
height: 3px;
border-radius: 2px;
background: var(--border);
outline: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--cyan);
cursor: pointer;
box-shadow: 0 0 5px var(--cyan);
transition: all 0.15s ease;
}
input[type=range]::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.preset-buttons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-top: 4px;
}
.btn-preset {
background: var(--bg-input);
border: 1px solid var(--border);
padding: 5px 8px;
border-radius: 4px;
color: var(--text-muted);
font-size: 10px;
cursor: pointer;
transition: all 0.15s ease;
text-align: left;
}
.btn-preset:hover {
border-color: var(--cyan);
color: var(--text-main);
}
</style>
</head>
<body>
<!-- Header -->
<header>
<div class="brand">
<div class="badge-icon">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" stroke-width="2"/>
<path d="M12 3v18M3 12h18" stroke-width="1.5" stroke-dasharray="2 2"/>
<circle cx="12" cy="12" r="4" fill="rgba(56, 189, 248, 0.4)"/>
</svg>
</div>
<div class="title-group">
<h1>
SPATIAL-CONTINUUM: The In Silico Sanctuary Digital Twin
<span class="tag-nature">Nature Comms Companion</span>
</h1>
<p>Biophysical Reaction-Diffusion & Evolutionary Game Engine for Post-ADAURA Sanctuary Dynamics • Luca Celebrano</p>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px;">
<a href="index.html#module-3" style="display: inline-flex; align-items: center; gap: 6px; background: rgba(56, 189, 248, 0.15); border: 1px solid var(--cyan); color: #38bdf8; text-decoration: none; padding: 5px 12px; border-radius: 6px; font-size: 12px; font-weight: 700; transition: all 0.2s ease;">
<span>← Back to Main Portal (5 Modules)</span>
</a>
</div>
<div class="header-actions">
<button class="btn btn-primary" id="btnPlayPause">
<span id="playIcon">⏸</span> <span id="playText">Pause Simulation</span>
</button>
<button class="btn" id="btnFastFwd">⏩ 2x Speed</button>
<button class="btn" id="btnReset">🔄 Reset</button>
<button class="btn" id="btnExportCSV">📊 Export Cohort CSV</button>
<button class="btn" id="btnSnapshot">📸 Snapshot</button>
</div>
</header>
<!-- Live Clinical HUD Bar -->
<div class="hud-bar">
<div class="metric-item">
<div class="metric-label">Simulated Timeline</div>
<div class="metric-value val-cyan" id="metricTime">Day 0 (0.0 Yrs)</div>
</div>
<div class="metric-item">
<div class="metric-label">Tumor Burden V(t)</div>
<div class="metric-value" id="metricVolume">100.0% <span style="font-size:11px; color:var(--text-dim)">(1000 mm³)</span></div>
</div>
<div class="metric-item">
<div class="metric-label">Continuum Resistance Index (CRI)</div>
<div class="metric-value val-amber" id="metricCRI">0.12 <span style="font-size:10px; font-weight:normal; color:var(--text-muted)">[Low Risk]</span></div>
</div>
<div class="metric-item">
<div class="metric-label">Sanctuary Surface Area</div>
<div class="metric-value val-rose" id="metricSanctuary">34.2% <span style="font-size:10px; font-weight:normal; color:var(--text-dim)">(Sub-therapeutic)</span></div>
</div>
<div class="metric-item">
<div class="metric-label">Phenotype Ratio (S : P : R)</div>
<div class="metric-value" id="metricRatio" style="font-size: 15px; line-height: 22px;">
<span style="color:var(--cyan)">88%</span> : <span style="color:var(--amber)">10%</span> : <span style="color:var(--rose)">2%</span>
</div>
</div>
</div>
<!-- Workspace: Canvas & Instrumentation -->
<div class="workspace">
<!-- Left: Spatial Tumor Microenvironment Canvas -->
<div class="card">
<div class="card-header">
<h2>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20M2 12h20"/>
</svg>
2D Radial Reaction-Diffusion & Cellular Architecture (500 μm Cord)
</h2>
<div style="display:flex; gap:6px;">
<button class="btn" style="padding:2px 7px; font-size:9.5px;" id="toggleOverlay">View: Drug Heatmap</button>
</div>
</div>
<div class="card-body">
<div class="canvas-container" id="canvasContainer">
<canvas id="spatialCanvas"></canvas>
<div class="canvas-overlay-hud" id="spatialHud">
<div style="font-weight:700; color:var(--text-main); margin-bottom:2px;">Microenvironment Probe:</div>
<div id="hudProbeDetails">Hover mouse over cord to inspect local microenvironment...</div>
</div>
<div class="canvas-legend">
<div class="legend-item"><span class="dot dot-cyan"></span> <strong>Sensitive (S)</strong> [EGFRmut, Osimertinib-Killable]</div>
<div class="legend-item"><span class="dot dot-amber"></span> <strong>Plastic (P)</strong> [DTP, AXL/ZEB1 Niche]</div>
<div class="legend-item"><span class="dot dot-rose"></span> <strong>Resistant (R)</strong> [C797S / MET amp]</div>
<div class="legend-item" style="color:var(--rose); font-weight:700;">⚡ Capillary Core (r=0)</div>
</div>
</div>
</div>
</div>
<!-- Right: Three Scientific Instruments -->
<div class="instrument-grid">
<!-- Instrument 1: Reaction-Diffusion Radial Gradient -->
<div class="mini-chart-card">
<div class="mini-chart-header">
<h3>PDE Radial Drug Gradient C(r) vs Sanctuary Window</h3>
<span style="font-size:9px; color:var(--text-dim)">D = 85 μm²/s • IFP = 18 mmHg</span>
</div>
<div class="chart-container">
<canvas id="gradientChart"></canvas>
</div>
</div>
<!-- Instrument 2: Longitudinal Head-to-Head Protocol Trial -->
<div class="mini-chart-card">
<div class="mini-chart-header">
<h3>5-Year Longitudinal Recurrence Trial V(t)</h3>
<div style="font-size:9px; display:flex; gap:8px;">
<span style="color:var(--rose)">● ADAURA (Stop Yr 3)</span>
<span style="color:var(--cyan)">● SG-ADAURA (Adaptive)</span>
</div>
</div>
<div class="chart-container">
<canvas id="trialChart"></canvas>
</div>
</div>
<!-- Instrument 3: Ternary Evolutionary Game Simplex (S-P-R) -->
<div class="mini-chart-card">
<div class="mini-chart-header">
<h3>Evolutionary Replicator Phase Simplex (S-P-R Attractor)</h3>
<span style="font-size:9px; color:var(--text-dim)">3x3 Game Payoff Trajectory</span>
</div>
<div class="chart-container">
<canvas id="simplexChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Bottom Control & Protocol Deck -->
<div class="control-deck">
<!-- Protocol Selection Cards -->
<div class="protocol-select-group">
<div style="font-size:10px; font-weight:700; color:var(--text-muted); text-transform:uppercase;">
Select Therapeutic Regimen:
</div>
<div class="protocol-card active-adaura" id="protoADAURA" onclick="setProtocol('ADAURA')">
<div class="proto-title">
<span>Standard ADAURA</span>
<span style="color:var(--rose); font-size:9px;">Continuous 80mg</span>
</div>
<div class="proto-desc">
Daily Osimertinib 36 months → Cessation → Sanctuary-driven explosive recurrence flare.
</div>
</div>
<div class="protocol-card" id="protoSG" onclick="setProtocol('SG_ADAURA')">
<div class="proto-title">
<span style="color:var(--cyan)">SG-ADAURA Protocol</span>
<span style="color:var(--cyan); font-size:9px;">Adaptive (CRI-Guided)</span>
</div>
<div class="proto-desc">
CRI-monitored pulsed dosing: preserves sensitive competitors, suppressing resistant expansion.
</div>
</div>
<div class="protocol-card" id="protoMetronomic" onclick="setProtocol('METRONOMIC')">
<div class="proto-title">
<span>Metronomic Maintenance</span>
<span style="color:var(--amber); font-size:9px;">Low-Dose 40mg</span>
</div>
<div class="proto-desc">
Continuous sub-maximal dosing. Flattens vascular peak but risks intermediate sanctuary niche.
</div>
</div>
</div>
<!-- Biophysical Sliders -->
<div class="slider-grid">
<div class="slider-item">
<div class="slider-label">
<span>Drug Diffusivity (D<sub>eff</sub>)</span>
<span class="slider-val" id="valDiff">85 μm²/s</span>
</div>
<input type="range" id="paramDiff" min="20" max="200" value="85" step="5">
</div>
<div class="slider-item">
<div class="slider-label">
<span>Capillary Permeability (P<sub>v</sub>)</span>
<span class="slider-val" id="valPerm">1.00</span>
</div>
<input type="range" id="paramPerm" min="0.2" max="2.5" value="1.0" step="0.1">
</div>
<div class="slider-item">
<div class="slider-label">
<span>Tumor Interstitial Pressure (IFP)</span>
<span class="slider-val" id="valIFP">18 mmHg</span>
</div>
<input type="range" id="paramIFP" min="0" max="40" value="18" step="1">
</div>
<div class="slider-item">
<div class="slider-label">
<span>Plasticity Transition (μ<sub>S→P</sub>)</span>
<span class="slider-val" id="valPlasticity">0.035 /day</span>
</div>
<input type="range" id="paramPlasticity" min="0.005" max="0.100" value="0.035" step="0.005">
</div>
<div class="slider-item">
<div class="slider-label">
<span>Cost of Resistance (c<sub>R</sub>)</span>
<span class="slider-val" id="valCostR">15% penalty</span>
</div>
<input type="range" id="paramCostR" min="0" max="35" value="15" step="1">
</div>
<div class="slider-item">
<div class="slider-label">
<span>Planned ADAURA Cessation</span>
<span class="slider-val" id="valDuration">36 Months</span>
</div>
<input type="range" id="paramDuration" min="12" max="60" value="36" step="6">
</div>
</div>
<!-- Presets & Quick Exploration -->
<div style="display:flex; flex-direction:column; gap:8px;">
<div style="font-size:10px; font-weight:700; color:var(--text-muted); text-transform:uppercase;">
Clinical Benchmark Scenarios:
</div>
<div class="preset-buttons">
<button class="btn-preset" onclick="applyPreset('standard_nsclc')">🎯 Baseline ADAURA NSCLC</button>
<button class="btn-preset" onclick="applyPreset('brain_met')">🧠 Brain / CNS Sanctuary</button>
<button class="btn-preset" onclick="applyPreset('high_plastic')">🧬 High AXL/ZEB1 Plasticity</button>
<button class="btn-preset" onclick="applyPreset('anti_vegf')">💉 Normalization (+Anti-VEGF)</button>
</div>
<div style="margin-top:auto; padding:8px 10px; background:var(--bg-input); border-radius:5px; border:1px solid var(--border); font-size:10px; color:var(--text-dim); line-height:1.35;">
<strong style="color:var(--text-muted)">The Spatial Invariant:</strong>
Whenever high interstitial fluid pressure impedes convective penetration, sub-lethal concentrations (<span style="color:var(--amber)">0.15 – 0.65 C₀</span>) create an inevitable evolutionary sanctuary breeding genetic resistance.
</div>
</div>
</div>
<!-- Interactive Script Engine -->
<script>
/* =========================================================================
* SPATIAL-CONTINUUM: Bio-physical Reaction-Diffusion & Replicator Engine
* Author: Luca Celebrano, MD Candidate, Università di Napoli Federico II
* ========================================================================= */
const STATE = {
running: true,
speed: 1,
protocol: 'ADAURA', // 'ADAURA', 'SG_ADAURA', 'METRONOMIC'
overlayMode: 'HEATMAP',
day: 0,
maxDays: 1825, // 5 Years (365.25 * 5)
dt: 0.5, // 0.5 days per step
Nr: 120, // radial bins
R_max: 500, // 500 microns
dr: 500 / 120,
// Biophysical Literature Parameters
D_eff: 85, // um^2/s (Jain et al.)
Pv: 1.0,
IFP: 18, // mmHg (Boucher & Jain)
k_clear: 0.12, // clearance /day
V_max: 0.8,
Km: 40, // nM
// Osimertinib PK/PD Constants
C_plasma_nominal: 100, // nM steady-state unbound active
C_sub: 18, // nM (lower boundary of sanctuary)
C_kill: 65, // nM (upper boundary of sanctuary)
// Evolutionary Parameters
r_S: 0.045, // /day intrinsic proliferation
r_P: 0.038,
r_R: 0.040,
c_R: 0.15, // 15% fitness penalty for resistance (Gatenby et al.)
mu_SP: 0.035, // plasticity rate in sanctuary
mu_PR: 0.002, // genetic lock-in mutation rate
cessationDay: 36 * 30.43, // 36 months ~ 1095 days
C: new Float32Array(120),
S: new Float32Array(120),
P: new Float32Array(120),
R: new Float32Array(120),
history: {
days: [],
vol_current: [],
vol_adaura_ref: [],
vol_sg_ref: [],
cri: [],
ratio_S: [],
ratio_P: [],
ratio_R: [],
sanctuary_pct: []
},
cells: []
};
let precomputedTrials = {
adaura: [],
sg_adaura: []
};
const canvasSpatial = document.getElementById('spatialCanvas');
const ctxSpatial = canvasSpatial.getContext('2d');
const canvasGradient = document.getElementById('gradientChart');
const ctxGradient = canvasGradient.getContext('2d');
const canvasTrial = document.getElementById('trialChart');
const ctxTrial = canvasTrial.getContext('2d');
const canvasSimplex = document.getElementById('simplexChart');
const ctxSimplex = canvasSimplex.getContext('2d');
let dims = {
spatial: { w: 0, h: 0 },
gradient: { w: 0, h: 0 },
trial: { w: 0, h: 0 },
simplex: { w: 0, h: 0 }
};
function resizeCanvas(canvas, key) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const w = Math.floor(rect.width);
const h = Math.floor(rect.height);
if (canvas.width !== w * dpr || canvas.height !== h * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
const ctx = canvas.getContext('2d');
ctx.resetTransform();
ctx.scale(dpr, dpr);
}
dims[key] = { w, h };
}
function resizeAllCanvases() {
resizeCanvas(canvasSpatial, 'spatial');
resizeCanvas(canvasGradient, 'gradient');
resizeCanvas(canvasTrial, 'trial');
resizeCanvas(canvasSimplex, 'simplex');
}
function initSimulation() {
STATE.day = 0;
window._baselineVolume = undefined;
window._sg_active = true;
STATE.history = {
days: [],
vol_current: [],
vol_adaura_ref: [],
vol_sg_ref: [],
cri: [],
ratio_S: [],
ratio_P: [],
ratio_R: [],
sanctuary_pct: []
};
// Initialize steady-state drug concentration profile at Day 0
const c_init = (STATE.protocol === 'METRONOMIC') ? 40 : 100;
for (let i = 0; i < STATE.Nr; i++) {
const r_ratio = i / STATE.Nr;
const r_um = i * STATE.dr;
// Exponential decay governed by D_eff and IFP
const decayLength = Math.max(80, Math.sqrt(STATE.D_eff * 100) / (1.0 + STATE.IFP / 20));
STATE.C[i] = c_init * Math.exp(-r_um / decayLength);
STATE.S[i] = 0.88 * (1.0 - 0.15 * r_ratio);
STATE.P[i] = 0.10 * (1.0 - 0.10 * r_ratio);
STATE.R[i] = 0.02; // 2% baseline resistant subclone
}
// Generate 2D visual cell particles
STATE.cells = [];
const numParticles = 2200;
for (let n = 0; n < numParticles; n++) {
const theta = Math.random() * 2 * Math.PI;
const r = Math.sqrt(Math.random()) * 210;
const bin = Math.min(STATE.Nr - 1, Math.floor((r / 210) * STATE.Nr));
STATE.cells.push({
r: r,
theta: theta,
bin: bin,
phenotype: Math.random() < 0.88 ? 'S' : (Math.random() < 0.85 ? 'P' : 'R'),
alive: true
});
}
precalculateCohortTrials();
resizeAllCanvases();
}
function precalculateCohortTrials() {
precomputedTrials.adaura = [];
precomputedTrials.sg_adaura = [];
let s_arr = new Float32Array(STATE.Nr).fill(0.88);
let p_arr = new Float32Array(STATE.Nr).fill(0.10);
let r_arr = new Float32Array(STATE.Nr).fill(0.02);
const totalSteps = Math.floor(STATE.maxDays / 2.0);
let initVol = 0;
for (let i = 0; i < STATE.Nr; i++) {
initVol += (s_arr[i] + p_arr[i] + r_arr[i]) * (i + 0.5) * STATE.dr;
}
// 1. ADAURA 36-Month Cessation Arm
for (let step = 0; step <= totalSteps; step++) {
const t = step * 2.0;
const inTreatment = (t <= (36 * 30.43));
const c_plasma = inTreatment ? 100 : 0;
let currentVol = 0;
for (let i = 0; i < STATE.Nr; i++) {
const r = (i + 0.5) * STATE.dr;
const conc = c_plasma * Math.exp(-r / 135);
const kill_s = 0.08 * (conc*conc) / (28*28 + conc*conc);
const kill_p = 0.02 * (conc*conc) / (80*80 + conc*conc);
const is_sanc = (conc >= STATE.C_sub && conc <= STATE.C_kill);
const mu_sp = is_sanc ? 0.035 : 0.001;
const mu_pr = is_sanc ? 0.0025 : 0.0003;
const rho = s_arr[i] + p_arr[i] + r_arr[i];
const lim = Math.max(0, 1.0 - rho);
s_arr[i] += ((0.045 * lim - kill_s - mu_sp) * s_arr[i]) * 2.0;
p_arr[i] += ((0.038 * 0.95 * lim - kill_p - mu_pr) * p_arr[i] + mu_sp * s_arr[i]) * 2.0;
r_arr[i] += ((0.040 * (1.0 - 0.15) * lim) * r_arr[i] + mu_pr * p_arr[i]) * 2.0;
s_arr[i] = Math.max(0, Math.min(1.0, s_arr[i]));
p_arr[i] = Math.max(0, Math.min(1.0, p_arr[i]));
r_arr[i] = Math.max(0, Math.min(1.0, r_arr[i]));
currentVol += (s_arr[i] + p_arr[i] + r_arr[i]) * r;
}
precomputedTrials.adaura.push({ day: t, vol: (currentVol / initVol) * 100 });
}
// 2. SG-ADAURA Adaptive Arm
s_arr.fill(0.88);
p_arr.fill(0.10);
r_arr.fill(0.02);
let sg_active = true;
for (let step = 0; step <= totalSteps; step++) {
const t = step * 2.0;
let currentVol = 0;
let s_tot = 0, p_sanc = 0;
for (let i = 0; i < STATE.Nr; i++) {
const r = (i + 0.5) * STATE.dr;
const vol_i = (s_arr[i] + p_arr[i] + r_arr[i]) * r;
currentVol += vol_i;
s_tot += s_arr[i] * r;
if (i >= 20 && i <= 80) p_sanc += p_arr[i] * r;
}
const cri = (currentVol > 0) ? (p_sanc / currentVol) : 0;
const volPct = (currentVol / initVol) * 100;
// Adaptive controller: stop drug when tumor < 18%, resume when tumor > 45% or CRI high
if (sg_active && volPct < 18) sg_active = false;
else if (!sg_active && (volPct > 45 || cri > 0.16)) sg_active = true;
const c_plasma = sg_active ? 100 : 0;
for (let i = 0; i < STATE.Nr; i++) {
const r = (i + 0.5) * STATE.dr;
const conc = c_plasma * Math.exp(-r / 135);
const kill_s = 0.08 * (conc*conc) / (28*28 + conc*conc);
const kill_p = 0.02 * (conc*conc) / (80*80 + conc*conc);
const is_sanc = (conc >= STATE.C_sub && conc <= STATE.C_kill);
const mu_sp = is_sanc ? 0.035 : 0.001;
const mu_pr = is_sanc ? 0.0025 : 0.0003;
const rho = s_arr[i] + p_arr[i] + r_arr[i];
const lim = Math.max(0, 1.0 - rho);
s_arr[i] += ((0.045 * lim - kill_s - mu_sp) * s_arr[i]) * 2.0;
p_arr[i] += ((0.038 * 0.95 * lim - kill_p - mu_pr) * p_arr[i] + mu_sp * s_arr[i]) * 2.0;
r_arr[i] += ((0.040 * (1.0 - 0.15) * lim) * r_arr[i] + mu_pr * p_arr[i]) * 2.0;
s_arr[i] = Math.max(0, Math.min(1.0, s_arr[i]));
p_arr[i] = Math.max(0, Math.min(1.0, p_arr[i]));
r_arr[i] = Math.max(0, Math.min(1.0, r_arr[i]));
}
precomputedTrials.sg_adaura.push({ day: t, vol: volPct });
}
}
function stepSimulation() {
const dt = STATE.dt;
STATE.day += dt;
let C_plasma = 0;
if (STATE.protocol === 'ADAURA') {
C_plasma = (STATE.day <= STATE.cessationDay) ? STATE.C_plasma_nominal : 0;
} else if (STATE.protocol === 'SG_ADAURA') {
const lastVol = (STATE.history.vol_current.length > 0) ?
STATE.history.vol_current[STATE.history.vol_current.length - 1] : 100;
const lastCRI = (STATE.history.cri.length > 0) ?
STATE.history.cri[STATE.history.cri.length - 1] : 0.12;
if (window._sg_active && lastVol < 18) {
window._sg_active = false;
} else if (!window._sg_active && (lastVol > 45 || lastCRI > 0.16)) {
window._sg_active = true;
}
C_plasma = window._sg_active ? STATE.C_plasma_nominal : 0;
} else if (STATE.protocol === 'METRONOMIC') {
C_plasma = STATE.C_plasma_nominal * 0.40;
}
// Reaction-Diffusion-Convection PDE
const D = STATE.D_eff * 0.0864;
const v_ifp = (STATE.IFP / 20.0) * 0.05;
STATE.C[0] = C_plasma * STATE.Pv;
for (let i = 1; i < STATE.Nr - 1; i++) {
const r = (i + 0.5) * STATE.dr;
const d2C = (STATE.C[i + 1] - 2 * STATE.C[i] + STATE.C[i - 1]) / (STATE.dr * STATE.dr);
const dC_dr = (STATE.C[i + 1] - STATE.C[i - 1]) / (2 * STATE.dr);
const Laplacian = d2C + (1 / r) * dC_dr;
const convection = v_ifp * dC_dr;
const totalCellDensity = STATE.S[i] + STATE.P[i] + STATE.R[i];
const uptake = (STATE.V_max * STATE.C[i]) / (STATE.Km + STATE.C[i]) * totalCellDensity;
const clear = STATE.k_clear * STATE.C[i];
const dC_dt = D * Laplacian - convection - uptake - clear;
STATE.C[i] += dC_dt * dt;
if (STATE.C[i] < 0) STATE.C[i] = 0;
}
STATE.C[STATE.Nr - 1] = STATE.C[STATE.Nr - 2];
// Replicator Updates
let totalS = 0, totalP = 0, totalR = 0;
let sanctuaryVolume = 0;
let totalVolumeWeight = 0;
let sanctuaryPersisters = 0;
for (let i = 0; i < STATE.Nr; i++) {
const r = (i + 0.5) * STATE.dr;
const shellWeight = 2 * Math.PI * r * STATE.dr;
const conc = STATE.C[i];
const rho = STATE.S[i] + STATE.P[i] + STATE.R[i];
const spaceLimitation = Math.max(0, 1.0 - rho);
const isSanctuary = (conc >= STATE.C_sub && conc <= STATE.C_kill);
if (isSanctuary) {
sanctuaryVolume += shellWeight;
sanctuaryPersisters += STATE.P[i] * shellWeight;
}
totalVolumeWeight += shellWeight;
const kill_S = 0.08 * Math.pow(conc, 2) / (Math.pow(28, 2) + Math.pow(conc, 2));
const kill_P = 0.02 * Math.pow(conc, 2) / (Math.pow(80, 2) + Math.pow(conc, 2));
const mu_sp_local = isSanctuary ? STATE.mu_SP : 0.001;
const mu_pr_local = isSanctuary ? STATE.mu_PR : 0.0003;
const dS = (STATE.r_S * spaceLimitation * STATE.S[i]) - (kill_S * STATE.S[i]) - (mu_sp_local * STATE.S[i]);
const dP = (STATE.r_P * 0.95 * spaceLimitation * STATE.P[i]) - (kill_P * STATE.P[i]) + (mu_sp_local * STATE.S[i]) - (mu_pr_local * STATE.P[i]);
const dR = (STATE.r_R * (1.0 - STATE.c_R) * spaceLimitation * STATE.R[i]) + (mu_pr_local * STATE.P[i]);
STATE.S[i] = Math.max(0, Math.min(1.0, STATE.S[i] + dS * dt));
STATE.P[i] = Math.max(0, Math.min(1.0, STATE.P[i] + dP * dt));
STATE.R[i] = Math.max(0, Math.min(1.0, STATE.R[i] + dR * dt));
totalS += STATE.S[i] * shellWeight;
totalP += STATE.P[i] * shellWeight;
totalR += STATE.R[i] * shellWeight;
}
const totalTumorWeight = totalS + totalP + totalR;
if (window._baselineVolume === undefined || STATE.day <= dt) {
window._baselineVolume = totalTumorWeight;
}
const volPct = (totalTumorWeight / window._baselineVolume) * 100.0;
const CRI = (totalTumorWeight > 0) ? (sanctuaryPersisters / totalTumorWeight) : 0;
const sancPct = (sanctuaryVolume / totalVolumeWeight) * 100.0;
if (Math.floor(STATE.day) > (STATE.history.days[STATE.history.days.length - 1] || -1)) {
STATE.history.days.push(Math.floor(STATE.day));
STATE.history.vol_current.push(volPct);
STATE.history.cri.push(CRI);
STATE.history.sanctuary_pct.push(sancPct);
STATE.history.ratio_S.push((totalS / totalTumorWeight) * 100);
STATE.history.ratio_P.push((totalP / totalTumorWeight) * 100);
STATE.history.ratio_R.push((totalR / totalTumorWeight) * 100);
}
updateCellParticles();
updateHUD(volPct, CRI, sancPct, totalS, totalP, totalR);
if (STATE.day >= STATE.maxDays) {
STATE.running = false;
document.getElementById('playText').innerText = 'Simulation Complete';
document.getElementById('playIcon').innerText = '✅';
}
}
function updateCellParticles() {
for (let cell of STATE.cells) {
const bin = Math.min(STATE.Nr - 1, Math.floor((cell.r / 210) * STATE.Nr));
const s_p = STATE.S[bin];
const p_p = STATE.P[bin];
const r_p = STATE.R[bin];
const total = s_p + p_p + r_p;
if (total < 0.05) {
cell.alive = false;
} else {
cell.alive = true;
const rand = Math.random() * total;
if (rand < s_p) cell.phenotype = 'S';
else if (rand < s_p + p_p) cell.phenotype = 'P';
else cell.phenotype = 'R';
}
}
}
function updateHUD(volPct, CRI, sancPct, totalS, totalP, totalR) {
const year = (STATE.day / 365.25).toFixed(1);
document.getElementById('metricTime').innerText = `Day ${Math.floor(STATE.day)} (${year} Yrs)`;
const mm3 = (1000 * (volPct / 100)).toFixed(0);
document.getElementById('metricVolume').innerHTML = `${volPct.toFixed(1)}% <span style="font-size:11px; color:var(--text-dim)">(${mm3} mm³)</span>`;
const criRisk = CRI < 0.08 ? '[Low Risk]' : (CRI < 0.20 ? '[Medium Sanctuary]' : '[High Recurrence Risk]');
const criClass = CRI < 0.08 ? 'val-emerald' : (CRI < 0.20 ? 'val-amber' : 'val-rose');
document.getElementById('metricCRI').className = `metric-value ${criClass}`;
document.getElementById('metricCRI').innerHTML = `${CRI.toFixed(3)} <span style="font-size:10px; font-weight:normal; color:var(--text-muted)">${criRisk}</span>`;
document.getElementById('metricSanctuary').innerText = `${sancPct.toFixed(1)}%`;
const tot = totalS + totalP + totalR || 1;
const pctS = Math.round((totalS / tot) * 100);
const pctP = Math.round((totalP / tot) * 100);
const pctR = Math.round((totalR / tot) * 100);
document.getElementById('metricRatio').innerHTML = `
<span style="color:var(--cyan)">${pctS}%</span> :
<span style="color:var(--amber)">${pctP}%</span> :
<span style="color:var(--rose)">${pctR}%</span>
`;
}
// --- Render Spatial Canvas ---
function renderSpatialCanvas() {
const w = dims.spatial.w;
const h = dims.spatial.h;
if (!w || !h) return;
const cx = w / 2;
const cy = h / 2;
const maxRadius = Math.min(cx, cy) - 20;
ctxSpatial.clearRect(0, 0, w, h);
// Heatmap Rings
const numRings = STATE.Nr;
for (let i = numRings - 1; i >= 0; i--) {
const r_outer = ((i + 1) / numRings) * maxRadius;
const conc = STATE.C[i];
ctxSpatial.beginPath();
ctxSpatial.arc(cx, cy, r_outer, 0, 2 * Math.PI);
if (STATE.overlayMode === 'HEATMAP') {
if (conc < STATE.C_sub) {
const alpha = 0.12 + (conc / STATE.C_sub) * 0.20;
ctxSpatial.fillStyle = `rgba(14, 28, 54, ${alpha})`;
} else if (conc <= STATE.C_kill) {
const frac = (conc - STATE.C_sub) / (STATE.C_kill - STATE.C_sub);
ctxSpatial.fillStyle = `rgba(251, 191, 36, ${0.16 + 0.22 * frac})`;
} else {
const frac = Math.min(1.0, (conc - STATE.C_kill) / 35.0);
ctxSpatial.fillStyle = `rgba(16, 185, 129, ${0.12 + 0.28 * frac})`;
}
} else {
ctxSpatial.fillStyle = '#060911';
}
ctxSpatial.fill();
}
// Sanctuary Boundary Rings
for (let i = 0; i < STATE.Nr - 1; i++) {
if ((STATE.C[i] >= STATE.C_sub && STATE.C[i+1] < STATE.C_sub) ||
(STATE.C[i] >= STATE.C_kill && STATE.C[i+1] < STATE.C_kill)) {
const r = (i / STATE.Nr) * maxRadius;
ctxSpatial.beginPath();
ctxSpatial.arc(cx, cy, r, 0, 2 * Math.PI);
ctxSpatial.strokeStyle = 'rgba(251, 191, 36, 0.45)';
ctxSpatial.lineWidth = 1.2;
ctxSpatial.setLineDash([4, 4]);
ctxSpatial.stroke();
ctxSpatial.setLineDash([]);
}
}
// Cellular Agents
for (let cell of STATE.cells) {
if (!cell.alive) continue;
const screenR = (cell.r / 210) * maxRadius;
const px = cx + screenR * Math.cos(cell.theta);
const py = cy + screenR * Math.sin(cell.theta);
ctxSpatial.beginPath();
ctxSpatial.arc(px, py, 2.2, 0, 2 * Math.PI);
if (cell.phenotype === 'S') ctxSpatial.fillStyle = '#38bdf8';
else if (cell.phenotype === 'P') ctxSpatial.fillStyle = '#fbbf24';
else ctxSpatial.fillStyle = '#f43f5e';
ctxSpatial.fill();
}
// Capillary Center
const pulse = 1.0 + 0.08 * Math.sin(Date.now() / 250);
const vRadius = 13 * pulse;
ctxSpatial.beginPath();
ctxSpatial.arc(cx, cy, vRadius + 6, 0, 2 * Math.PI);
ctxSpatial.fillStyle = 'rgba(244, 63, 94, 0.15)';
ctxSpatial.fill();
ctxSpatial.beginPath();
ctxSpatial.arc(cx, cy, vRadius, 0, 2 * Math.PI);
ctxSpatial.fillStyle = '#be123c';
ctxSpatial.strokeStyle = '#f43f5e';
ctxSpatial.lineWidth = 1.5;
ctxSpatial.fill();
ctxSpatial.stroke();
ctxSpatial.fillStyle = '#ffffff';
ctxSpatial.font = '8px monospace';
ctxSpatial.textAlign = 'center';
ctxSpatial.textBaseline = 'middle';
ctxSpatial.fillText('RBC', cx, cy);
}
// --- Render Gradient Chart C(r) ---
function renderGradientChart() {
const w = dims.gradient.w;
const h = dims.gradient.h;
if (!w || !h) return;
ctxGradient.clearRect(0, 0, w, h);
const padL = 34, padR = 14, padT = 8, padB = 22;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
// Sanctuary Band
const sancY_top = padT + plotH * (1.0 - STATE.C_kill / 100);
const sancY_bot = padT + plotH * (1.0 - STATE.C_sub / 100);
ctxGradient.fillStyle = 'rgba(251, 191, 36, 0.12)';
ctxGradient.fillRect(padL, sancY_top, plotW, sancY_bot - sancY_top);
ctxGradient.strokeStyle = 'rgba(251, 191, 36, 0.35)';
ctxGradient.setLineDash([2, 2]);
ctxGradient.beginPath();
ctxGradient.moveTo(padL, sancY_top); ctxGradient.lineTo(padL + plotW, sancY_top);
ctxGradient.moveTo(padL, sancY_bot); ctxGradient.lineTo(padL + plotW, sancY_bot);
ctxGradient.stroke();
ctxGradient.setLineDash([]);
ctxGradient.fillStyle = '#fbbf24';
ctxGradient.font = '8.5px sans-serif';
ctxGradient.textAlign = 'left';
ctxGradient.fillText('Sanctuary Window (18 - 65 nM)', padL + 8, sancY_top + 11);
// Axes
ctxGradient.strokeStyle = '#334155';
ctxGradient.lineWidth = 1;
ctxGradient.beginPath();
ctxGradient.moveTo(padL, padT); ctxGradient.lineTo(padL, padT + plotH);
ctxGradient.lineTo(padL + plotW, padT + plotH);
ctxGradient.stroke();
// Labels
ctxGradient.fillStyle = '#64748b';
ctxGradient.font = '8px monospace';
ctxGradient.textAlign = 'right';
ctxGradient.fillText('100', padL - 4, padT + 6);
ctxGradient.fillText('0', padL - 4, padT + plotH);
ctxGradient.textAlign = 'center';
ctxGradient.fillText('r=0', padL, padT + plotH + 13);
ctxGradient.fillText('250 μm', padL + plotW * 0.5, padT + plotH + 13);
ctxGradient.fillText('500 μm', padL + plotW - 12, padT + plotH + 13);
// C(r) Curve
ctxGradient.beginPath();
ctxGradient.strokeStyle = '#38bdf8';
ctxGradient.lineWidth = 2.0;
for (let i = 0; i < STATE.Nr; i++) {
const x = padL + (i / (STATE.Nr - 1)) * plotW;
const y = padT + plotH * (1.0 - Math.min(1.0, STATE.C[i] / 100));
if (i === 0) ctxGradient.moveTo(x, y);
else ctxGradient.lineTo(x, y);
}
ctxGradient.stroke();
}
// --- Render Trial Chart V(t) ---
function renderTrialChart() {
const w = dims.trial.w;
const h = dims.trial.h;
if (!w || !h) return;
ctxTrial.clearRect(0, 0, w, h);
const padL = 34, padR = 14, padT = 8, padB = 22;
const plotW = w - padL - padR;
const plotH = h - padT - padB;
// 36-Month Cessation Marker
const cessX = padL + ((36 * 30.43) / STATE.maxDays) * plotW;
ctxTrial.strokeStyle = 'rgba(244, 63, 94, 0.4)';
ctxTrial.setLineDash([3, 3]);
ctxTrial.beginPath();
ctxTrial.moveTo(cessX, padT); ctxTrial.lineTo(cessX, padT + plotH);
ctxTrial.stroke();
ctxTrial.setLineDash([]);
ctxTrial.fillStyle = '#f43f5e';
ctxTrial.font = '8px sans-serif';
ctxTrial.textAlign = 'center';
ctxTrial.fillText('Yr 3 Cessation', cessX, padT + 8);
// Axes
ctxTrial.strokeStyle = '#334155';
ctxTrial.lineWidth = 1;
ctxTrial.beginPath();
ctxTrial.moveTo(padL, padT); ctxTrial.lineTo(padL, padT + plotH);
ctxTrial.lineTo(padL + plotW, padT + plotH);
ctxTrial.stroke();
// Benchmark ADAURA (Red dashed)
ctxTrial.strokeStyle = '#f43f5e';
ctxTrial.lineWidth = 1.5;
ctxTrial.setLineDash([2, 2]);
ctxTrial.beginPath();
precomputedTrials.adaura.forEach((pt, idx) => {
const x = padL + (pt.day / STATE.maxDays) * plotW;
const y = padT + plotH * (1.0 - Math.min(1.2, pt.vol / 115));
if (idx === 0) ctxTrial.moveTo(x, y);
else ctxTrial.lineTo(x, y);
});
ctxTrial.stroke();
ctxTrial.setLineDash([]);
// Benchmark SG-ADAURA (Cyan dashed)
ctxTrial.strokeStyle = '#38bdf8';
ctxTrial.lineWidth = 1.5;
ctxTrial.setLineDash([2, 2]);
ctxTrial.beginPath();
precomputedTrials.sg_adaura.forEach((pt, idx) => {
const x = padL + (pt.day / STATE.maxDays) * plotW;
const y = padT + plotH * (1.0 - Math.min(1.2, pt.vol / 115));
if (idx === 0) ctxTrial.moveTo(x, y);
else ctxTrial.lineTo(x, y);
});
ctxTrial.stroke();
ctxTrial.setLineDash([]);
// Active Real-Time Simulation Run
if (STATE.history.days.length > 1) {
ctxTrial.beginPath();
ctxTrial.strokeStyle = (STATE.protocol === 'ADAURA') ? '#f43f5e' : '#10b981';
ctxTrial.lineWidth = 2.4;
for (let idx = 0; idx < STATE.history.days.length; idx++) {
const d = STATE.history.days[idx];
const v = STATE.history.vol_current[idx];
const x = padL + (d / STATE.maxDays) * plotW;
const y = padT + plotH * (1.0 - Math.min(1.2, v / 115));
if (idx === 0) ctxTrial.moveTo(x, y);
else ctxTrial.lineTo(x, y);
}
ctxTrial.stroke();
}
// Labels
ctxTrial.fillStyle = '#64748b';
ctxTrial.font = '8px monospace';
ctxTrial.textAlign = 'right';
ctxTrial.fillText('100%', padL - 4, padT + plotH * 0.15);
ctxTrial.fillText('0%', padL - 4, padT + plotH);
ctxTrial.textAlign = 'center';
ctxTrial.fillText('Yr 0', padL, padT + plotH + 13);
ctxTrial.fillText('Yr 1', padL + (1/5)*plotW, padT + plotH + 13);
ctxTrial.fillText('Yr 3', padL + (3/5)*plotW, padT + plotH + 13);
ctxTrial.fillText('Yr 5', padL + plotW - 10, padT + plotH + 13);
}
// --- Render Simplex Chart ---
function renderSimplexChart() {
const w = dims.simplex.w;
const h = dims.simplex.h;
if (!w || !h) return;
ctxSimplex.clearRect(0, 0, w, h);
const cx = w / 2;
const cy = h / 2 + 5;
const size = Math.min(w, h) * 0.34;
const vR = { x: cx, y: cy - size };
const vS = { x: cx - size * 0.95, y: cy + size * 0.60 };
const vP = { x: cx + size * 0.95, y: cy + size * 0.60 };
// Triangle Background
ctxSimplex.beginPath();
ctxSimplex.moveTo(vR.x, vR.y);
ctxSimplex.lineTo(vS.x, vS.y);
ctxSimplex.lineTo(vP.x, vP.y);
ctxSimplex.closePath();
ctxSimplex.fillStyle = 'rgba(11, 16, 29, 0.7)';
ctxSimplex.fill();
ctxSimplex.strokeStyle = '#334155';
ctxSimplex.lineWidth = 1.2;
ctxSimplex.stroke();
// Vertex Labels
ctxSimplex.font = '8.5px monospace';
ctxSimplex.fillStyle = '#f43f5e';
ctxSimplex.textAlign = 'center';
ctxSimplex.fillText('R (Resistant)', vR.x, vR.y - 5);
ctxSimplex.fillStyle = '#38bdf8';
ctxSimplex.textAlign = 'right';
ctxSimplex.fillText('S (Sensitive)', vS.x - 4, vS.y + 11);
ctxSimplex.fillStyle = '#fbbf24';
ctxSimplex.textAlign = 'left';
ctxSimplex.fillText('P (Plastic)', vP.x + 4, vP.y + 11);
function toCartesian(s, p, r) {
const tot = s + p + r || 1;
const normS = s / tot;
const normP = p / tot;
const normR = r / tot;
return {
x: normS * vS.x + normP * vP.x + normR * vR.x,
y: normS * vS.y + normP * vP.y + normR * vR.y
};
}
// Draw Trajectory
if (STATE.history.ratio_S.length > 1) {
ctxSimplex.beginPath();
ctxSimplex.strokeStyle = '#38bdf8';
ctxSimplex.lineWidth = 1.8;
for (let i = 0; i < STATE.history.ratio_S.length; i++) {
const pt = toCartesian(
STATE.history.ratio_S[i],
STATE.history.ratio_P[i],
STATE.history.ratio_R[i]
);
if (i === 0) ctxSimplex.moveTo(pt.x, pt.y);
else ctxSimplex.lineTo(pt.x, pt.y);
}
ctxSimplex.stroke();
const lastIdx = STATE.history.ratio_S.length - 1;
const curr = toCartesian(
STATE.history.ratio_S[lastIdx],
STATE.history.ratio_P[lastIdx],
STATE.history.ratio_R[lastIdx]
);
ctxSimplex.beginPath();
ctxSimplex.arc(curr.x, curr.y, 4, 0, 2 * Math.PI);
ctxSimplex.fillStyle = '#ffffff';
ctxSimplex.shadowColor = '#38bdf8';
ctxSimplex.shadowBlur = 6;
ctxSimplex.fill();
ctxSimplex.shadowBlur = 0;
}
}
// --- Mouse Probe ---
canvasSpatial.addEventListener('mousemove', (e) => {
const rect = canvasSpatial.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const cx = rect.width / 2;
const cy = rect.height / 2;
const distPx = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
const maxR = Math.min(cx, cy) - 20;
const r_um = Math.min(500, (distPx / maxR) * 500);
const bin = Math.min(STATE.Nr - 1, Math.floor((r_um / 500) * STATE.Nr));
const conc = STATE.C[bin].toFixed(1);
const s_pct = Math.round(STATE.S[bin] * 100);
const p_pct = Math.round(STATE.P[bin] * 100);
const r_pct = Math.round(STATE.R[bin] * 100);
let status = 'Therapeutic Kill Zone';
let statusColor = 'var(--emerald)';
if (STATE.C[bin] < STATE.C_sub) {
status = 'Untreated Core Niche';
statusColor = 'var(--cyan)';
} else if (STATE.C[bin] <= STATE.C_kill) {
status = 'Pharmacological Sanctuary (High Risk)';
statusColor = 'var(--amber)';
}
document.getElementById('hudProbeDetails').innerHTML = `
Radius: <strong>${r_um.toFixed(0)} μm</strong> from capillary core<br>
Osimertinib: <strong>${conc} nM</strong> (<span style="color:${statusColor}">${status}</span>)<br>
Phenotypes: <span style="color:var(--cyan)">S:${s_pct}%</span> |
<span style="color:var(--amber)">P:${p_pct}%</span> |
<span style="color:var(--rose)">R:${r_pct}%</span>
`;
});
// --- Animation Loop ---
let lastRenderTime = 0;
function mainLoop(timestamp) {
if (STATE.running) {
const steps = STATE.speed;
for (let s = 0; s < steps; s++) {
stepSimulation();
}
}
if (timestamp - lastRenderTime > 20) {
renderSpatialCanvas();
renderGradientChart();
renderTrialChart();
renderSimplexChart();
lastRenderTime = timestamp;
}
requestAnimationFrame(mainLoop);
}
// --- Protocol & Presets ---
function setProtocol(proto) {
STATE.protocol = proto;
document.querySelectorAll('.protocol-card').forEach(c => {
c.classList.remove('active', 'active-adaura');
});
if (proto === 'ADAURA') {
document.getElementById('protoADAURA').classList.add('active-adaura');
} else if (proto === 'SG_ADAURA') {
document.getElementById('protoSG').classList.add('active');
} else {
document.getElementById('protoMetronomic').classList.add('active');
}
}
function applyPreset(type) {
if (type === 'standard_nsclc') {
document.getElementById('paramDiff').value = 85;
document.getElementById('paramIFP').value = 18;
document.getElementById('paramPlasticity').value = 0.035;
document.getElementById('paramCostR').value = 15;
} else if (type === 'brain_met') {
document.getElementById('paramDiff').value = 35;
document.getElementById('paramIFP').value = 30;
document.getElementById('paramPlasticity').value = 0.055;
document.getElementById('paramCostR').value = 10;
} else if (type === 'high_plastic') {
document.getElementById('paramDiff').value = 90;
document.getElementById('paramIFP').value = 15;
document.getElementById('paramPlasticity').value = 0.080;
document.getElementById('paramCostR').value = 18;
} else if (type === 'anti_vegf') {
document.getElementById('paramDiff').value = 150;
document.getElementById('paramIFP').value = 5;
document.getElementById('paramPlasticity').value = 0.020;
document.getElementById('paramCostR').value = 20;
}
syncInputs();
initSimulation();
}
function syncInputs() {
STATE.D_eff = parseFloat(document.getElementById('paramDiff').value);
STATE.Pv = parseFloat(document.getElementById('paramPerm').value);
STATE.IFP = parseFloat(document.getElementById('paramIFP').value);
STATE.mu_SP = parseFloat(document.getElementById('paramPlasticity').value);
STATE.c_R = parseFloat(document.getElementById('paramCostR').value) / 100;
STATE.cessationDay = parseFloat(document.getElementById('paramDuration').value) * 30.43;
document.getElementById('valDiff').innerText = `${STATE.D_eff} μm²/s`;
document.getElementById('valPerm').innerText = `${STATE.Pv.toFixed(2)}`;
document.getElementById('valIFP').innerText = `${STATE.IFP} mmHg`;
document.getElementById('valPlasticity').innerText = `${STATE.mu_SP.toFixed(3)} /day`;
document.getElementById('valCostR').innerText = `${(STATE.c_R * 100).toFixed(0)}% penalty`;
document.getElementById('valDuration').innerText = `${document.getElementById('paramDuration').value} Months`;
}
['paramDiff', 'paramPerm', 'paramIFP', 'paramPlasticity', 'paramCostR', 'paramDuration'].forEach(id => {
document.getElementById(id).addEventListener('input', () => {
syncInputs();
});
});
document.getElementById('btnPlayPause').addEventListener('click', () => {
STATE.running = !STATE.running;
document.getElementById('playText').innerText = STATE.running ? 'Pause Simulation' : 'Resume Simulation';
document.getElementById('playIcon').innerText = STATE.running ? '⏸' : '▶';
});
document.getElementById('btnFastFwd').addEventListener('click', () => {
STATE.speed = (STATE.speed === 1) ? 2 : (STATE.speed === 2 ? 5 : 1);
document.getElementById('btnFastFwd').innerText = `⏩ ${STATE.speed}x Speed`;
});
document.getElementById('btnReset').addEventListener('click', () => {
initSimulation();
});
document.getElementById('toggleOverlay').addEventListener('click', () => {
STATE.overlayMode = (STATE.overlayMode === 'HEATMAP') ? 'CELLS' : 'HEATMAP';
document.getElementById('toggleOverlay').innerText = `View: ${STATE.overlayMode}`;
});
document.getElementById('btnExportCSV').addEventListener('click', () => {
let csv = 'Day,Year,TumorVolume_pct,CRI_Index,Sanctuary_pct,Sensitive_pct,Plastic_pct,Resistant_pct\n';
for (let i = 0; i < STATE.history.days.length; i++) {
const d = STATE.history.days[i];
const yr = (d / 365.25).toFixed(2);
csv += `${d},${yr},${STATE.history.vol_current[i].toFixed(2)},${STATE.history.cri[i].toFixed(4)},${STATE.history.sanctuary_pct[i].toFixed(2)},${STATE.history.ratio_S[i].toFixed(1)},${STATE.history.ratio_P[i].toFixed(1)},${STATE.history.ratio_R[i].toFixed(1)}\n`;
}
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `SPATIAL_CONTINUUM_SIMULATION_${STATE.protocol}.csv`;
a.click();
});
document.getElementById('btnSnapshot').addEventListener('click', () => {
const a = document.createElement('a');
a.href = canvasSpatial.toDataURL('image/png');
a.download = `SPATIAL_CONTINUUM_SANCTUARY_DAY_${Math.floor(STATE.day)}.png`;
a.click();
});
window.addEventListener('resize', () => {
resizeAllCanvases();
});
// Bootstrapping
syncInputs();
initSimulation();
requestAnimationFrame(mainLoop);
</script>
</body>
</html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SPATIAL-CONTINUUM | In Silico Tumor Sanctuary & SG-ADAURA Digital Twin</title>
<style>
:root {
--bg-base: #070a12;
--bg-card: #0f1523;
--bg-card-hover: #161e31;
--bg-input: #0a0f1a;
--border: #1e293b;
--border-accent: #334155;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-dim: #64748b;
--cyan: #38bdf8;
--cyan-glow: rgba(56, 189, 248, 0.25);
--amber: #fbbf24;
--amber-glow: rgba(251, 191, 36, 0.25);
--rose: #f43f5e;
--rose-glow: rgba(244, 63, 94, 0.25);
--emerald: #10b981;
--emerald-glow: rgba(16, 185, 129, 0.25);
--purple: #a855f7;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
}
body {
background-color: var(--bg-base);
color: var(--text-main);
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
}
/* Header */
header {
background: linear-gradient(180deg, #111827 0%, #0b1120 100%);
border-bottom: 1px solid var(--border);
padding: 10px 20px;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
z-index: 50;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.badge-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background: linear-gradient(135deg, #0284c7 0%, #0f172a 100%);
border: 1px solid var(--cyan);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 15px var(--cyan-glow);
}
.badge-icon svg {
width: 18px;
height: 18px;
stroke: #38bdf8;
fill: none;
}
.title-group h1 {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.4px;
display: flex;
align-items: center;
gap: 8px;
}
.tag-nature {
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 4px;
background: rgba(56, 189, 248, 0.15);