Project

General

Profile

Feature #859 » perio-chart-v3.html

Redmine Admin, 03/11/2026 01:44 PM

 
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>DentPal — Perio Chart v3 (OD Convention)</title>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=DM+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #eef0f4;
--surface: #ffffff;
--surface2: #f5f6f9;
--border: #dde1ea;
--border-dark: #b0b8c8;
--text: #1c2033;
--muted: #6b7491;
--hdr-bg: #1c2033;
--hdr-text: #e8ecf6;
--upper-col: #1d4ed8;
--lower-col: #6d28d9;
--red: #dc2626;
--amber: #d97706;
--bleed-dot: #ef4444;
--plaq-dot: #2563eb;
--calc-dot: #16a34a;
--supp-dot: #f59e0b;
--graph-bg: #f9fafb;
--cell-w: 28px;
--label-w: 110px;
--marker-w: 22px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'DM Sans', sans-serif; background: var(--bg); color: var(--text); padding: 16px; font-size: 12px; }

/* ── Shell ── */
.shell { background: var(--surface); border-radius: 10px; overflow: hidden; box-shadow: 0 2px 16px rgba(0,0,0,.08); }

.topbar {
background: var(--hdr-bg); color: var(--hdr-text);
padding: 10px 16px; display: flex; align-items: center; gap: 12px;
}
.topbar h1 { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; }
.topbar .tag { font-family: 'JetBrains Mono', monospace; font-size: 10px; background: var(--upper-col); color: #fff; padding: 2px 8px; border-radius: 3px; letter-spacing: .5px; }

.infobar { background: var(--surface2); border-bottom: 1px solid var(--border); padding: 8px 16px; display: flex; gap: 20px; flex-wrap: wrap; align-items: center; }
.info-field { display: flex; flex-direction: column; gap: 1px; }
.info-field label { font-size: 9px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .7px; }
.info-field span { font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 500; }

.exambar { background: var(--surface); border-bottom: 1px solid var(--border); padding: 8px 16px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.exambar label { font-size: 10px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .7px; }
.exambar select { font-family: 'JetBrains Mono', monospace; font-size: 11px; border: 1px solid var(--border-dark); border-radius: 4px; padding: 3px 8px; background: var(--surface); }
.sc-badge { font-family: 'JetBrains Mono', monospace; font-size: 10px; padding: 2px 8px; border-radius: 3px; font-weight: 700; }
.sc-n { background:#dcfce7;color:#166534; } .sc-m { background:#fef9c3;color:#854d0e; }
.sc-s { background:#fee2e2;color:#991b1b; } .sc-e { background:#ede9fe;color:#5b21b6; }
.sc-k { background:#f3f4f6;color:#374151; }

/* ── Body: chart + stats side-by-side ── */
.body-wrap { display: flex; gap: 0; }

/* ── Stats panel (right, OD-style) ── */
.stats-panel {
width: 180px; min-width: 180px;
border-left: 1px solid var(--border);
padding: 12px 10px; background: var(--surface2);
display: flex; flex-direction: column; gap: 10px;
}
.sp-title { font-size: 9px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .8px; margin-bottom: 2px; }
.sp-row { display: flex; align-items: center; justify-content: space-between; gap: 6px; padding: 3px 0; border-bottom: 1px solid var(--border); }
.sp-row:last-child { border-bottom: none; }
.sp-label { font-size: 10px; color: var(--text); }
.sp-val { font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700; }
.sp-val.red { color: var(--red); } .sp-val.ok { color: #16a34a; } .sp-val.warn { color: var(--amber); }
.sp-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; }
.numbers-red-table { width: 100%; border-collapse: collapse; font-size: 10px; font-family: 'JetBrains Mono', monospace; }
.numbers-red-table td { padding: 2px 4px; }
.numbers-red-table .nr-label { color: var(--muted); }
.numbers-red-table .nr-thresh { color: var(--red); font-weight: 600; }
.numbers-red-table .nr-count { color: var(--text); font-weight: 700; text-align: right; }

/* ── Chart scroll area ── */
.chart-scroll { flex: 1; overflow-x: auto; padding: 12px 0 12px 12px; }

/* ── Jaw block ── */
.jaw-block { margin-bottom: 8px; }
.jaw-label { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; padding: 0 4px; }
.jaw-label-text { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase; padding: 2px 10px; border-radius: 3px; color: #fff; }
.jaw-label-text.upper { background: var(--upper-col); }
.jaw-label-text.lower { background: var(--lower-col); }
.jaw-divider { flex: 1; height: 1px; background: var(--border); }

/* ── Grid ── */
.grid { display: table; border-collapse: collapse; font-family: 'JetBrains Mono', monospace; font-size: 10.5px; }
.grow { display: table-row; }

/* Section marker (F / L) */
.fl-marker {
display: table-cell; width: var(--marker-w); min-width: var(--marker-w);
text-align: center; vertical-align: middle;
font-size: 11px; font-weight: 700;
padding: 0 2px;
}
.fl-marker.f { color: var(--upper-col); }
.fl-marker.l { color: var(--lower-col); }
.fl-marker.blank { color: transparent; }

/* Row label */
.rlabel {
display: table-cell; vertical-align: middle; white-space: nowrap;
width: var(--label-w); min-width: var(--label-w);
padding: 1px 6px 1px 0;
font-size: 9.5px; color: var(--muted); font-weight: 500;
border-right: 2px solid var(--border-dark);
font-family: 'DM Sans', sans-serif;
}
.rlabel.auto { font-style: italic; color: #7c3aed; }
.rlabel.section { font-size: 10px; font-weight: 700; color: var(--text); text-transform: uppercase; letter-spacing: .8px; }
.rlabel.upper-lbl { color: var(--upper-col); }
.rlabel.lower-lbl { color: var(--lower-col); }

/* Data cell — triple values */
.dcell {
display: table-cell; vertical-align: middle;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 0;
}
.dcell.skipped { background: #f3f4f6; }

.triple { display: flex; width: calc(var(--cell-w)*3); min-width: calc(var(--cell-w)*3); }
.v {
flex: 1; text-align: center; line-height: 18px; min-height: 18px;
font-size: 10.5px; font-family: 'JetBrains Mono', monospace; font-weight: 500;
cursor: default;
}
.v.red { color: var(--red); font-weight: 700; }
.v.black { color: var(--text); }
.v.amber { color: var(--amber); font-weight: 600; }
.v.gray { color: #c0c4d0; }
.v.skip { color: #d1d5db; }

/* Single cell (mobility, furcation) */
.scell {
display: table-cell; vertical-align: middle; text-align: center;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
width: calc(var(--cell-w)*3); min-width: calc(var(--cell-w)*3);
font-family: 'JetBrains Mono', monospace; font-size: 10.5px;
line-height: 18px; min-height: 18px;
cursor: default;
}
.scell.skipped { background: #f3f4f6; }

/* Tooth number row */
.tnum-cell {
display: table-cell; text-align: center; vertical-align: middle;
width: calc(var(--cell-w)*3); min-width: calc(var(--cell-w)*3);
border-left: 1px solid var(--border);
background: var(--surface2);
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
padding: 4px 0; color: var(--text);
}
.tnum-cell.skipped { color: #9ca3af; text-decoration: line-through; background: #f3f4f6; }

/* Bleeding dots row */
.dots-cell {
display: table-cell; vertical-align: middle;
border-left: 1px solid var(--border);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
padding: 1px 0;
}
.dots-triple { display: flex; width: calc(var(--cell-w)*3); min-width: calc(var(--cell-w)*3); align-items: center; justify-content: space-around; padding: 0 2px; }
.dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; }
.dot.on-bleed { background: var(--bleed-dot); }
.dot.on-plaq { background: var(--plaq-dot); }
.dot.on-calc { background: var(--calc-dot); }
.dot.on-supp { background: var(--supp-dot); }
.dot.off { background: #e5e7eb; }
.dot.skip { background: #f3f4f6; }

/* ── GRAPH SECTION ── */
.graph-row .dcell, .graph-row .scell { border: none; padding: 0; background: transparent; }
.graph-outer {
width: calc(var(--cell-w)*3); min-width: calc(var(--cell-w)*3);
height: 72px;
border-left: 1px solid var(--border);
background: repeating-linear-gradient(
to bottom, transparent 0px, transparent 11px,
rgba(180,186,200,.2) 11px, rgba(180,186,200,.2) 12px
);
display: flex; align-items: flex-end; gap: 1px; padding: 0 2px;
border-bottom: 2px solid var(--border-dark);
position: relative;
}
/* Upper jaw: bars hang from TOP */
.graph-outer.inverted {
align-items: flex-start;
border-bottom: none;
border-top: 2px solid var(--border-dark);
background: repeating-linear-gradient(
to top, transparent 0px, transparent 11px,
rgba(180,186,200,.2) 11px, rgba(180,186,200,.2) 12px
);
}
.graph-outer.skipped { background: #f3f4f6; }
.bar { flex: 1; border-radius: 1px; min-height: 2px; }
.bar.red { background: var(--red); opacity: .82; }
.bar.black { background: #374151; opacity: .68; }
.bar.skip { background: #e5e7eb; }
/* Upper: bar rounds at BOTTOM (hangs down) */
.graph-outer.inverted .bar { border-radius: 0 0 2px 2px; }
/* Lower: bar rounds at TOP (grows up) */
.graph-outer:not(.inverted) .bar { border-radius: 2px 2px 0 0; }

/* Spacer */
.spacer-row { height: 6px; display: table-row; }

/* Jaw separator */
.jaw-sep { height: 12px; display: table-row; background: var(--bg); }

/* Furcation / mobility coloring */
.furc-0 { color: #d1d5db; }
.furc-1,.furc-2,.furc-3,.furc-4 { color: var(--red); font-weight: 700; }
.mob-0 { color: #d1d5db; }
.mob-1,.mob-2,.mob-3 { color: var(--red); font-weight: 700; }

/* Tooltip */
[data-tip] { position: relative; }
[data-tip]:hover::after {
content: attr(data-tip); position: absolute; bottom: 120%; left: 50%;
transform: translateX(-50%); background: #1c2033; color: #fff;
font-size: 9px; padding: 2px 6px; border-radius: 3px; white-space: nowrap;
z-index: 200; pointer-events: none; font-family: 'JetBrains Mono', monospace;
}

/* Legend strip */
.legend { padding: 6px 16px; border-top: 1px solid var(--border); background: var(--surface2); display: flex; gap: 14px; flex-wrap: wrap; font-size: 10px; color: var(--muted); align-items: center; }
.li { display: flex; align-items: center; gap: 4px; }
.li .dot { width: 8px; height: 8px; }
</style>
</head>
<body>
<div class="shell">

<div class="topbar">
<h1>Periodontal Chart</h1>
<span class="tag">DENTPAL</span>
<span style="font-size:10px;opacity:.5;font-family:'JetBrains Mono',monospace;margin-left:auto">v3 — OD Convention</span>
</div>

<div class="infobar">
<div class="info-field"><label>Patient</label><span>John A. Doe</span></div>
<div class="info-field"><label>DOB</label><span>14 Mar 1978</span></div>
<div class="info-field"><label>ID</label><span>PT-00412</span></div>
<div class="info-field"><label>Provider</label><span>Dr. R. Sharma</span></div>
<div class="info-field"><label>Recorded by</label><span>K. Patel (Hygienist)</span></div>
</div>

<div class="exambar">
<label>Exam</label>
<select id="examSel" onchange="loadExam(this.value)">
<option value="sc1">11 Mar 2026 — Scenario 1: Healthy Baseline</option>
<option value="sc2">05 Sep 2025 — Scenario 2: Moderate Periodontitis</option>
<option value="sc3">18 Mar 2025 — Scenario 3: Severe (All Flags)</option>
<option value="sc4">02 Jan 2025 — Scenario 4: Edge Cases (AG≤0, GM&lt;0, Boundaries)</option>
<option value="sc5">15 Jun 2024 — Scenario 5: Missing Teeth #1 #16 #17 #32</option>
</select>
<span id="scBadge" class="sc-badge"></span>
<div style="margin-left:auto;font-size:9px;color:var(--muted);font-family:'JetBrains Mono',monospace">
Upper bars ▼ down &nbsp;·&nbsp; Lower bars ▲ up &nbsp;·&nbsp; OD convention
</div>
</div>

<div class="body-wrap">
<!-- Chart area -->
<div class="chart-scroll" id="chartArea"></div>
<!-- Stats panel -->
<div class="stats-panel" id="statsPanel"></div>
</div>

<div class="legend">
<span style="font-size:9px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.8px">Legend:</span>
<span class="li"><span class="dot" style="background:var(--bleed-dot);border-radius:50%"></span>Bleeding</span>
<span class="li"><span class="dot" style="background:var(--plaq-dot);border-radius:50%"></span>Plaque</span>
<span class="li"><span class="dot" style="background:var(--calc-dot);border-radius:50%"></span>Calculus</span>
<span class="li"><span class="dot" style="background:var(--supp-dot);border-radius:50%"></span>Suppuration</span>
<span class="li" style="margin-left:8px"><span style="color:var(--red);font-weight:700;font-family:'JetBrains Mono',monospace">3</span> = red threshold met</span>
<span class="li"><span style="color:var(--muted);font-style:italic;font-size:9px">auto</span> = calculated row</span>
<span class="li"><span style="background:#f3f4f6;padding:0 4px;border-radius:2px"></span> = skipped/missing tooth</span>
</div>

</div>

<script>
// ═══════════════════════════════════════════════════
// ANSWERS FROM OD IMAGE — applied to v3
// Q1: Upper bars DOWN (inverted), Lower bars UP ✓
// Q2: Furcation red if >= 1 ✓
// Q3: Mobility red if >= 1 ✓
// Q4: BleedSupPlaqCalc = 4 separate boolean dot indicators ✓
// Q5: Missing surface = dash (–) ✓
// Q6: AG <= 0 = display as-is (amber for negative, no block)
// Q7: Universal 1-32 ✓
// Q8: Exam note shown below patient bar area ✓
// Q9: Recession = separate row from GingMargin ✓
// Q10: No mm scale on graph (OD screenshot shows none) ✓
// ═══════════════════════════════════════════════════

// ── CALCULATIONS ───────────────────────────────────
const calcAG = (mgj, pd) => (mgj == null || pd == null) ? null : mgj - pd;
const calcCAL = (pd, rec, gm) => {
if (pd == null) return null;
// Recession row > 0 = apical: CAL = PD + recession
if (rec != null && rec > 0) return pd + rec;
// GingMargin row < 0 = overgrowth: CAL = PD - |gm|
if (gm != null && gm < 0) return Math.max(0, pd - Math.abs(gm));
// GM = 0 or null: CAL = PD
return pd;
};

// ── COLOUR RULES (from OD screenshot right panel) ──
const pdC = v => v == null ? 'gray' : v >= 4 ? 'red' : 'black';
const mgjC = v => v == null ? 'gray' : v <= 2 ? 'red' : 'black';
const agC = v => v == null ? 'gray' : v < 0 ? 'amber' : v <= 3 ? 'red' : 'black';
const calC = v => v == null ? 'gray' : v >= 6 ? 'red' : 'black';
const recC = v => v == null ? 'gray' : v >= 2 ? 'red' : 'black';
const furcC = v => v == null ? 'furc-0' : v >= 1 ? 'furc-1' : 'furc-0'; // red if >= 1
const mobC = v => v == null ? 'mob-0' : v >= 1 ? 'mob-1' : 'mob-0'; // red if >= 1

const FURC_SYM = ['','','▲▲','▲▲▲',''];
const MOB_SYM = ['','1','2','3'];
const SURF_F = ['MB','B','DB'];
const SURF_L = ['ML','L','DL'];

// ── DATA SCENARIOS ──────────────────────────────────
// Per tooth: facial{pd[3], gm[3], mgj[3], rec[3]}, lingual{...}, mobility, furcation
// bleeding/plaque/calculus/suppuration: [6] booleans — indices 0-2 facial, 3-5 lingual
// null = missing surface data (dash shown)
function mk(fPD,fGM,fMGJ,fRec, lPD,lGM,lMGJ,lRec, mob,furc, bl,pl,ca,su,skip=false) {
return { facial:{pd:fPD,gm:fGM,mgj:fMGJ,rec:fRec}, lingual:{pd:lPD,gm:lGM,mgj:lMGJ,rec:lRec},
mobility:mob, furcation:furc, bleeding:bl, plaque:pl, calculus:ca, suppuration:su, skip };
}
function healthy() {
return mk([2,2,2],[0,0,0],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],0,0,
[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]);
}
function skipped() {
return mk([0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],[0,0,0],0,0,
[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],true);
}
function buildTeeth(overrides) {
const T = {};
for (let i=1;i<=32;i++) T[i] = overrides[i] || healthy();
return T;
}

// SC1 — Healthy
const SC1 = buildTeeth({
3:mk([2,3,2],[0,0,0],[5,5,6],[0,0,0],[2,2,3],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
8:mk([1,2,1],[0,0,0],[6,5,5],[0,0,0],[1,1,2],[0,0,0],[5,6,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
});

// SC2 — Moderate
const SC2 = buildTeeth({
2:mk([4,5,4],[0,0,0],[5,5,5],[1,1,1],[3,4,3],[0,0,0],[4,5,4],[1,0,1],1,1,[1,1,0,0,1,0],[1,0,1,1,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
3:mk([5,4,5],[0,0,0],[5,5,4],[2,1,2],[4,5,4],[0,0,0],[4,4,5],[1,2,1],0,1,[1,1,1,1,0,1],[1,1,0,1,1,0],[1,0,0,0,1,0],[0,0,0,0,0,0]),
4:mk([3,4,3],[0,0,0],[4,5,4],[1,0,1],[3,3,4],[0,0,0],[5,5,5],[0,1,0],1,0,[0,1,0,0,0,1],[0,1,0,0,1,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
14:mk([4,5,4],[0,0,0],[4,4,5],[2,2,1],[4,4,5],[0,0,0],[4,5,4],[1,2,1],0,2,[1,1,1,1,1,0],[1,1,1,0,1,1],[1,0,1,0,0,1],[0,0,0,0,0,0]),
19:mk([5,5,4],[0,0,0],[5,5,4],[2,2,2],[4,5,4],[0,0,0],[4,4,5],[2,1,2],1,1,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,0,1,1,0,0],[0,1,0,0,0,0]),
30:mk([4,4,5],[0,0,0],[5,4,5],[1,2,1],[3,4,4],[0,0,0],[5,5,4],[0,2,1],0,2,[1,0,1,0,1,1],[0,1,0,1,0,1],[1,0,0,0,0,0],[0,0,0,0,0,0]),
});

// SC3 — Severe
const SC3 = buildTeeth({
2:mk([7,8,6],[0,0,0],[4,4,5],[3,4,3],[6,7,6],[0,0,0],[4,4,4],[3,3,4],2,3,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1]),
3:mk([9,8,7],[0,0,0],[4,3,4],[4,5,4],[8,7,8],[0,0,0],[4,4,3],[4,4,3],3,4,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1]),
14:mk([6,7,6],[0,0,0],[4,4,4],[3,3,3],[6,6,7],[0,0,0],[4,3,4],[3,4,3],2,3,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1],[1,0,1,1,0,1]),
18:mk([6,8,7],[0,0,0],[4,4,3],[4,3,4],[7,6,8],[0,0,0],[4,3,4],[4,4,3],2,3,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,0,1,1,1]),
31:mk([9,7,8],[0,0,0],[3,3,4],[5,4,5],[8,7,9],[0,0,0],[3,3,3],[5,4,5],3,4,[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1],[1,1,1,1,1,1]),
});

// SC4 — Edge cases
const SC4 = buildTeeth({
// AG=0: MGJ=4, PD=4
5:mk([4,4,4],[0,0,0],[4,4,4],[0,0,0],[3,3,3],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// AG<0: MGJ=3, PD=5
6:mk([5,5,5],[0,0,0],[3,3,3],[0,0,0],[3,3,3],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// GM<0 overgrowth: recession=0, gm=-2 CAL = PD-|gm|
7:mk([3,3,3],[-2,-2,-2],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// GM=0 at CEJ
8:mk([3,2,3],[0,0,0],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// CAL=0: PD=2, gm=-2
9:mk([2,2,2],[-2,-2,-2],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// PD=3 boundary → black
10:mk([3,3,3],[0,0,0],[5,5,5],[0,0,0],[3,3,3],[0,0,0],[5,5,5],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// PD=4 boundary → red
11:mk([4,4,4],[0,0,0],[6,6,6],[0,0,0],[4,4,4],[0,0,0],[6,6,6],[0,0,0],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// Partial surface data — tooth 12 only has middle surface
12:mk([null,3,null],[null,0,null],[null,5,null],[null,0,null],[null,2,null],[null,0,null],[null,5,null],[null,0,null],0,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// Furc=1 → red (>= 1 rule confirmed from OD)
13:mk([2,2,2],[0,0,0],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],0,1,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
// Mob=1 → red (>= 1 rule confirmed from OD)
14:mk([2,2,2],[0,0,0],[5,5,5],[0,0,0],[2,2,2],[0,0,0],[5,5,5],[0,0,0],1,0,[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
});

// SC5 — Missing teeth
const SC5 = buildTeeth({
1:skipped(), 16:skipped(), 17:skipped(), 32:skipped(),
5:mk([4,5,4],[0,0,0],[5,5,4],[2,1,2],[3,4,3],[0,0,0],[4,5,5],[1,1,0],1,1,[1,1,0,0,1,0],[1,0,1,1,0,0],[0,0,0,0,0,0],[0,0,0,0,0,0]),
12:mk([5,5,4],[0,0,0],[4,4,5],[2,2,1],[4,4,5],[0,0,0],[4,5,4],[1,2,1],0,2,[1,1,1,1,1,0],[1,1,1,0,1,1],[1,0,1,0,0,1],[0,0,0,0,0,0]),
});

const EXAMS = {sc1:SC1,sc2:SC2,sc3:SC3,sc4:SC4,sc5:SC5};
const META = {
sc1:{label:'Healthy',cls:'sc-n'},
sc2:{label:'Moderate',cls:'sc-m'},
sc3:{label:'Severe',cls:'sc-s'},
sc4:{label:'Edge Cases',cls:'sc-e'},
sc5:{label:'Missing Teeth',cls:'sc-k'},
};

// Exam notes (Q8 — shown per OD convention)
const NOTES = {
sc1: 'Full mouth probing. Patient is healthy with good home care. No significant findings.',
sc2: 'Moderate generalized periodontitis. Plaque and calculus moderate. Bleeding on probing moderate.',
sc3: 'Severe generalized periodontitis. Immediate referral to periodontist recommended. Full probing complete.',
sc4: 'Edge case data — boundary values and partial surface recordings for QA testing purposes.',
sc5: 'Teeth #1, #16, #17, #32 extracted. Remaining dentition charted in full.',
};

// ═══════════════════════════════════════════════════
// RENDER
// ═══════════════════════════════════════════════════
const UPPER = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16];
const LOWER = [32,31,30,29,28,27,26,25,24,23,22,21,20,19,18,17];

function vSpan(v, cls, tip) {
if (v === null) return `<span class="v gray" data-tip="–">–</span>`;
return `<span class="v ${cls}" data-tip="${tip}">${v}</span>`;
}

function tripleVals(arr, colorFn, surfs) {
return arr.map((v,i) => vSpan(v, colorFn(v), `${surfs[i]}: ${v??''}`)).join('');
}

function dotRow(boolArr, skip, type) {
const colors = {bleed:'on-bleed',plaque:'on-plaq',calculus:'on-calc',suppuration:'on-supp'};
const cls = colors[type] || 'on-bleed';
if (skip) return [0,1,2].map(()=>`<span class="dot skip"></span>`).join('');
return boolArr.map(v=>`<span class="dot ${v?cls:'off'}"></span>`).join('');
}

function graphBars(pdArr, skip, inverted) {
const maxH = 72, scale = 10;
if (skip) return pdArr.map(()=>`<div class="bar skip" style="height:4px"></div>`).join('');
return pdArr.map((v,i) => {
if (v === null) return `<div class="bar skip" style="height:2px"></div>`;
const h = Math.min(maxH, Math.max(3, v * scale));
const cls = v >= 4 ? 'red' : 'black';
return `<div class="bar ${cls}" style="height:${h}px" data-tip="PD ${v}mm"></div>`;
}).join('');
}

function furcCell(v, skip) {
if (skip) return `<div class="scell skipped">✕</div>`;
const sym = FURC_SYM[v??0];
const cls = furcC(v??0);
return `<div class="scell"><span class="${cls}" data-tip="${['None','Incipient','Moderate','Advanced','Severe'][v??0]}">${sym}</span></div>`;
}
function mobCell(v, skip) {
if (skip) return `<div class="scell skipped">✕</div>`;
const sym = MOB_SYM[v??0];
const cls = mobC(v??0);
return `<div class="scell"><span class="${cls}" data-tip="${['None','Slight','Moderate','Severe'][v??0]}">${sym}</span></div>`;
}

function buildJaw(teeth, T, isUpper) {
// Derive calculated values per tooth
function der(td, side) {
const d = td[side];
const ag = d.pd.map((pd,i) => pd===null ? null : calcAG(d.mgj[i], pd));
const cal = d.pd.map((pd,i) => pd===null ? null : calcCAL(pd, d.rec[i], d.gm[i]));
return {...d, ag, cal};
}

function bleedDotsRow(teeth, T, sliceStart) {
// Combined all 4 types in one row as colored dots (OD style)
return `<div class="grow">
<div class="fl-marker blank"></div>
<div class="rlabel">Bleed/Plaq/Calc/Supp</div>
${teeth.map(t => {
const td = T[t];
const sk = td.skip;
const bl = td.bleeding.slice(sliceStart, sliceStart+3);
const pl = td.plaque.slice(sliceStart, sliceStart+3);
const ca = td.calculus.slice(sliceStart, sliceStart+3);
const su = td.suppuration.slice(sliceStart, sliceStart+3);
return `<div class="dots-cell${sk?' style=background:#f3f4f6':''}" ${sk?'':''}><div class="dots-triple">
${[0,1,2].map(i => `
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;padding:1px 0">
${sk ? '<span class="dot skip"></span><span class="dot skip"></span><span class="dot skip"></span><span class="dot skip"></span>'
: `<span class="dot ${bl[i]?'on-bleed':'off'}" data-tip="${bl[i]?'Bleeding':'No bleeding'}"></span>
<span class="dot ${pl[i]?'on-plaq':'off'}" data-tip="${pl[i]?'Plaque':'No plaque'}"></span>
<span class="dot ${ca[i]?'on-calc':'off'}" data-tip="${ca[i]?'Calculus':'No calculus'}"></span>
<span class="dot ${su[i]?'on-supp':'off'}" data-tip="${su[i]?'Suppuration':'No suppuration'}"></span>`
}
</div>`).join('')}
</div></div>`;
}).join('')}
</div>`;
}

function dataRow(flCls, flLabel, rlCls, rlLabel, valFn) {
return `<div class="grow">
<div class="fl-marker ${flCls}">${flLabel}</div>
<div class="rlabel ${rlCls}">${rlLabel}</div>
${teeth.map(t => {
const td = T[t];
return `<div class="dcell${td.skip?' skipped':''}"><div class="triple">${td.skip?'<span class="v skip">✕</span><span class="v skip">✕</span><span class="v skip">✕</span>':valFn(t,td)}</div></div>`;
}).join('')}
</div>`;
}

function singleRow(flCls, flLabel, rlCls, rlLabel, valFn) {
return `<div class="grow">
<div class="fl-marker ${flCls}">${flLabel}</div>
<div class="rlabel ${rlCls}">${rlLabel}</div>
${teeth.map(t => valFn(t, T[t])).join('')}
</div>`;
}

function toothNumRow() {
return `<div class="grow">
<div class="fl-marker blank"></div>
<div class="rlabel section ${isUpper?'upper-lbl':'lower-lbl'}"></div>
${teeth.map(t => `<div class="tnum-cell${T[t].skip?' skipped':''}">${t}</div>`).join('')}
</div>`;
}

function graphRow(isInverted, side) {
const surfs = side==='facial' ? SURF_F : SURF_L;
return `<div class="grow graph-row">
<div class="fl-marker blank"></div>
<div class="rlabel" style="font-style:italic;color:var(--muted);font-size:9px">PD graph</div>
${teeth.map(t => {
const td = T[t]; const sk = td.skip;
const pd = td[side].pd;
return `<div class="dcell"><div class="graph-outer${isInverted?' inverted':''}${sk?' skipped':''}">${graphBars(pd,sk,isInverted)}</div></div>`;
}).join('')}
</div>`;
}

const acc = isUpper ? 'f' : 'l';
const alt = isUpper ? 'l' : 'f';

if (isUpper) {
// OD UPPER layout:
// [F] PD facial (with exam date)
// [ ] Bleed/Plaq/Calc/Supp dots (facial)
// [ ] MGJ
// [ ] auto Att Ging
// [ ] Recession
// [ ] auto CAL
// [ ] Furc
// [ ] Mobility
// [tooth numbers 1-16]
// [graph — bars DOWN ▼]
// [L] Furc (lingual)
// [ ] auto Att Ging (lingual)
// [ ] Recession (lingual)
// [L] PD lingual (with exam date)
// [ ] Bleed/Plaq/Calc/Supp dots (lingual)
return `
${dataRow('f','F','','Probing Depth', (t,td) => tripleVals(der(td,'facial').pd, pdC, SURF_F))}
${bleedDotsRow(teeth, T, 0)}
${dataRow('blank','','','MGJ', (t,td) => tripleVals(der(td,'facial').mgj, mgjC, SURF_F))}
${dataRow('blank','','auto','auto Att Ging', (t,td) => { const ag=der(td,'facial').ag; return ag.map((v,i)=>vSpan(v,agC(v),`${SURF_F[i]}: AG ${v??''}`)).join(''); })}
${dataRow('blank','','','Recession', (t,td) => tripleVals(der(td,'facial').rec, recC, SURF_F))}
${dataRow('blank','','auto','auto CAL', (t,td) => { const cal=der(td,'facial').cal; return cal.map((v,i)=>vSpan(v,calC(v),`${SURF_F[i]}: CAL ${v??''}`)).join(''); })}
${singleRow('blank','','','Furc', (t,td) => furcCell(td.furcation, td.skip))}
${singleRow('blank','','','Mobility', (t,td) => mobCell(td.mobility, td.skip))}
${toothNumRow()}
${graphRow(true, 'facial')}
${singleRow('blank','','','Furc (L)', (t,td) => furcCell(td.furcation, td.skip))}
${dataRow('blank','','auto','auto Att Ging (L)', (t,td) => { const ag=der(td,'lingual').ag; return ag.map((v,i)=>vSpan(v,agC(v),`${SURF_L[i]}: AG ${v??''}`)).join(''); })}
${dataRow('blank','','','Recession (L)', (t,td) => tripleVals(der(td,'lingual').rec, recC, SURF_L))}
${dataRow('l','L','','Probing Depth (L)', (t,td) => tripleVals(der(td,'lingual').pd, pdC, SURF_L))}
${bleedDotsRow(teeth, T, 3)}
`;
} else {
// OD LOWER layout:
// [L] PD lingual (exam date)
// [ ] Bleed/Plaq/Calc/Supp (lingual)
// [ ] MGJ
// [ ] auto Att Ging
// [ ] Recession
// [ ] auto CAL
// [ ] Furc
// [tooth numbers 32-17]
// [graph — bars UP ▲]
// [ ] Mobility
// [ ] auto CAL (facial)
// [ ] auto Att Ging (facial)
// [ ] Recession (facial)
// [ ] MGJ (facial)
// [F] PD facial (exam date)
// [ ] Bleed/Plaq/Calc/Supp (facial)
return `
${dataRow('l','L','','Probing Depth (L)', (t,td) => tripleVals(der(td,'lingual').pd, pdC, SURF_L))}
${bleedDotsRow(teeth, T, 3)}
${dataRow('blank','','','MGJ (L)', (t,td) => tripleVals(der(td,'lingual').mgj, mgjC, SURF_L))}
${dataRow('blank','','auto','auto Att Ging (L)', (t,td) => { const ag=der(td,'lingual').ag; return ag.map((v,i)=>vSpan(v,agC(v),`${SURF_L[i]}: AG ${v??''}`)).join(''); })}
${dataRow('blank','','','Recession (L)', (t,td) => tripleVals(der(td,'lingual').rec, recC, SURF_L))}
${dataRow('blank','','auto','auto CAL (L)', (t,td) => { const cal=der(td,'lingual').cal; return cal.map((v,i)=>vSpan(v,calC(v),`${SURF_L[i]}: CAL ${v??''}`)).join(''); })}
${singleRow('blank','','','Furc (L)', (t,td) => furcCell(td.furcation, td.skip))}
${toothNumRow()}
${graphRow(false, 'lingual')}
${singleRow('blank','','','Mobility', (t,td) => mobCell(td.mobility, td.skip))}
${dataRow('blank','','auto','auto CAL', (t,td) => { const cal=der(td,'facial').cal; return cal.map((v,i)=>vSpan(v,calC(v),`${SURF_F[i]}: CAL ${v??''}`)).join(''); })}
${dataRow('blank','','auto','auto Att Ging', (t,td) => { const ag=der(td,'facial').ag; return ag.map((v,i)=>vSpan(v,agC(v),`${SURF_F[i]}: AG ${v??''}`)).join(''); })}
${dataRow('blank','','','Recession', (t,td) => tripleVals(der(td,'facial').rec, recC, SURF_F))}
${dataRow('blank','','','MGJ', (t,td) => tripleVals(der(td,'facial').mgj, mgjC, SURF_F))}
${dataRow('f','F','','Probing Depth', (t,td) => tripleVals(der(td,'facial').pd, pdC, SURF_F))}
${bleedDotsRow(teeth, T, 0)}
`;
}
}

function buildStats(T) {
let pd4=0,pdN=0,mgj2=0,mgjN=0,rec2=0,recN=0,cal6=0,calN=0,ag3=0,agN=0,furc1=0,mob1=0,skip=0;
let bleed=0,plaq=0,calc_=0,supp=0,boolN=0;
let pdSum=0;

for (let t=1;t<=32;t++) {
const td=T[t]; if(td.skip){skip++;continue;}
['facial','lingual'].forEach(side=>{
const d=td[side];
d.pd.forEach((v,i)=>{
if(v===null)return;
pdN++;pdSum+=v;if(v>=4)pd4++;
const ag=calcAG(d.mgj[i],v); if(ag!==null){agN++;if(ag<=3)ag3++;}
const cal=calcCAL(v,d.rec[i],d.gm[i]); if(cal!==null){calN++;if(cal>=6)cal6++;}
});
d.mgj.forEach(v=>{if(v===null)return;mgjN++;if(v<=2)mgj2++;});
d.rec.forEach(v=>{if(v===null)return;recN++;if(v>=2)rec2++;});
});
td.bleeding.forEach(v=>{boolN++;if(v)bleed++;});
td.plaque.forEach(v=>{if(v)plaq++;});
td.calculus.forEach(v=>{if(v)calc_++;});
td.suppuration.forEach(v=>{if(v)supp++;});
if(td.furcation>=1)furc1++;
if(td.mobility>=1)mob1++;
}
const bop=boolN?Math.round(bleed/boolN*100):0;
const meanPD=pdN?(pdSum/pdN).toFixed(1):'';

return {pd4,pdN,mgj2,mgjN,rec2,recN,cal6,calN,ag3,agN,furc1,mob1,skip,bleed,plaq,calc_,supp,boolN,bop,meanPD};
}

function renderStats(s) {
const nr = (label, thresh, count) =>
`<tr><td class="nr-label">${label}</td><td class="nr-thresh">${thresh}</td><td class="nr-count">${count}</td></tr>`;

document.getElementById('statsPanel').innerHTML = `
<div>
<div class="sp-title">Index %</div>
<div class="sp-row"><span class="sp-label">Plaque</span><span class="sp-dot" style="background:var(--plaq-dot)"></span><span class="sp-val ${s.plaq>10?'red':'ok'}">${s.plaq}</span></div>
<div class="sp-row"><span class="sp-label">Calculus</span><span class="sp-dot" style="background:var(--calc-dot)"></span><span class="sp-val ${s.calc_>10?'red':'ok'}">${s.calc_}</span></div>
<div class="sp-row"><span class="sp-label">Bleeding</span><span class="sp-dot" style="background:var(--bleed-dot)"></span><span class="sp-val ${s.bop>20?'red':'ok'}">${s.bop}%</span></div>
<div class="sp-row"><span class="sp-label">Suppuration</span><span class="sp-dot" style="background:var(--supp-dot)"></span><span class="sp-val ${s.supp>0?'warn':'ok'}">${s.supp}</span></div>
</div>
<div>
<div class="sp-title">Mean PD</div>
<div class="sp-row"><span class="sp-label">Average</span><span class="sp-val ${parseFloat(s.meanPD)>=3?'warn':'ok'}">${s.meanPD}mm</span></div>
</div>
<div>
<div class="sp-title">Numbers in red</div>
<table class="numbers-red-table">
<tr style="background:var(--surface2)"><td class="nr-label" style="font-weight:700">Metric</td><td class="nr-thresh" style="font-weight:700">Red if</td><td class="nr-count" style="font-weight:700"># Teeth</td></tr>
${nr('Probing','≥ 4',s.pd4)}
${nr('MGJ','≤ 2',s.mgj2)}
${nr('Recession','≥ 2',s.rec2)}
${nr('CAL','≥ 6',s.cal6)}
${nr('Att Ging','≤ 3',s.ag3)}
${nr('Furc','≥ 1',s.furc1)}
${nr('Mobility','≥ 1',s.mob1)}
</table>
</div>
${s.skip>0?`<div><div class="sp-title">Missing</div><div class="sp-row"><span class="sp-label">Skipped teeth</span><span class="sp-val" style="color:var(--muted)">${s.skip}</span></div></div>`:''}
`;
}

function loadExam(key) {
const T = EXAMS[key];
const meta = META[key];
document.getElementById('scBadge').textContent = meta.label;
document.getElementById('scBadge').className = `sc-badge ${meta.cls}`;

const note = NOTES[key];
const noteHtml = note ? `
<div style="padding:6px 16px;background:#fffbeb;border-bottom:1px solid #fde68a;font-size:11px;display:flex;gap:8px;align-items:flex-start">
<span style="font-weight:700;color:#92400e;font-size:10px;text-transform:uppercase;letter-spacing:.5px;white-space:nowrap;margin-top:1px">Exam Note</span>
<span style="color:#78350f">${note}</span>
</div>` : '';

document.getElementById('chartArea').innerHTML = `
${noteHtml}
<div style="padding:0 0 0 0">
<!-- UPPER JAW -->
<div class="jaw-block">
<div class="jaw-label">
<span class="jaw-label-text upper">Maxillary — Teeth 1–16</span>
<div class="jaw-divider"></div>
</div>
<div style="overflow-x:auto">
<div class="grid">${buildJaw(UPPER, T, true)}</div>
</div>
</div>
<!-- separator -->
<div style="height:10px;background:var(--bg);margin:8px 0;border-top:1px solid var(--border);border-bottom:1px solid var(--border)"></div>
<!-- LOWER JAW -->
<div class="jaw-block">
<div class="jaw-label">
<span class="jaw-label-text lower">Mandibular — Teeth 17–32</span>
<div class="jaw-divider"></div>
</div>
<div style="overflow-x:auto">
<div class="grid">${buildJaw(LOWER, T, false)}</div>
</div>
</div>
</div>
`;

renderStats(buildStats(T));
}

loadExam('sc1');
</script>
</body>
</html>
(15-15/19)