/* eslint-disable no-console */ (function () { 'use strict'; // ===== DOM ===== const tbody = document.getElementById('ammoTbody'); const searchEl = document.getElementById('ammoSearch'); const caliberEl = document.getElementById('caliberSelect'); const minPenEl = document.getElementById('minPen'); const sortEl = document.getElementById('sortSelect'); // toolbar dropdown (kept) const clearBtn = document.getElementById('clearFilters'); const updatedBadge = document.getElementById('ammoUpdated'); const statusEl = document.getElementById('ammoStatus'); const thead = document.querySelector('#ammoTable thead'); // ===== State ===== let ammo = []; // normalized rows let calibers = []; // distinct list let lastUpdated = ''; // Default: Name ascending const sortState = { key: 'name', dir: 'asc' }; // Column mapping (by header index) -> sort key // 0 Ammo, 1 Caliber, 2 Pen, 3 Dmg, 4 Armor Dmg %, 5 Frag %, 6 Acc %, 7 Recoil %, 8 Vel, 9 Flags const headerKeys = [ 'name', // 0 'caliber', // 1 'pen', // 2 'dmg', // 3 'armorDmgVal', // 4 (numeric) 'fragVal', // 5 (numeric) 'accVal', // 6 (numeric) 'recoilVal', // 7 (numeric) 'vel', // 8 null // 9 (Flags: not sortable) ]; // ===== Helpers ===== function guessCaliberFromName(name = '') { // e.g., "5.45x39mm BP gzh" -> "5.45x39mm" const m = name.match(/^([0-9.]+x[0-9.]+mm|[.][0-9]+[\w.]*)/i); return m ? m[1] : ''; } function toPct(x) { if (x === null || x === undefined) return null; return Math.round(x * 100); } function fmtSignedPct(x) { if (x === null || x === undefined) return ''; const v = Math.round(x * 100); return (v > 0 ? `+${v}` : `${v}`) + '%'; } function asArmorPct(raw) { // API often returns 0..100; if we see 0..1, convert to % if (raw === null || raw === undefined) return null; return raw > 1 ? Math.round(raw) : Math.round(raw * 100); } function cmpValues(a, b) { if (a == null && b == null) return 0; if (a == null) return -1; if (b == null) return 1; if (typeof a === 'number' && typeof b === 'number') return a - b; return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' }); } // ===== Normalization ===== /** * Expected shape of /ammo-data.json: * { fetchedAt, source, items: [ { id, name, shortName, iconLink, wikiLink, updated, properties: { __typename:"ItemPropertiesAmmo", ... } } ] } */ function normalize(raw) { const items = Array.isArray(raw.items) ? raw.items : []; const rows = items .filter((it) => it && it.properties && it.properties.__typename === 'ItemPropertiesAmmo') .map((it) => { const p = it.properties || {}; const caliber = p.caliber || guessCaliberFromName(it.name); const pen = p.penetrationPower ?? null; const dmg = p.damage ?? null; const armorDmgVal = asArmorPct(p.armorDamage); // numeric for sorting const armorDmgPct = armorDmgVal; // display const fragVal = p.fragmentationChance != null ? toPct(p.fragmentationChance) : null; const fragPct = fragVal; // display const accVal = p.accuracyModifier != null ? Math.round(p.accuracyModifier * 100) : null; const accPct = p.accuracyModifier != null ? fmtSignedPct(p.accuracyModifier) : ''; const recoilVal = p.recoilModifier != null ? Math.round(p.recoilModifier * 100) : null; const recoilPct = p.recoilModifier != null ? fmtSignedPct(p.recoilModifier) : ''; const vel = p.initialSpeed ?? null; return { id: it.id, name: it.name, short: it.shortName || it.name, icon: it.iconLink, wiki: it.wikiLink, updated: it.updated || '', caliber, pen, dmg, armorDmgVal, armorDmgPct, fragVal, fragPct, accVal, accPct, recoilVal, recoilPct, vel, tracer: p.tracer ? (p.tracerColor || 'Tracer') : '', bleedLight: p.lightBleedModifier != null ? toPct(p.lightBleedModifier) : null, bleedHeavy: p.heavyBleedModifier != null ? toPct(p.heavyBleedModifier) : null, proj: p.projectileCount ?? 1 }; }); const cals = Array.from(new Set(rows.map((r) => r.caliber).filter(Boolean))) .sort((a, b) => a.localeCompare(b)); lastUpdated = raw.fetchedAt || ''; return { rows, calibers: cals }; } // ===== Rendering ===== function renderCalibers(list) { caliberEl.innerHTML = '' + list.map((c) => ``).join(''); } function renderTable(rows) { if (!rows.length) { tbody.innerHTML = `No results. Try clearing filters.`; return; } tbody.innerHTML = rows.map((r) => { const flags = [r.tracer].filter(Boolean) .map((s) => `${s}`).join(' '); return `
${r.icon ? `` : ''} ${r.wiki ? `${r.name}` : `${r.name}` }
${r.caliber || ''} ${r.pen ?? ''} ${r.dmg ?? ''} ${r.armorDmgPct ?? ''} ${r.fragPct ?? ''} ${r.accPct} ${r.recoilPct} ${r.vel ?? ''} ${flags} `; }).join(''); } // ===== Sorting ===== function sortRows(rows) { const key = sortState.key; const dir = sortState.dir === 'asc' ? 1 : -1; return rows.slice().sort((a, b) => dir * cmpValues(a[key], b[key])); } function setSort(key, dir) { sortState.key = key; sortState.dir = dir; syncSortDropdown(); applyFilters(); // re-renders } function toggleSortForKey(key) { if (sortState.key === key) { setSort(key, sortState.dir === 'asc' ? 'desc' : 'asc'); } else { // Default direction: strings asc, numbers desc const stringKeys = new Set(['name', 'caliber']); setSort(key, stringKeys.has(key) ? 'asc' : 'desc'); } } function syncSortDropdown() { // Map state -> dropdown value const map = { 'name:asc': 'name-asc', 'pen:desc': 'pen-desc', 'dmg:desc': 'dmg-desc', 'vel:desc': 'vel-desc' }; // If key not in dropdown, leave as-is (user can still change via header) const k = `${sortState.key}:${sortState.dir}`; if (map[k]) sortEl.value = map[k]; // Update header indicators updateHeaderIndicators(); } function updateHeaderIndicators() { const ths = Array.from(thead.querySelectorAll('th')); ths.forEach((th, i) => { const key = headerKeys[i]; th.classList.remove('is-sorted-asc', 'is-sorted-desc'); th.setAttribute('aria-sort', 'none'); if (!key) return; th.classList.add('is-sortable'); if (key === sortState.key) { const cls = sortState.dir === 'asc' ? 'is-sorted-asc' : 'is-sorted-desc'; th.classList.add(cls); th.setAttribute('aria-sort', sortState.dir === 'asc' ? 'ascending' : 'descending'); } }); } function wireHeaderSorting() { const ths = Array.from(thead.querySelectorAll('th')); ths.forEach((th, i) => { const key = headerKeys[i]; if (!key) return; // skip Flags th.classList.add('is-sortable'); th.title = 'Click to sort'; th.addEventListener('click', () => toggleSortForKey(key)); th.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleSortForKey(key); } }); th.tabIndex = 0; // keyboard focusable }); updateHeaderIndicators(); } // ===== Filtering + render pipeline ===== function applyFilters() { const q = (searchEl.value || '').trim().toLowerCase(); const cal = (caliberEl.value || '').trim(); const minPen = parseInt(minPenEl.value || '0', 10); let out = ammo.slice(); if (q) { out = out.filter( (r) => r.name.toLowerCase().includes(q) || r.short.toLowerCase().includes(q) ); } if (cal) out = out.filter((r) => r.caliber === cal); if (!Number.isNaN(minPen) && minPen > 0) out = out.filter((r) => (r.pen ?? 0) >= minPen); out = sortRows(out); renderTable(out); statusEl.textContent = `${out.length} of ${ammo.length} rounds shown`; } function wireUI() { searchEl.addEventListener('input', applyFilters); caliberEl.addEventListener('change', applyFilters); minPenEl.addEventListener('input', applyFilters); // Keep dropdown sorter, but make default "name-asc" sortEl.value = 'name-asc'; sortEl.addEventListener('change', () => { switch (sortEl.value) { case 'name-asc': setSort('name', 'asc'); break; case 'pen-desc': setSort('pen', 'desc'); break; case 'dmg-desc': setSort('dmg', 'desc'); break; case 'vel-desc': setSort('vel', 'desc'); break; default: setSort('name', 'asc'); break; } }); clearBtn.addEventListener('click', () => { searchEl.value = ''; caliberEl.value = ''; minPenEl.value = ''; sortEl.value = 'name-asc'; setSort('name', 'asc'); }); } async function loadLocal() { const res = await fetch('/ammo-data.json', { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to load /ammo-data.json (HTTP ${res.status})`); return res.json(); } async function init() { try { updatedBadge.textContent = 'Loading…'; const data = await loadLocal(); const { rows, calibers: cals } = normalize(data); ammo = rows; calibers = cals; renderCalibers(calibers); wireHeaderSorting(); applyFilters(); updatedBadge.textContent = `Updated: ${lastUpdated || '—'}`; } catch (err) { console.error(err); tbody.innerHTML = `Failed to load ammo-data.json (${String(err)}).`; updatedBadge.textContent = 'Update failed'; } } wireUI(); // Ensure default dropdown reflects our default sort sortEl.value = 'name-asc'; init(); })();