decision-tool / index.html
rigelbar's picture
Update index.html
daecf69 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Weighted Decision Tool (with Radar Chart)</title>
<style>
:root{
--bg:#0b1220;
--panel:#111a2e;
--panel2:#0f1730;
--text:#e8eefc;
--muted:#aab6d8;
--line:#223055;
--accent:#6ea8ff;
--good:#48d597;
--warn:#ffcc66;
--bad:#ff6b6b;
--chip:#182545;
--shadow: 0 10px 30px rgba(0,0,0,.35);
--radius: 16px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji","Segoe UI Emoji";
}
*{ box-sizing: border-box; }
body{
margin:0;
font-family: var(--sans);
background: radial-gradient(1200px 800px at 20% 0%, #15234a 0%, var(--bg) 55%);
color: var(--text);
}
header{
padding: 22px 18px 10px;
max-width: 1200px;
margin: 0 auto;
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:16px;
}
h1{
margin:0;
font-weight:800;
letter-spacing:.2px;
font-size: 20px;
}
.sub{
margin:6px 0 0;
color: var(--muted);
font-size: 13px;
line-height:1.35;
}
.toolbar{
display:flex;
gap:10px;
flex-wrap:wrap;
align-items:center;
justify-content:flex-end;
}
button, .btn{
border:1px solid var(--line);
background: linear-gradient(180deg, #16264a 0%, #0f1b35 100%);
color: var(--text);
padding: 10px 12px;
border-radius: 12px;
cursor:pointer;
font-weight:650;
font-size: 13px;
box-shadow: var(--shadow);
transition: transform .06s ease, border-color .2s ease, filter .2s ease;
user-select:none;
}
button:hover{ border-color:#2f4277; filter: brightness(1.05); }
button:active{ transform: translateY(1px); }
button.ghost{
background: transparent;
box-shadow:none;
}
button.danger{
background: linear-gradient(180deg, #3a1a24 0%, #241019 100%);
border-color:#5a2333;
}
.wrap{
max-width: 1200px;
margin: 0 auto;
padding: 0 18px 28px;
display:grid;
grid-template-columns: 1.2fr .8fr;
gap: 16px;
}
@media (max-width: 980px){
.wrap{ grid-template-columns: 1fr; }
header{ align-items:flex-start; flex-direction:column; }
.toolbar{ justify-content:flex-start; }
}
.card{
background: linear-gradient(180deg, rgba(255,255,255,.035) 0%, rgba(255,255,255,.02) 100%);
border: 1px solid rgba(255,255,255,.08);
border-radius: var(--radius);
box-shadow: var(--shadow);
overflow:hidden;
}
.card h2{
margin:0;
padding: 14px 14px 0;
font-size: 14px;
letter-spacing:.2px;
}
.card .pad{ padding: 14px; }
.note{
margin:0;
color: var(--muted);
font-size: 12.5px;
line-height: 1.4;
}
/* Table */
.tableWrap{
overflow:auto;
border-top:1px solid rgba(255,255,255,.06);
margin-top: 12px;
}
table{
width:100%;
border-collapse: separate;
border-spacing:0;
min-width: 820px;
}
th, td{
padding: 10px 10px;
border-bottom: 1px solid rgba(255,255,255,.06);
vertical-align: middle;
}
th{
position: sticky;
top: 0;
z-index: 1;
background: rgba(16, 25, 48, .85);
backdrop-filter: blur(8px);
text-align:left;
font-size: 12px;
color: var(--muted);
font-weight: 750;
letter-spacing: .3px;
}
tr:hover td{ background: rgba(255,255,255,.02); }
.col-criterion{ min-width: 220px; }
.col-weight{ min-width: 120px; }
.col-actions{ min-width: 120px; text-align:right; }
.optHead{
display:flex;
gap:8px;
align-items:center;
}
.pill{
font-size: 11px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(255,255,255,.06);
border: 1px solid rgba(255,255,255,.08);
color: var(--muted);
white-space:nowrap;
}
/* Inputs */
input[type="text"], input[type="number"], textarea{
width:100%;
padding: 9px 10px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(9, 14, 28, .55);
color: var(--text);
outline: none;
font-size: 13px;
}
input[type="number"]{ font-family: var(--mono); }
input:focus, textarea:focus{
border-color: rgba(110,168,255,.65);
box-shadow: 0 0 0 3px rgba(110,168,255,.12);
}
.small{
font-size: 12px;
color: var(--muted);
}
.rowActions{
display:flex;
justify-content:flex-end;
gap:8px;
}
.mini{
padding: 8px 10px;
border-radius: 12px;
font-size: 12px;
box-shadow:none;
}
/* Results */
.rank{
display:flex;
flex-direction:column;
gap:10px;
margin-top: 10px;
}
.rankItem{
display:flex;
align-items:center;
justify-content:space-between;
gap:12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255,255,255,.08);
background: rgba(10, 16, 34, .45);
}
.rankLeft{
display:flex;
flex-direction:column;
gap:3px;
}
.rankName{
font-weight: 820;
letter-spacing:.2px;
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
}
.badge{
font-size: 11px;
padding: 4px 9px;
border-radius: 999px;
border:1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.06);
color: var(--muted);
font-weight: 750;
}
.badge.win{
color: #07160f;
background: rgba(72,213,151,.92);
border-color: rgba(72,213,151,.85);
}
.score{
font-family: var(--mono);
font-weight: 800;
font-size: 14px;
white-space:nowrap;
}
.score small{
font-family: var(--sans);
font-weight: 650;
color: var(--muted);
font-size: 11px;
margin-left: 6px;
}
.divider{
height: 1px;
background: rgba(255,255,255,.06);
margin: 12px 0;
}
/* Radar */
.radarWrap{
display:grid;
grid-template-columns: 1fr;
gap: 10px;
align-items:start;
}
.radarRow{
display:flex;
gap: 10px;
flex-wrap:wrap;
align-items:center;
justify-content:space-between;
}
.legend{
display:flex;
gap:10px;
flex-wrap:wrap;
}
.legendItem{
display:flex;
align-items:center;
gap:8px;
font-size: 12px;
color: var(--muted);
background: rgba(255,255,255,.04);
border:1px solid rgba(255,255,255,.08);
border-radius: 999px;
padding: 6px 10px;
}
.swatch{
width: 10px;
height: 10px;
border-radius: 3px;
background: #999;
border: 1px solid rgba(255,255,255,.25);
flex: 0 0 auto;
}
svg{
width:100%;
height:auto;
display:block;
border-radius: 14px;
background: rgba(8, 12, 26, .35);
border:1px solid rgba(255,255,255,.07);
}
.why{
display:flex;
flex-direction:column;
gap:10px;
}
.why ul{
margin: 0;
padding-left: 18px;
color: var(--text);
}
.why li{
margin: 6px 0;
color: var(--text);
line-height:1.35;
}
.muted{ color: var(--muted); }
.kpi{
display:flex;
gap:10px;
flex-wrap:wrap;
margin-top: 6px;
}
.kpi .chip{
background: rgba(255,255,255,.05);
border:1px solid rgba(255,255,255,.08);
padding: 8px 10px;
border-radius: 999px;
color: var(--muted);
font-size: 12px;
}
.chip strong{ color: var(--text); font-weight: 850; }
/* Import/Export */
textarea{
min-height: 130px;
font-family: var(--mono);
font-size: 12px;
}
.twoCols{
display:grid;
grid-template-columns: 1fr 1fr;
gap:10px;
}
@media (max-width: 980px){
.twoCols{ grid-template-columns: 1fr; }
table{ min-width: 760px; }
}
/* Footer hint */
.foot{
max-width: 1200px;
margin: 0 auto;
padding: 0 18px 22px;
color: var(--muted);
font-size: 12px;
}
a{ color: #9dc0ff; }
</style>
</head>
<body>
<header>
<div>
<h1>Weighted Decision Tool</h1>
<p class="sub">
Score each option from <b>0–10</b> per criterion, set criterion weights, and it’ll compute totals,
show a <b>radar chart</b>, and explain <b>why the winner wins</b>.
</p>
</div>
<div class="toolbar">
<button id="btnAddCriterion" title="Add a new criterion">+ Criterion</button>
<button id="btnAddOption" title="Add a new option">+ Option</button>
<button id="btnReset" class="ghost" title="Reset to example data">Reset (example)</button>
</div>
</header>
<div class="wrap">
<!-- Left: Decision table -->
<section class="card">
<h2>Inputs</h2>
<div class="pad">
<p class="note">
<b>Weights</b> can be any non-negative numbers (they don’t need to add to 1). Higher weight = more important.
Scores are 0–10. Totals are computed as <span class="pill">Σ (weight × score)</span>.
</p>
<div class="tableWrap" id="tableWrap"></div>
<div class="divider"></div>
<div class="twoCols">
<div>
<h2 style="padding:0; font-size:13px;">Import / Export JSON</h2>
<p class="note" style="margin-top:6px;">
Copy to share, or paste to load. (This is plain JSON—no files needed.)
</p>
</div>
<div style="display:flex; gap:10px; justify-content:flex-end; align-items:flex-end; flex-wrap:wrap;">
<button class="mini" id="btnExport">Export</button>
<button class="mini" id="btnImport">Import</button>
<button class="mini danger" id="btnClear">Clear</button>
</div>
</div>
<div style="margin-top:10px;">
<textarea id="jsonBox" placeholder='{"options":[...],"criteria":[...],"scores":{...}}'></textarea>
<div class="small" id="jsonMsg" style="margin-top:8px;"></div>
</div>
</div>
</section>
<!-- Right: Results + Radar + Why -->
<aside class="card">
<h2>Results</h2>
<div class="pad">
<div class="kpi" id="kpis"></div>
<div class="rank" id="rank"></div>
<div class="divider"></div>
<div class="radarWrap">
<div class="radarRow">
<div>
<div style="font-weight:820; font-size:13px;">Radar chart</div>
<div class="small">Shows raw 0–10 criterion scores (weights appear in labels).</div>
</div>
<div class="legend" id="legend"></div>
</div>
<div id="radarHolder"></div>
</div>
<div class="divider"></div>
<div class="why">
<div style="font-weight:820; font-size:13px;">Why this wins</div>
<div class="small" id="whyIntro"></div>
<div id="whyBody"></div>
</div>
</div>
</aside>
</div>
<div class="foot">
Tip: If you want a faster decision, cap criteria to 5–8 and only add criteria that would genuinely change the outcome.
</div>
<script>
/* ==============================
Weighted Decision Tool (single file)
- Dynamic criteria/options
- Weighted total scoring
- SVG radar chart
- "Why this wins" explanation
================================ */
(function(){
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const fmt = (n) => (Math.round(n * 100) / 100).toFixed(2).replace(/\.00$/, "");
const uid = () => (crypto && crypto.randomUUID) ? crypto.randomUUID() : ("id_" + Math.random().toString(16).slice(2));
const palette = [
{ stroke:"#6ea8ff", fill:"rgba(110,168,255,.18)" },
{ stroke:"#48d597", fill:"rgba(72,213,151,.16)" },
{ stroke:"#ffcc66", fill:"rgba(255,204,102,.16)" },
{ stroke:"#ff6b6b", fill:"rgba(255,107,107,.14)" },
{ stroke:"#c38bff", fill:"rgba(195,139,255,.14)" },
{ stroke:"#66e3ff", fill:"rgba(102,227,255,.14)" },
{ stroke:"#ffd1f0", fill:"rgba(255,209,240,.14)" }
];
const els = {
tableWrap: document.getElementById("tableWrap"),
rank: document.getElementById("rank"),
radarHolder: document.getElementById("radarHolder"),
legend: document.getElementById("legend"),
kpis: document.getElementById("kpis"),
whyIntro: document.getElementById("whyIntro"),
whyBody: document.getElementById("whyBody"),
jsonBox: document.getElementById("jsonBox"),
jsonMsg: document.getElementById("jsonMsg")
};
const btns = {
addCriterion: document.getElementById("btnAddCriterion"),
addOption: document.getElementById("btnAddOption"),
reset: document.getElementById("btnReset"),
export: document.getElementById("btnExport"),
import: document.getElementById("btnImport"),
clear: document.getElementById("btnClear")
};
let state = makeExampleState();
function makeExampleState(){
// Example: build vs buy vs outsource decision
const options = [
{ id: uid(), name: "Build in-house" },
{ id: uid(), name: "Buy SaaS" },
{ id: uid(), name: "Outsource" }
];
const criteria = [
{ id: uid(), name: "Total cost (12 mo)", weight: 3 },
{ id: uid(), name: "Time-to-value", weight: 2.5 },
{ id: uid(), name: "Control / flexibility", weight: 2 },
{ id: uid(), name: "Risk (delivery + vendor)", weight: 3 },
{ id: uid(), name: "Scalability", weight: 1.5 }
];
const scores = {}; // scores[critId][optId] = number 0..10
// Fill with reasonable example scores
for (const c of criteria){
scores[c.id] = {};
for (const o of options){
scores[c.id][o.id] = 5;
}
}
// Customize
// Total cost
scores[criteria[0].id][options[0].id] = 6; // build
scores[criteria[0].id][options[1].id] = 8; // SaaS
scores[criteria[0].id][options[2].id] = 7; // outsource
// Time-to-value
scores[criteria[1].id][options[0].id] = 4;
scores[criteria[1].id][options[1].id] = 9;
scores[criteria[1].id][options[2].id] = 7;
// Control / flexibility
scores[criteria[2].id][options[0].id] = 9;
scores[criteria[2].id][options[1].id] = 6;
scores[criteria[2].id][options[2].id] = 5;
// Risk
scores[criteria[3].id][options[0].id] = 6;
scores[criteria[3].id][options[1].id] = 7;
scores[criteria[3].id][options[2].id] = 5;
// Scalability
scores[criteria[4].id][options[0].id] = 8;
scores[criteria[4].id][options[1].id] = 7;
scores[criteria[4].id][options[2].id] = 6;
return { options, criteria, scores };
}
function normalizeState(){
// Ensure scores exist for all (crit, opt)
for (const c of state.criteria){
if (!state.scores[c.id]) state.scores[c.id] = {};
for (const o of state.options){
if (state.scores[c.id][o.id] == null || Number.isNaN(+state.scores[c.id][o.id])) {
state.scores[c.id][o.id] = 0;
}
}
}
// Remove scores for deleted criteria/options
const critIds = new Set(state.criteria.map(c => c.id));
const optIds = new Set(state.options.map(o => o.id));
for (const cid of Object.keys(state.scores)){
if (!critIds.has(cid)) delete state.scores[cid];
else {
for (const oid of Object.keys(state.scores[cid])){
if (!optIds.has(oid)) delete state.scores[cid][oid];
}
}
}
}
function compute(){
normalizeState();
const weightsSum = state.criteria.reduce((s,c)=> s + Math.max(0, +c.weight || 0), 0);
const totals = {};
const perCriterion = {}; // perCriterion[optId][critId] = weight*score (contribution)
for (const o of state.options){
totals[o.id] = 0;
perCriterion[o.id] = {};
}
for (const c of state.criteria){
const w = Math.max(0, +c.weight || 0);
for (const o of state.options){
const s = clamp(+state.scores[c.id][o.id] || 0, 0, 10);
const contrib = w * s;
totals[o.id] += contrib;
perCriterion[o.id][c.id] = contrib;
}
}
const ranked = [...state.options]
.map(o => ({ ...o, total: totals[o.id] }))
.sort((a,b) => b.total - a.total);
return { weightsSum, totals, perCriterion, ranked };
}
function render(){
const { weightsSum, ranked } = compute();
renderTable();
renderKpis(weightsSum);
renderRank(ranked);
renderRadar();
renderWhy(ranked);
saveToLocalStorage();
}
function renderKpis(weightsSum){
const critCount = state.criteria.length;
const optCount = state.options.length;
const sumText = weightsSum === 0 ? "0 (set weights)" : fmt(weightsSum);
els.kpis.innerHTML = `
<div class="chip">Criteria: <strong>${critCount}</strong></div>
<div class="chip">Options: <strong>${optCount}</strong></div>
<div class="chip">Weight sum: <strong>${sumText}</strong></div>
`;
}
function renderTable(){
const { weightsSum } = compute();
const thead = `
<thead>
<tr>
<th class="col-criterion">Criterion</th>
<th class="col-weight">Weight</th>
${state.options.map((o, idx) => {
const label = `Option ${idx+1}`;
return `
<th>
<div class="optHead">
<span class="pill">${label}</span>
</div>
<div style="margin-top:8px; display:flex; gap:8px; align-items:center;">
<input type="text" data-role="optName" data-oid="${o.id}" value="${escapeHtml(o.name)}" aria-label="${label} name"/>
<button class="mini danger" data-role="removeOption" data-oid="${o.id}" title="Remove option">✕</button>
</div>
</th>
`;
}).join("")}
<th class="col-actions">Row</th>
</tr>
</thead>
`;
const tbody = `
<tbody>
${state.criteria.map((c) => {
const w = Math.max(0, +c.weight || 0);
const weightHint = weightsSum > 0 ? (w / weightsSum) : 0;
return `
<tr>
<td class="col-criterion">
<input type="text" data-role="critName" data-cid="${c.id}" value="${escapeHtml(c.name)}" aria-label="Criterion name"/>
<div class="small" style="margin-top:6px;">
Weight share: <span class="pill">${weightsSum>0 ? Math.round(weightHint*100) + "%" : "—"}</span>
</div>
</td>
<td class="col-weight">
<input type="number" min="0" step="0.1" data-role="critWeight" data-cid="${c.id}" value="${w}" aria-label="Weight"/>
</td>
${state.options.map((o) => {
const s = clamp(+state.scores[c.id][o.id] || 0, 0, 10);
return `
<td>
<input type="number" min="0" max="10" step="0.1"
data-role="score" data-cid="${c.id}" data-oid="${o.id}"
value="${s}" aria-label="Score for ${escapeHtml(o.name)} on ${escapeHtml(c.name)}"/>
<div class="small" style="margin-top:6px;">
Contribution: <span class="pill">${fmt(w*s)}</span>
</div>
</td>
`;
}).join("")}
<td class="col-actions">
<div class="rowActions">
<button class="mini danger" data-role="removeCriterion" data-cid="${c.id}" title="Remove criterion">Remove</button>
</div>
</td>
</tr>
`;
}).join("")}
</tbody>
`;
els.tableWrap.innerHTML = `
<table aria-label="Decision scoring table">
${thead}
${tbody}
</table>
`;
// Wire events (delegated)
els.tableWrap.onclick = (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const role = btn.getAttribute("data-role");
if (role === "removeCriterion"){
const cid = btn.getAttribute("data-cid");
state.criteria = state.criteria.filter(c => c.id !== cid);
delete state.scores[cid];
render();
}
if (role === "removeOption"){
const oid = btn.getAttribute("data-oid");
state.options = state.options.filter(o => o.id !== oid);
for (const cid of Object.keys(state.scores)){
delete state.scores[cid][oid];
}
render();
}
};
els.tableWrap.oninput = (e) => {
const el = e.target;
const role = el.getAttribute("data-role");
if (!role) return;
if (role === "critName"){
const cid = el.getAttribute("data-cid");
const c = state.criteria.find(x => x.id === cid);
if (c) c.name = el.value;
render();
}
if (role === "critWeight"){
const cid = el.getAttribute("data-cid");
const c = state.criteria.find(x => x.id === cid);
if (c) c.weight = Math.max(0, +el.value || 0);
render();
}
if (role === "optName"){
const oid = el.getAttribute("data-oid");
const o = state.options.find(x => x.id === oid);
if (o) o.name = el.value;
render();
}
if (role === "score"){
const cid = el.getAttribute("data-cid");
const oid = el.getAttribute("data-oid");
const v = clamp(+el.value || 0, 0, 10);
if (!state.scores[cid]) state.scores[cid] = {};
state.scores[cid][oid] = v;
render();
}
};
}
function renderRank(ranked){
if (state.options.length === 0){
els.rank.innerHTML = `<div class="note">Add at least one option.</div>`;
return;
}
const winnerId = ranked[0]?.id;
els.rank.innerHTML = ranked.map((o, idx) => {
const isWinner = o.id === winnerId && ranked.length > 0;
const badge = isWinner ? `<span class="badge win">Winner</span>` : `<span class="badge">#${idx+1}</span>`;
return `
<div class="rankItem">
<div class="rankLeft">
<div class="rankName">
${escapeHtml(o.name)} ${badge}
</div>
<div class="small">Weighted total score</div>
</div>
<div class="score">${fmt(o.total)} <small>pts</small></div>
</div>
`;
}).join("");
}
function renderWhy(ranked){
const { weightsSum } = compute();
els.whyBody.innerHTML = "";
els.whyIntro.textContent = "";
if (ranked.length < 1){
els.whyIntro.textContent = "Add options to see a winner.";
return;
}
if (state.criteria.length < 1){
els.whyIntro.textContent = "Add criteria to explain the result.";
return;
}
if (weightsSum === 0){
els.whyIntro.textContent = "Set at least one non-zero weight to produce a meaningful result.";
return;
}
const winner = ranked[0];
const runner = ranked[1] || null;
if (!runner){
els.whyIntro.textContent = `Only one option exists, so "${winner.name}" wins by default.`;
return;
}
const { perCriterion } = compute();
const deltas = state.criteria.map(c => {
const w = Math.max(0, +c.weight || 0);
const wScore = clamp(+state.scores[c.id][winner.id] || 0, 0, 10);
const rScore = clamp(+state.scores[c.id][runner.id] || 0, 0, 10);
const contribDelta = w * (wScore - rScore); // how much this criterion pushes winner vs runner
return {
cid: c.id,
name: c.name,
weight: w,
winnerScore: wScore,
runnerScore: rScore,
contribDelta
};
}).sort((a,b) => Math.abs(b.contribDelta) - Math.abs(a.contribDelta));
const margin = winner.total - runner.total;
els.whyIntro.innerHTML =
`Comparing <b>${escapeHtml(winner.name)}</b> (winner) to <b>${escapeHtml(runner.name)}</b> (runner-up): ` +
`the margin is <b>${fmt(margin)}</b> points. Biggest drivers below (weight × score difference).`;
const positives = deltas.filter(d => d.contribDelta > 0).slice(0, 4);
const negatives = deltas.filter(d => d.contribDelta < 0).slice(0, 3);
const posList = positives.length ? `
<div>
<div class="small muted" style="margin-bottom:6px;">Where the winner pulls ahead</div>
<ul>
${positives.map(d => {
return `<li><b>${escapeHtml(d.name)}</b>: ${d.winnerScore} vs ${d.runnerScore}
<span class="pill">+${fmt(d.contribDelta)}</span></li>`;
}).join("")}
</ul>
</div>` : `<div class="note">No criteria where the winner scores higher than the runner-up (likely a tie).</div>`;
const negList = negatives.length ? `
<div style="margin-top:10px;">
<div class="small muted" style="margin-bottom:6px;">Where the runner-up is stronger (risk to the choice)</div>
<ul>
${negatives.map(d => {
return `<li><b>${escapeHtml(d.name)}</b>: ${d.winnerScore} vs ${d.runnerScore}
<span class="pill">${fmt(d.contribDelta)}</span></li>`;
}).join("")}
</ul>
</div>` : "";
// A short actionable takeaway:
const top = deltas[0];
let takeaway = "";
if (top){
const direction = top.contribDelta >= 0 ? "helped" : "hurt";
takeaway = `Takeaway: The most influential criterion was <b>${escapeHtml(top.name)}</b> (it ${direction} the winner by <b>${fmt(top.contribDelta)}</b> points).`;
}
els.whyBody.innerHTML = posList + negList + `
<div class="divider"></div>
<div class="note">${takeaway}</div>
`;
}
function renderRadar(){
els.radarHolder.innerHTML = "";
els.legend.innerHTML = "";
const N = state.criteria.length;
const M = state.options.length;
if (N < 3){
els.radarHolder.innerHTML = `<div class="note">Add at least <b>3 criteria</b> to draw a radar chart.</div>`;
return;
}
if (M < 1){
els.radarHolder.innerHTML = `<div class="note">Add at least one option.</div>`;
return;
}
// Build legend
state.options.forEach((o, idx) => {
const col = palette[idx % palette.length];
els.legend.insertAdjacentHTML("beforeend", `
<div class="legendItem">
<span class="swatch" style="background:${col.stroke}"></span>
${escapeHtml(o.name)}
</div>
`);
});
// SVG config
const size = 380;
const pad = 44;
const cx = size/2, cy = size/2;
const R = (size/2) - pad;
const rings = 5;
const angleFor = (i) => (-Math.PI/2) + (i * (2*Math.PI / N));
// Helpers
const polar = (r, a) => ({ x: cx + r*Math.cos(a), y: cy + r*Math.sin(a) });
const pointsToStr = (pts) => pts.map(p => `${p.x.toFixed(2)},${p.y.toFixed(2)}`).join(" ");
// Grid rings
let grid = "";
for (let k=1; k<=rings; k++){
const rr = R * (k / rings);
const pts = [];
for (let i=0; i<N; i++){
pts.push(polar(rr, angleFor(i)));
}
grid += `<polygon points="${pointsToStr(pts)}" fill="none" stroke="rgba(255,255,255,.10)" stroke-width="1" />`;
}
// Axes + labels
let axes = "";
for (let i=0; i<N; i++){
const a = angleFor(i);
const p0 = polar(0, a);
const p1 = polar(R, a);
axes += `<line x1="${p0.x}" y1="${p0.y}" x2="${p1.x}" y2="${p1.y}" stroke="rgba(255,255,255,.10)" stroke-width="1" />`;
const c = state.criteria[i];
const w = Math.max(0, +c.weight || 0);
const label = `${c.name} (${w})`;
// label position slightly beyond edge
const pl = polar(R + 14, a);
const anchor = (Math.cos(a) > 0.25) ? "start" : (Math.cos(a) < -0.25 ? "end" : "middle");
const dy = (Math.sin(a) > 0.25) ? 10 : (Math.sin(a) < -0.25 ? -6 : 4);
axes += `
<text x="${pl.x}" y="${pl.y + dy}" fill="rgba(232,238,252,.78)"
font-size="11" text-anchor="${anchor}">
${escapeHtml(label)}
</text>
`;
}
// Polygons for each option
let polys = "";
state.options.forEach((o, idx) => {
const col = palette[idx % palette.length];
const pts = [];
for (let i=0; i<N; i++){
const c = state.criteria[i];
const s = clamp(+state.scores[c.id][o.id] || 0, 0, 10);
const rr = R * (s / 10);
pts.push(polar(rr, angleFor(i)));
}
polys += `
<polygon points="${pointsToStr(pts)}"
fill="${col.fill}" stroke="${col.stroke}" stroke-width="2" />
${pts.map(p => `<circle cx="${p.x}" cy="${p.y}" r="2.6" fill="${col.stroke}" />`).join("")}
`;
});
// Center label
const center = `<circle cx="${cx}" cy="${cy}" r="2.5" fill="rgba(255,255,255,.35)" />`;
const svg = `
<svg viewBox="0 0 ${size} ${size}" role="img" aria-label="Radar chart of option scores across criteria">
<rect x="0" y="0" width="${size}" height="${size}" fill="transparent" />
${grid}
${axes}
${polys}
${center}
<text x="${size-10}" y="${size-10}" text-anchor="end"
fill="rgba(255,255,255,.35)" font-size="10" font-family="var(--mono)">
scale: 0–10
</text>
</svg>
`;
els.radarHolder.innerHTML = svg;
}
function exportJSON(){
normalizeState();
const payload = {
options: state.options.map(o => ({ id:o.id, name:o.name })),
criteria: state.criteria.map(c => ({ id:c.id, name:c.name, weight: +c.weight || 0 })),
scores: state.scores
};
els.jsonBox.value = JSON.stringify(payload, null, 2);
setJsonMsg("Exported current state to JSON.", "ok");
}
function importJSON(){
const txt = (els.jsonBox.value || "").trim();
if (!txt){
setJsonMsg("Paste JSON first.", "warn");
return;
}
try{
const obj = JSON.parse(txt);
// Basic validation
if (!obj || !Array.isArray(obj.options) || !Array.isArray(obj.criteria) || typeof obj.scores !== "object"){
throw new Error("Invalid shape. Expected { options:[], criteria:[], scores:{} }");
}
const options = obj.options.map(o => ({
id: String(o.id || uid()),
name: String(o.name || "Option")
}));
const criteria = obj.criteria.map(c => ({
id: String(c.id || uid()),
name: String(c.name || "Criterion"),
weight: Math.max(0, +c.weight || 0)
}));
const scores = {};
for (const c of criteria){
scores[c.id] = {};
for (const o of options){
const raw = obj.scores?.[c.id]?.[o.id];
scores[c.id][o.id] = clamp(+raw || 0, 0, 10);
}
}
state = { options, criteria, scores };
setJsonMsg("Imported JSON successfully.", "ok");
render();
}catch(err){
setJsonMsg("Import failed: " + err.message, "bad");
}
}
function clearJSON(){
els.jsonBox.value = "";
setJsonMsg("Cleared JSON box.", "ok");
}
function setJsonMsg(msg, kind){
const color = kind === "ok" ? "rgba(72,213,151,.9)"
: kind === "warn" ? "rgba(255,204,102,.9)"
: "rgba(255,107,107,.9)";
els.jsonMsg.style.color = color;
els.jsonMsg.textContent = msg;
}
function addCriterion(){
const c = { id: uid(), name: "New criterion", weight: 1 };
state.criteria.push(c);
state.scores[c.id] = {};
for (const o of state.options){
state.scores[c.id][o.id] = 5;
}
render();
}
function addOption(){
const o = { id: uid(), name: "New option" };
state.options.push(o);
for (const c of state.criteria){
if (!state.scores[c.id]) state.scores[c.id] = {};
state.scores[c.id][o.id] = 5;
}
render();
}
function resetExample(){
state = makeExampleState();
setJsonMsg("Reset to example data.", "ok");
render();
}
// Persistence (optional, nice UX)
const LS_KEY = "weightedDecisionTool_v1";
function saveToLocalStorage(){
try{
normalizeState();
localStorage.setItem(LS_KEY, JSON.stringify(state));
}catch(e){ /* ignore */ }
}
function loadFromLocalStorage(){
try{
const raw = localStorage.getItem(LS_KEY);
if (!raw) return false;
const obj = JSON.parse(raw);
if (!obj || !Array.isArray(obj.options) || !Array.isArray(obj.criteria) || typeof obj.scores !== "object") return false;
state = obj;
normalizeState();
return true;
}catch(e){
return false;
}
}
function escapeHtml(str){
return String(str ?? "")
.replaceAll("&","&amp;")
.replaceAll("<","&lt;")
.replaceAll(">","&gt;")
.replaceAll('"',"&quot;")
.replaceAll("'","&#039;");
}
// Wire top buttons
btns.addCriterion.addEventListener("click", addCriterion);
btns.addOption.addEventListener("click", addOption);
btns.reset.addEventListener("click", resetExample);
btns.export.addEventListener("click", exportJSON);
btns.import.addEventListener("click", importJSON);
btns.clear.addEventListener("click", clearJSON);
// Boot
loadFromLocalStorage(); // if it fails, we keep example state
render();
})();
</script>
</body>
</html>