- 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
361 lines
14 KiB
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
function escapeAttr(s) { return escapeHtml(s).replace(/'/g, "'"); }
|
|
|
|
boot().catch(err => {
|
|
document.body.innerHTML = `<pre style="color:#f85149;padding:20px;">${err.message}</pre>`;
|
|
}); |