325 lines
11 KiB
JavaScript
Executable File
325 lines
11 KiB
JavaScript
Executable File
|
|
/* 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 =
|
|
'<option value="">All calibers</option>' +
|
|
list.map((c) => `<option value="${c}">${c}</option>`).join('');
|
|
}
|
|
|
|
function renderTable(rows) {
|
|
if (!rows.length) {
|
|
tbody.innerHTML = `<tr><td colspan="10" class="muted">No results. Try clearing filters.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = rows.map((r) => {
|
|
const flags = [r.tracer].filter(Boolean)
|
|
.map((s) => `<span class="chip">${s}</span>`).join(' ');
|
|
|
|
return `
|
|
<tr>
|
|
<td data-label="Ammo">
|
|
<div class="ammo-name">
|
|
${r.icon ? `<img class="ammo-icon" src="${r.icon}" alt="">` : ''}
|
|
${r.wiki
|
|
? `<a href="${r.wiki}" target="_blank" rel="noopener">${r.name}</a>`
|
|
: `${r.name}`
|
|
}
|
|
</div>
|
|
</td>
|
|
<td data-label="Caliber">${r.caliber || ''}</td>
|
|
<td class="right" data-label="Pen">${r.pen ?? ''}</td>
|
|
<td class="right" data-label="Dmg">${r.dmg ?? ''}</td>
|
|
<td class="right" data-label="Armor Dmg %">${r.armorDmgPct ?? ''}</td>
|
|
<td class="right" data-label="Frag %">${r.fragPct ?? ''}</td>
|
|
<td class="right" data-label="Acc %">${r.accPct}</td>
|
|
<td class="right" data-label="Recoil %">${r.recoilPct}</td>
|
|
<td class="right" data-label="Vel (m/s)">${r.vel ?? ''}</td>
|
|
<td data-label="Flags">${flags}</td>
|
|
</tr>
|
|
`;
|
|
}).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 =
|
|
`<tr><td colspan="10" class="muted">Failed to load ammo-data.json (${String(err)}).</td></tr>`;
|
|
updatedBadge.textContent = 'Update failed';
|
|
}
|
|
}
|
|
|
|
wireUI();
|
|
// Ensure default dropdown reflects our default sort
|
|
sortEl.value = 'name-asc';
|
|
init();
|
|
})();
|