Spaces:
Running
Running
| <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("&","&") | |
| .replaceAll("<","<") | |
| .replaceAll(">",">") | |
| .replaceAll('"',""") | |
| .replaceAll("'","'"); | |
| } | |
| // 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> | |