Files
root 38bffa491c Initial commit: WH40K Points Comparator
- React + MUI DataGrid app with faction filter, search, change filter
- Biggest movers cards (drops/rises) scoped to current filter view
- Historical points graph modal (5 MFM versions: 1.14 → current)
- URL state sync (faction, dir, q params — shareable URLs)
- Grimdark favicon + OG embed image (Google Imagen)
- Multi-stage Dockerfile (node build → nginx serve)
- docker-compose.yml with Traefik + Cloudflare TLS
- Data pipeline: build_deduped_data.py merges PDF + live scrape
- Ynnari merged into Aeldari (shared codex)
- Mobile responsive: flex columns, no fixed pixel widths
- Color semantics: green=cheaper, red=costlier (consistent everywhere)
- 1,449 units across 31 factions
2026-06-18 02:42:29 +00:00

361 lines
14 KiB
JavaScript

// WH40K Points Comparator — clean rewrite
// Event delegation for all interactive elements. No per-render listener attachment.
const $ = (id) => document.getElementById(id);
const state = {
data: null,
view: [],
query: "",
faction: "",
dir: "",
sort: "change_pct_desc",
mode: "table",
sizeChoice: new Map(), // key: "faction|name" → size label
};
// ─── boot ────────────────────────────────────────────
async function boot() {
const res = await fetch("data.json");
if (!res.ok) throw new Error(`Failed to load data.json: ${res.status}`);
state.data = await res.json();
// Populate faction dropdown
const sel = $("faction");
for (const slug of state.data.factions) {
const opt = document.createElement("option");
opt.value = slug;
opt.textContent = state.data.faction_names[slug] || slug;
sel.appendChild(opt);
}
// Header meta
$("meta").textContent =
`${state.data.stats.total_rows} units · ` +
`${state.data.stats.rows_with_both} with both old & new · ` +
`generated ${state.data.generated_at.slice(0, 10)}`;
renderMovers();
applyFilters();
// ── Wire up controls (these never get re-created) ──
$("q").addEventListener("input", (e) => { state.query = e.target.value; applyFilters(); });
$("faction").addEventListener("change", (e) => { state.faction = e.target.value; applyFilters(); });
$("dir").addEventListener("change", (e) => { state.dir = e.target.value; applyFilters(); });
$("sort").addEventListener("change", (e) => { state.sort = e.target.value; applyFilters(); });
$("view").addEventListener("change", (e) => { state.mode = e.target.value; render(); });
// ── Event delegation for #rows (handles all future renders) ──
$("rows").addEventListener("click", (e) => {
// Size toggle button?
const sizeBtn = e.target.closest(".size-btn");
if (sizeBtn) {
e.stopPropagation();
const u = state.data.units.find(x => x.faction === sizeBtn.dataset.fac && x.name === sizeBtn.dataset.name);
if (u) cycleSize(u);
return;
}
// Table header sort?
const th = e.target.closest("th[data-sort]");
if (th) {
const map = { faction: "faction", name: "name", size: "size",
old: "original", new: "new", change_pts: "change_pts", change_pct: "change_pct" };
const k = map[th.dataset.sort];
if (!k) return;
state.sort = state.sort.startsWith(k)
? `${k}_${state.sort.endsWith("_desc") ? "asc" : "desc"}`
: `${k}_desc`;
// Sync the sort dropdown
$("sort").value = state.sort;
applyFilters();
return;
}
// Row click → modal
const row = e.target.closest("[data-fac][data-name]");
if (row && !e.target.closest(".size-btn")) {
openModal(row.dataset.fac, row.dataset.name);
}
});
// ── Event delegation for movers ──
$("movers").addEventListener("click", (e) => {
const mover = e.target.closest(".mover-row");
if (!mover) return;
$("q").value = mover.dataset.name;
state.query = mover.dataset.name;
applyFilters();
$("q").focus();
});
// ── Measure controls height for sticky offset ──
updateStickyTop();
window.addEventListener("resize", updateStickyTop);
}
function updateStickyTop() {
const el = $("controls-wrap");
if (el) {
const h = el.getBoundingClientRect().height;
document.documentElement.style.setProperty("--sticky-top", h + "px");
}
}
// ─── size variant helpers ────────────────────────────
function unitKey(u) { return `${u.faction}|${u.name}`; }
function getActiveSize(u) {
const chosen = state.sizeChoice.get(unitKey(u));
if (chosen) {
const found = u.sizes.find(s => s.size === chosen);
if (found) return found;
}
return u.sizes.find(s => s.size === u.default_size) || u.sizes[0];
}
function cycleSize(u) {
const cur = getActiveSize(u).size;
const idx = u.sizes.findIndex(s => s.size === cur);
const next = u.sizes[(idx + 1) % u.sizes.length];
state.sizeChoice.set(unitKey(u), next.size);
applyFilters();
}
// ─── movers ──────────────────────────────────────────
function renderMovers() {
const aug = state.data.units.map(u => {
const a = getActiveSize(u);
return { ...u, size: a.size, original: a.original, new: a.new, change_pct: a.change_pct, change_pts: a.change_pts };
});
const drops = aug.filter(u => u.change_pct !== null && u.change_pct < 0)
.sort((a, b) => a.change_pct - b.change_pct).slice(0, 5);
const rises = aug.filter(u => u.change_pct !== null && u.change_pct > 0)
.sort((a, b) => b.change_pct - a.change_pct).slice(0, 5);
$("top-drops").innerHTML = drops.map(u => moverRow(u, "down")).join("");
$("top-rises").innerHTML = rises.map(u => moverRow(u, "up")).join("");
$("movers").hidden = false;
}
function moverRow(u, kind) {
const arrow = kind === "up" ? "↑" : "↓";
const cls = kind === "up" ? "rise" : "drop";
const pct = u.change_pct.toFixed(1);
return `
<div class="mover-row ${cls}" data-name="${escapeAttr(u.name)}">
<div class="mover-info">
<div class="mover-name">${escapeHtml(u.name)}</div>
<div class="mover-meta">${escapeHtml(u.faction_name)} · ${escapeHtml(u.size)}</div>
</div>
<div class="mover-costs">${u.original ?? "—"}${u.new ?? "—"}</div>
<div class="mover-delta ${cls}">${arrow} ${pct}%</div>
</div>
`;
}
// ─── filtering / sorting ─────────────────────────────
function applyFilters() {
const q = state.query.trim().toLowerCase();
const fac = state.faction;
const dir = state.dir;
let view = state.data.units.map(u => {
const a = getActiveSize(u);
return { ...u, size: a.size, original: a.original, new: a.new,
change_pct: a.change_pct, change_pts: a.change_pts };
});
if (q) {
view = view.filter(u =>
u.name.toLowerCase().includes(q) ||
u.faction_name.toLowerCase().includes(q) ||
u.size.toLowerCase().includes(q)
);
}
if (fac) view = view.filter(u => u.faction === fac);
if (dir === "up") view = view.filter(u => u.change_pct !== null && u.change_pct > 0);
else if (dir === "down") view = view.filter(u => u.change_pct !== null && u.change_pct < 0);
else if (dir === "no-change") view = view.filter(u => u.change_pct === 0);
else if (dir === "new-only") view = view.filter(u => u.original === null && u.new !== null);
else if (dir === "old-only") view = view.filter(u => u.original !== null && u.new === null);
const [k, d] = state.sort.endsWith("_asc") ? [state.sort.replace("_asc", ""), 1] :
state.sort.endsWith("_desc") ? [state.sort.replace("_desc", ""), -1] :
[state.sort, 1];
view.sort((a, b) => {
let x = a[k], y = b[k];
if (x === null) return 1;
if (y === null) return -1;
if (typeof x === "string") return d * x.localeCompare(y);
return d * (x - y);
});
state.view = view;
render();
}
// ─── rendering ────────────────────────────────────────
function render() {
const view = state.view;
$("count").innerHTML = `Showing <b>${view.length.toLocaleString()}</b> of ${state.data.stats.total_rows.toLocaleString()} units`;
if (view.length === 0) {
$("rows").innerHTML = `<div class="empty">No matches — try clearing filters.</div>`;
return;
}
if (state.mode === "compact") {
$("rows").innerHTML = view.map(compactRow).join("");
} else {
$("rows").innerHTML = `
<table class="row-table">
<thead>
<tr>
<th data-sort="faction">Faction</th>
<th data-sort="name">Unit</th>
<th data-sort="size">Size</th>
<th class="num" data-sort="old">Original</th>
<th class="num" data-sort="new">New</th>
<th class="num" data-sort="change_pts">Δ pts</th>
<th class="num" data-sort="change_pct">Δ %</th>
<th>Status</th>
</tr>
</thead>
<tbody>${view.map(tableRow).join("")}</tbody>
</table>
`;
}
}
function sizeControl(u) {
if (u.sizes.length <= 1) {
return `<span class="size-static">${escapeHtml(u.size)}</span>`;
}
if (u.equal_costs) {
const labels = u.sizes.map(s => s.size.replace(/\s*models?$/, "")).join(" or ");
return `<span class="size-static" title="Same cost at all sizes">${escapeHtml(labels)} models</span>`;
}
const active = u.size;
const sizeShort = active.replace(/\s*models?$/, "");
const count = u.sizes.length;
return `<button class="size-btn" data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}" title="Click to cycle (${count} sizes)">${escapeHtml(sizeShort)}<span class="size-btn-badge">${count}</span></button>`;
}
function tableRow(u) {
const orig = u.original ?? "—";
const newp = u.new ?? "—";
const dp = u.change_pts === null ? "—" : (u.change_pts > 0 ? `+${u.change_pts}` : u.change_pts);
const pc = u.change_pct === null ? "—" : `${u.change_pct > 0 ? "+" : ""}${u.change_pct.toFixed(1)}%`;
let pill = `<span class="pill none">—</span>`;
if (u.change_pct !== null) {
if (u.change_pct > 0) pill = `<span class="pill up">↑ ${u.change_pct.toFixed(1)}%</span>`;
else if (u.change_pct < 0) pill = `<span class="pill down">↓ ${Math.abs(u.change_pct).toFixed(1)}%</span>`;
else pill = `<span class="pill same">— 0%</span>`;
} else if (u.new !== null && u.original === null) {
pill = `<span class="pill new">NEW</span>`;
} else if (u.new === null && u.original !== null) {
pill = `<span class="pill gone">REMOVED</span>`;
}
return `
<tr data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}">
<td class="col-faction" data-label="Faction">${escapeHtml(u.faction_name)}</td>
<td class="col-unit" data-label="Unit">${escapeHtml(u.name)}</td>
<td class="col-size" data-label="Size">${sizeControl(u)}</td>
<td class="num" data-label="Original">${orig}</td>
<td class="num" data-label="New">${newp}</td>
<td class="num" data-label="Δ pts">${dp}</td>
<td class="num" data-label="Δ %">${pc}</td>
<td class="col-status" data-label="Status">${pill}</td>
</tr>
`;
}
function compactRow(u) {
const orig = u.original ?? "—";
const newp = u.new ?? "—";
const pc = u.change_pct;
const pcStr = pc === null ? "—" :
pc > 0 ? `<span class="pill up">↑ ${pc.toFixed(1)}%</span>` :
pc < 0 ? `<span class="pill down">↓ ${Math.abs(pc).toFixed(1)}%</span>` :
`<span class="pill same">— 0%</span>`;
return `
<div class="compact-row" data-fac="${escapeAttr(u.faction)}" data-name="${escapeAttr(u.name)}">
<div class="compact-main">
<div class="compact-name">${escapeHtml(u.name)}</div>
<div class="compact-sub">${escapeHtml(u.faction_name)} · ${sizeControl(u)}</div>
</div>
<div class="compact-nums">
<span class="compact-orig">${orig}</span>
<span class="compact-arrow">→</span>
<span class="compact-new">${newp}</span>
<span class="compact-pct">${pcStr}</span>
</div>
</div>
`;
}
// ─── modal ────────────────────────────────────────────
function openModal(fac, name) {
const u = state.data.units.find(x => x.faction === fac && x.name === name);
if (!u) return;
const factionName = state.data.faction_names[fac] || fac;
const activeSize = getActiveSize(u).size;
const backdrop = document.createElement("div");
backdrop.className = "modal-backdrop";
backdrop.innerHTML = `
<div class="modal">
<button class="modal-close" type="button" aria-label="Close">✕</button>
<div class="modal-title">${escapeHtml(name)}</div>
<div class="modal-faction">${escapeHtml(factionName)}</div>
<div class="modal-sizes-label">${u.sizes.length} size variant${u.sizes.length !== 1 ? 's' : ''}</div>
<div class="modal-variants">
${u.sizes.map(s => {
const active = activeSize === s.size;
return `
<div class="variant-row ${active ? 'active' : ''}">
<div class="variant-size">${escapeHtml(s.size)}${active ? ' <span class="variant-current">● current</span>' : ''}</div>
<div class="variant-nums">
<span class="variant-orig">${s.original ?? "—"}</span>
<span class="variant-arrow">→</span>
<span class="variant-new">${s.new ?? "—"}</span>
</div>
<div class="variant-delta">
${s.change_pct === null ? "—" :
s.change_pct > 0 ? `<span class="pill up">↑ ${s.change_pct.toFixed(1)}%</span>` :
s.change_pct < 0 ? `<span class="pill down">↓ ${Math.abs(s.change_pct).toFixed(1)}%</span>` :
`<span class="pill same">— 0%</span>`}
</div>
</div>
`;
}).join("")}
</div>
</div>
`;
document.body.appendChild(backdrop);
const close = () => backdrop.remove();
backdrop.addEventListener("click", (e) => { if (e.target === backdrop) close(); });
backdrop.querySelector(".modal-close").addEventListener("click", close);
document.addEventListener("keydown", function esc(e) {
if (e.key === "Escape") { close(); document.removeEventListener("keydown", esc); }
});
}
// ─── utils ────────────────────────────────────────────
function escapeHtml(s) {
if (s == null) return "";
return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, "&#39;"); }
boot().catch(err => {
document.body.innerHTML = `<pre style="color:#f85149;padding:20px;">${err.message}</pre>`;
});