LR-Assumption-Checker / index.html
mlnjsh's picture
Add 5 preset datasets dropdown with auto-fill
68b9d18
Raw
History Blame Contribute Delete
27.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Logistic Regression Assumption Checker</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#c9d1d9;font-family:'Segoe UI',system-ui,sans-serif;overflow-x:hidden}
.header{background:linear-gradient(135deg,#161b22,#1a2332);padding:18px 24px;border-bottom:1px solid #30363d;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
.header h1{font-size:1.35rem;color:#58d68d;font-weight:700}
.header .subtitle{font-size:.82rem;color:#8b949e}
.badge{background:#1e3a5f;color:#58a6ff;padding:2px 10px;border-radius:12px;font-size:.7rem;font-weight:600}
.main{display:grid;grid-template-columns:340px 1fr;gap:0;height:calc(100vh - 64px)}
.sidebar{background:#161b22;border-right:1px solid #30363d;padding:16px;overflow-y:auto}
.panel{background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:14px;margin-bottom:14px}
.panel-title{font-size:.85rem;font-weight:700;color:#58a6ff;margin-bottom:10px;display:flex;align-items:center;gap:6px}
.panel-title .icon{width:18px;height:18px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-size:.7rem;font-weight:800}
label{display:block;font-size:.75rem;color:#8b949e;margin-bottom:3px;margin-top:8px}
label:first-of-type{margin-top:0}
input[type=number],input[type=range],select{width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;border-radius:5px;padding:6px 8px;font-size:.82rem;outline:none}
input[type=number]:focus,select:focus{border-color:#58a6ff}
input[type=range]{padding:0;height:6px;-webkit-appearance:none;appearance:none;border:none;border-radius:3px;background:#21262d;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:#58a6ff;cursor:pointer}
.range-row{display:flex;align-items:center;gap:8px}
.range-val{font-size:.78rem;color:#58a6ff;min-width:36px;text-align:right;font-weight:600}
.btn{background:linear-gradient(135deg,#238636,#2ea043);color:#fff;border:none;border-radius:6px;padding:10px 16px;font-size:.88rem;font-weight:700;cursor:pointer;width:100%;margin-top:12px;transition:transform .15s}
.btn:hover{transform:scale(1.02)}
.btn:active{transform:scale(.98)}
.content{padding:20px;overflow-y:auto}
/* Traffic light dashboard */
.dashboard{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px}
.card{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:14px;text-align:center;transition:border-color .3s,box-shadow .3s}
.card.green{border-color:#238636;box-shadow:0 0 12px #23863622}
.card.yellow{border-color:#d29922;box-shadow:0 0 12px #d2992222}
.card.red{border-color:#da3633;box-shadow:0 0 12px #da363322}
.card .light{width:36px;height:36px;border-radius:50%;margin:0 auto 8px;display:flex;align-items:center;justify-content:center;font-size:1.1rem;font-weight:800}
.card.green .light{background:radial-gradient(circle,#58d68d,#238636);color:#0d1117}
.card.yellow .light{background:radial-gradient(circle,#f0c040,#d29922);color:#0d1117}
.card.red .light{background:radial-gradient(circle,#f85149,#da3633);color:#fff}
.card .name{font-size:.78rem;font-weight:700;margin-bottom:3px}
.card .detail{font-size:.68rem;color:#8b949e}
/* Overall score */
.score-bar{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px;margin-bottom:20px;display:flex;align-items:center;gap:16px}
.score-circle{width:64px;height:64px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:1.5rem;font-weight:800;flex-shrink:0}
.score-info h2{font-size:1rem;margin-bottom:4px}
.score-info p{font-size:.78rem;color:#8b949e}
/* Charts area */
.charts{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:20px}
.chart-box{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:14px}
.chart-box h3{font-size:.82rem;color:#58a6ff;margin-bottom:8px;text-align:center}
canvas{width:100%!important;height:auto!important}
/* Correlation matrix input */
.corr-grid{display:grid;gap:3px;margin-top:6px}
.corr-cell{width:100%;background:#0d1117;border:1px solid #30363d;color:#c9d1d9;border-radius:3px;padding:3px;font-size:.72rem;text-align:center}
.corr-cell.diag{background:#1e3a5f;color:#58a6ff;font-weight:700}
.corr-label{font-size:.68rem;color:#8b949e;text-align:center;font-weight:600}
/* Recommendations */
.recs{background:#161b22;border:1px solid #30363d;border-radius:10px;padding:16px}
.recs h3{font-size:.88rem;color:#f0c040;margin-bottom:10px}
.rec-item{display:flex;gap:8px;margin-bottom:8px;font-size:.78rem;align-items:flex-start}
.rec-icon{flex-shrink:0;width:20px;height:20px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:.65rem;font-weight:800}
.rec-icon.warn{background:#d29922;color:#0d1117}
.rec-icon.fix{background:#da3633;color:#fff}
.rec-icon.ok{background:#238636;color:#fff}
/* Responsive */
@media(max-width:900px){.main{grid-template-columns:1fr}.sidebar{border-right:none;border-bottom:1px solid #30363d}}
@media(max-width:600px){.dashboard{grid-template-columns:repeat(2,1fr)}.charts{grid-template-columns:1fr}}
.credit{text-align:center;padding:10px;font-size:.68rem;color:#484f58;border-top:1px solid #30363d}
.credit a{color:#58a6ff;text-decoration:none}
</style>
</head>
<body>
<div class="header">
<h1>Logistic Regression Assumption Checker</h1>
<span class="badge">ML-II Ch03 S4</span>
<span class="subtitle">Enter your dataset properties and get instant diagnostics</span>
</div>
<div class="main">
<!-- SIDEBAR: Inputs -->
<div class="sidebar">
<div class="panel" style="border-color:#58d68d">
<div class="panel-title" style="color:#58d68d"><div class="icon" style="background:#58d68d;color:#0d1117">&#9733;</div> Load Example Dataset</div>
<label>Choose a preset to auto-fill all fields</label>
<select id="presetDataset" onchange="loadPreset(this.value)" style="border-color:#58d68d">
<option value="">-- Select a dataset --</option>
<option value="titanic">Titanic Survival (clean, well-behaved)</option>
<option value="credit">Credit Card Default (large, imbalanced)</option>
<option value="heart">Heart Disease UCI (small, clinical)</option>
<option value="churn">Telecom Customer Churn (collinear features)</option>
<option value="medical">ICU Readmission (repeated measures, messy)</option>
</select>
<div id="presetDesc" style="font-size:.7rem;color:#8b949e;margin-top:6px;min-height:28px"></div>
</div>
<div class="panel">
<div class="panel-title"><div class="icon" style="background:#238636;color:#fff">1</div> Dataset Properties</div>
<label>Number of features (predictors)</label>
<input type="number" id="nFeatures" value="5" min="1" max="200"/>
<label>Total sample size (n)</label>
<input type="number" id="nSamples" value="200" min="2" max="1000000"/>
<label>Number of positive events (Y=1)</label>
<input type="number" id="nEvents" value="40" min="0" max="1000000"/>
<label>Outcome type</label>
<select id="outcomeType">
<option value="binary">Binary (0/1)</option>
<option value="ordinal">Ordinal (3+ ordered)</option>
<option value="nominal">Nominal (3+ unordered)</option>
<option value="continuous">Continuous</option>
</select>
</div>
<div class="panel">
<div class="panel-title"><div class="icon" style="background:#d29922;color:#0d1117">2</div> Independence</div>
<label>Data collection method</label>
<select id="independence">
<option value="independent">Cross-sectional (independent)</option>
<option value="repeated">Repeated measures / longitudinal</option>
<option value="clustered">Clustered / grouped (e.g., students in schools)</option>
<option value="timeseries">Time series</option>
</select>
</div>
<div class="panel">
<div class="panel-title"><div class="icon" style="background:#da3633;color:#fff">3</div> Multicollinearity</div>
<label>Highest VIF among features</label>
<div class="range-row">
<input type="range" id="maxVIF" min="1" max="30" step="0.5" value="2"/>
<span class="range-val" id="vifVal">2.0</span>
</div>
<label>Highest pairwise |correlation|</label>
<div class="range-row">
<input type="range" id="maxCorr" min="0" max="1" step="0.01" value="0.3"/>
<span class="range-val" id="corrVal">0.30</span>
</div>
</div>
<div class="panel">
<div class="panel-title"><div class="icon" style="background:#58a6ff;color:#0d1117">4</div> Linearity in Log-Odds</div>
<label>Have you checked the Box-Tidwell test?</label>
<select id="boxTidwell">
<option value="not_checked">Not checked yet</option>
<option value="passed">Yes — all features pass</option>
<option value="some_fail">Some features fail</option>
<option value="many_fail">Most features fail</option>
</select>
<label>Lowess / GAM smoothed plots</label>
<select id="lowess">
<option value="not_checked">Not checked yet</option>
<option value="linear">Plots look linear</option>
<option value="mild_curve">Mild curvature</option>
<option value="strong_curve">Strong curvature / U-shapes</option>
</select>
</div>
<div class="panel">
<div class="panel-title"><div class="icon" style="background:#bc8cff;color:#0d1117">5</div> Outliers</div>
<label>Outlier situation</label>
<select id="outliers">
<option value="none">No extreme outliers</option>
<option value="few">A few mild outliers</option>
<option value="some">Some influential points (Cook's D > 1)</option>
<option value="extreme">Extreme values present</option>
</select>
</div>
<button class="btn" onclick="runCheck()">Run Assumption Check</button>
</div>
<!-- CONTENT: Results -->
<div class="content" id="results">
<div style="text-align:center;padding:80px 20px;color:#484f58">
<div style="font-size:3rem;margin-bottom:16px">&#9881;</div>
<p style="font-size:1rem;color:#8b949e">Configure your dataset properties on the left,<br/>then click <strong style="color:#58d68d">Run Assumption Check</strong> to see results.</p>
<p style="font-size:.78rem;margin-top:12px">Part of <em>ML-II: Supervised Learning Classification</em> by Dr Milan Amrut Joshi</p>
</div>
</div>
</div>
<div class="credit">
ML-II Book &middot; Chapter 03 Section 4 &middot; Dr Milan Amrut Joshi &middot;
<a href="https://huggingface.co/mlnjsh" target="_blank">huggingface.co/mlnjsh</a>
</div>
<script>
// ═══════════════════════════════════════
// PRESET DATASETS
// ═══════════════════════════════════════
const PRESETS = {
titanic: {
desc: 'Classic ML dataset. 891 passengers, binary survival outcome, well-separated features, no major violations. A textbook example of when logistic regression works well.',
nFeatures: 6, nSamples: 891, nEvents: 342,
outcomeType: 'binary', independence: 'independent',
maxVIF: 1.8, maxCorr: 0.31,
boxTidwell: 'passed', lowess: 'linear',
outliers: 'few'
},
credit: {
desc: 'Large-scale financial dataset. 30,000 clients, 23 features (payment history, bill amounts, demographics). High class imbalance (22% default). Some features are correlated (bill amounts across months).',
nFeatures: 23, nSamples: 30000, nEvents: 6636,
outcomeType: 'binary', independence: 'independent',
maxVIF: 7.5, maxCorr: 0.79,
boxTidwell: 'some_fail', lowess: 'mild_curve',
outliers: 'some'
},
heart: {
desc: 'UCI Heart Disease dataset. 303 patients, 13 clinical features (age, cholesterol, blood pressure, etc.). Small sample, but features are mostly independent. Good for teaching.',
nFeatures: 13, nSamples: 303, nEvents: 138,
outcomeType: 'binary', independence: 'independent',
maxVIF: 2.5, maxCorr: 0.43,
boxTidwell: 'passed', lowess: 'linear',
outliers: 'few'
},
churn: {
desc: 'Telecom churn prediction. 7,043 customers, 19 features including monthly charges, total charges, tenure, and contract type. Total charges and tenure are highly correlated (VIF > 12).',
nFeatures: 19, nSamples: 7043, nEvents: 1869,
outcomeType: 'binary', independence: 'independent',
maxVIF: 12.5, maxCorr: 0.83,
boxTidwell: 'some_fail', lowess: 'mild_curve',
outliers: 'few'
},
medical: {
desc: 'ICU readmission within 30 days. 1,200 patient encounters from 480 unique patients (repeated admissions). Only 8 features but very few positive events (72 readmissions). Multiple assumption violations.',
nFeatures: 8, nSamples: 1200, nEvents: 72,
outcomeType: 'binary', independence: 'repeated',
maxVIF: 3.2, maxCorr: 0.52,
boxTidwell: 'not_checked', lowess: 'strong_curve',
outliers: 'extreme'
}
};
function loadPreset(key) {
if (!key || !PRESETS[key]) {
document.getElementById('presetDesc').textContent = '';
return;
}
const p = PRESETS[key];
document.getElementById('presetDesc').textContent = p.desc;
// Fill all fields
document.getElementById('nFeatures').value = p.nFeatures;
document.getElementById('nSamples').value = p.nSamples;
document.getElementById('nEvents').value = p.nEvents;
document.getElementById('outcomeType').value = p.outcomeType;
document.getElementById('independence').value = p.independence;
document.getElementById('maxVIF').value = p.maxVIF;
document.getElementById('vifVal').textContent = p.maxVIF.toFixed(1);
document.getElementById('maxCorr').value = p.maxCorr;
document.getElementById('corrVal').textContent = p.maxCorr.toFixed(2);
document.getElementById('boxTidwell').value = p.boxTidwell;
document.getElementById('lowess').value = p.lowess;
document.getElementById('outliers').value = p.outliers;
// Auto-run the check
runCheck();
}
// Slider value display
document.getElementById('maxVIF').addEventListener('input', e => {
document.getElementById('vifVal').textContent = parseFloat(e.target.value).toFixed(1);
});
document.getElementById('maxCorr').addEventListener('input', e => {
document.getElementById('corrVal').textContent = parseFloat(e.target.value).toFixed(2);
});
function runCheck() {
const nF = parseInt(document.getElementById('nFeatures').value) || 1;
const nS = parseInt(document.getElementById('nSamples').value) || 10;
const nE = parseInt(document.getElementById('nEvents').value) || 0;
const outcome = document.getElementById('outcomeType').value;
const indep = document.getElementById('independence').value;
const vif = parseFloat(document.getElementById('maxVIF').value);
const corr = parseFloat(document.getElementById('maxCorr').value);
const bt = document.getElementById('boxTidwell').value;
const low = document.getElementById('lowess').value;
const outl = document.getElementById('outliers').value;
const epp = nF > 0 ? nE / nF : 999; // events per predictor
const nNeg = nS - nE;
const eppNeg = nF > 0 ? nNeg / nF : 999;
const minEPP = Math.min(epp, eppNeg);
// ── Evaluate each assumption ──
const results = [];
// 1. Binary outcome
if (outcome === 'binary') {
results.push({ name: 'Binary Outcome', status: 'green', detail: 'Y is binary (0/1) — perfect.', score: 100 });
} else if (outcome === 'ordinal') {
results.push({ name: 'Binary Outcome', status: 'yellow', detail: 'Ordinal Y — consider ordinal logistic regression.', score: 50 });
} else if (outcome === 'nominal') {
results.push({ name: 'Binary Outcome', status: 'yellow', detail: 'Nominal Y — use multinomial logistic regression.', score: 40 });
} else {
results.push({ name: 'Binary Outcome', status: 'red', detail: 'Continuous Y — logistic regression is not appropriate.', score: 0 });
}
// 2. Independence
if (indep === 'independent') {
results.push({ name: 'Independence', status: 'green', detail: 'Cross-sectional data — observations are independent.', score: 100 });
} else if (indep === 'clustered') {
results.push({ name: 'Independence', status: 'yellow', detail: 'Clustered data — consider GEE or mixed-effects logistic.', score: 40 });
} else if (indep === 'repeated') {
results.push({ name: 'Independence', status: 'red', detail: 'Repeated measures — standard LR will give wrong SEs.', score: 15 });
} else {
results.push({ name: 'Independence', status: 'red', detail: 'Time series — observations are autocorrelated.', score: 10 });
}
// 3. Multicollinearity
let mcScore = 100;
let mcStatus = 'green';
let mcDetail = `Max VIF = ${vif.toFixed(1)}, Max |r| = ${corr.toFixed(2)} — no issues.`;
if (vif > 10 || corr > 0.9) {
mcScore = 10; mcStatus = 'red';
mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — severe multicollinearity!`;
} else if (vif > 5 || corr > 0.7) {
mcScore = 45; mcStatus = 'yellow';
mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — moderate concern.`;
} else if (vif > 3 || corr > 0.5) {
mcScore = 70; mcStatus = 'yellow';
mcDetail = `VIF=${vif.toFixed(1)}, |r|=${corr.toFixed(2)} — mild, probably OK.`;
}
results.push({ name: 'No Multicollinearity', status: mcStatus, detail: mcDetail, score: mcScore });
// 4. Linearity in log-odds
let linScore = 100, linStatus = 'green', linDetail = '';
if (bt === 'not_checked' && low === 'not_checked') {
linScore = 60; linStatus = 'yellow';
linDetail = 'Not tested yet — run Box-Tidwell or check lowess plots.';
} else if (bt === 'many_fail' || low === 'strong_curve') {
linScore = 10; linStatus = 'red';
linDetail = 'Strong non-linearity detected — add polynomial terms or use splines.';
} else if (bt === 'some_fail' || low === 'mild_curve') {
linScore = 50; linStatus = 'yellow';
linDetail = 'Some non-linearity — consider transformations for affected features.';
} else {
linDetail = 'Linearity tests passed — log-odds relationship looks linear.';
}
results.push({ name: 'Linear Log-Odds', status: linStatus, detail: linDetail, score: linScore });
// 5. Sample size
let ssScore = 100, ssStatus = 'green', ssDetail = '';
if (minEPP >= 20) {
ssDetail = `${minEPP.toFixed(0)} events/feature — excellent sample size.`;
} else if (minEPP >= 10) {
ssScore = 75; ssStatus = 'green';
ssDetail = `${minEPP.toFixed(0)} events/feature — adequate (10+ rule met).`;
} else if (minEPP >= 5) {
ssScore = 40; ssStatus = 'yellow';
ssDetail = `${minEPP.toFixed(1)} events/feature — borderline, coefficients may be biased.`;
} else {
ssScore = 10; ssStatus = 'red';
ssDetail = `${minEPP.toFixed(1)} events/feature — too few! MLE may not converge.`;
}
results.push({ name: 'Sample Size', status: ssStatus, detail: ssDetail, score: ssScore });
// 6. Outliers
if (outl === 'none') {
results.push({ name: 'No Extreme Outliers', status: 'green', detail: 'No extreme outliers — model is stable.', score: 100 });
} else if (outl === 'few') {
results.push({ name: 'No Extreme Outliers', status: 'green', detail: 'A few mild outliers — usually acceptable.', score: 85 });
} else if (outl === 'some') {
results.push({ name: 'No Extreme Outliers', status: 'yellow', detail: 'Influential points detected — investigate Cook\'s D.', score: 45 });
} else {
results.push({ name: 'No Extreme Outliers', status: 'red', detail: 'Extreme values present — may distort coefficients.', score: 15 });
}
// Overall score
const avgScore = Math.round(results.reduce((s, r) => s + r.score, 0) / results.length);
// Generate recommendations
const recs = [];
results.forEach(r => {
if (r.status === 'red') {
recs.push({ level: 'fix', text: `<strong>${r.name}:</strong> ${r.detail}` });
} else if (r.status === 'yellow') {
recs.push({ level: 'warn', text: `<strong>${r.name}:</strong> ${r.detail}` });
}
});
if (recs.length === 0) {
recs.push({ level: 'ok', text: 'All assumptions look good! Your model should produce reliable results.' });
}
// ── Render results ──
let overallColor = avgScore >= 75 ? '#58d68d' : avgScore >= 50 ? '#f0c040' : '#f85149';
let overallBg = avgScore >= 75 ? '#23863644' : avgScore >= 50 ? '#d2992244' : '#da363344';
let overallLabel = avgScore >= 75 ? 'Good to go' : avgScore >= 50 ? 'Needs attention' : 'Serious issues';
let html = `
<div class="score-bar">
<div class="score-circle" style="background:${overallBg};color:${overallColor};border:3px solid ${overallColor}">${avgScore}</div>
<div class="score-info">
<h2 style="color:${overallColor}">${overallLabel}</h2>
<p>Overall assumption score: ${avgScore}/100. ${results.filter(r=>r.status==='green').length} of 6 assumptions satisfied.</p>
</div>
</div>
<div class="dashboard">
${results.map(r => `
<div class="card ${r.status}">
<div class="light">${r.status === 'green' ? '&#10003;' : r.status === 'yellow' ? '!' : '&#10007;'}</div>
<div class="name">${r.name}</div>
<div class="detail">${r.detail}</div>
</div>
`).join('')}
</div>
<div class="charts">
<div class="chart-box">
<h3>Events-Per-Feature Analysis</h3>
<canvas id="eppChart" width="400" height="260"></canvas>
</div>
<div class="chart-box">
<h3>Assumption Radar</h3>
<canvas id="radarChart" width="400" height="260"></canvas>
</div>
</div>
<div class="recs">
<h3>${recs.some(r => r.level === 'fix') ? 'Action Required' : recs.some(r => r.level === 'warn') ? 'Recommendations' : 'All Clear'}</h3>
${recs.map(r => `
<div class="rec-item">
<div class="rec-icon ${r.level}">${r.level === 'fix' ? '!' : r.level === 'warn' ? '?' : '&#10003;'}</div>
<div>${r.text}</div>
</div>
`).join('')}
</div>
`;
document.getElementById('results').innerHTML = html;
// ── Draw EPP bar chart ──
drawEPPChart(nF, nE, nNeg);
// ── Draw Radar chart ──
drawRadarChart(results);
}
function drawEPPChart(nF, nE, nNeg) {
const canvas = document.getElementById('eppChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const epp = nF > 0 ? nE / nF : 0;
const eppNeg = nF > 0 ? nNeg / nF : 0;
const bars = [
{ label: 'Events/Feature', value: epp, color: epp >= 10 ? '#58d68d' : epp >= 5 ? '#f0c040' : '#f85149' },
{ label: 'Non-Events/Feature', value: eppNeg, color: eppNeg >= 10 ? '#58d68d' : eppNeg >= 5 ? '#f0c040' : '#f85149' },
];
const maxVal = Math.max(epp, eppNeg, 25);
const barW = 60, gap = 80;
const chartL = 80, chartB = 40, chartH = H - 80, chartW = W - 100;
// Axes
ctx.strokeStyle = '#30363d'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(chartL, 20); ctx.lineTo(chartL, 20 + chartH); ctx.lineTo(chartL + chartW, 20 + chartH); ctx.stroke();
// Threshold lines
[10, 20].forEach(thresh => {
const y = 20 + chartH - (thresh / maxVal) * chartH;
ctx.strokeStyle = thresh === 10 ? '#da363366' : '#23863666';
ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(chartL, y); ctx.lineTo(chartL + chartW, y); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = thresh === 10 ? '#da3633' : '#238636';
ctx.font = '11px system-ui';
ctx.fillText(thresh === 10 ? 'Min (10)' : 'Good (20)', chartL + chartW - 52, y - 4);
});
// Bars
bars.forEach((b, i) => {
const x = chartL + 50 + i * (barW + gap);
const barH = (Math.min(b.value, maxVal) / maxVal) * chartH;
const y = 20 + chartH - barH;
// Gradient fill
const grad = ctx.createLinearGradient(x, y, x, 20 + chartH);
grad.addColorStop(0, b.color);
grad.addColorStop(1, b.color + '44');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.roundRect(x, y, barW, barH, [4, 4, 0, 0]);
ctx.fill();
// Value label
ctx.fillStyle = '#c9d1d9';
ctx.font = 'bold 13px system-ui';
ctx.textAlign = 'center';
ctx.fillText(b.value.toFixed(1), x + barW / 2, y - 8);
// Bar label
ctx.fillStyle = '#8b949e';
ctx.font = '10px system-ui';
ctx.fillText(b.label, x + barW / 2, 20 + chartH + 16);
});
ctx.textAlign = 'start';
}
function drawRadarChart(results) {
const canvas = document.getElementById('radarChart');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H / 2 + 5;
const R = Math.min(W, H) / 2 - 35;
const n = results.length;
const angles = results.map((_, i) => (Math.PI * 2 * i) / n - Math.PI / 2);
// Grid rings
[0.25, 0.5, 0.75, 1.0].forEach(frac => {
ctx.strokeStyle = '#21262d';
ctx.lineWidth = 0.8;
ctx.beginPath();
for (let i = 0; i <= n; i++) {
const a = (Math.PI * 2 * (i % n)) / n - Math.PI / 2;
const x = cx + Math.cos(a) * R * frac;
const y = cy + Math.sin(a) * R * frac;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
});
// Axis lines
angles.forEach(a => {
ctx.strokeStyle = '#21262d';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(a) * R, cy + Math.sin(a) * R);
ctx.stroke();
});
// Data polygon
ctx.fillStyle = '#58a6ff22';
ctx.strokeStyle = '#58a6ff';
ctx.lineWidth = 2;
ctx.beginPath();
results.forEach((r, i) => {
const frac = r.score / 100;
const x = cx + Math.cos(angles[i]) * R * frac;
const y = cy + Math.sin(angles[i]) * R * frac;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fill();
ctx.stroke();
// Data points
results.forEach((r, i) => {
const frac = r.score / 100;
const x = cx + Math.cos(angles[i]) * R * frac;
const y = cy + Math.sin(angles[i]) * R * frac;
ctx.fillStyle = r.status === 'green' ? '#58d68d' : r.status === 'yellow' ? '#f0c040' : '#f85149';
ctx.beginPath();
ctx.arc(x, y, 5, 0, Math.PI * 2);
ctx.fill();
});
// Labels
ctx.font = '10px system-ui';
ctx.textAlign = 'center';
const shortNames = ['Binary', 'Independence', 'Multicollin.', 'Log-Odds', 'Sample Size', 'Outliers'];
results.forEach((r, i) => {
const lx = cx + Math.cos(angles[i]) * (R + 22);
const ly = cy + Math.sin(angles[i]) * (R + 22);
ctx.fillStyle = r.status === 'green' ? '#58d68d' : r.status === 'yellow' ? '#f0c040' : '#f85149';
ctx.fillText(shortNames[i] || r.name, lx, ly + 4);
});
}
// Run check on load with defaults
window.addEventListener('load', () => { setTimeout(runCheck, 300); });
</script>
</body>
</html>