initial commit
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
|
||||
/* 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();
|
||||
})();
|
||||
Reference in New Issue
Block a user