initial commit

This commit is contained in:
2026-06-25 21:26:53 +00:00
commit e5a1511098
68 changed files with 186669 additions and 0 deletions
Executable
+324
View File
@@ -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();
})();