ugh
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const qs = (sel) => document.querySelector(sel);
|
||||
|
||||
const DIFFICULTY_ORDER = {
|
||||
Normal: 0,
|
||||
Hard: 1,
|
||||
"Very Hard": 2,
|
||||
Ultimate: 3,
|
||||
};
|
||||
|
||||
const NUMERIC_COLUMNS = new Set([
|
||||
"hp", "atp", "dfp", "mst", "ata", "evp", "lck", "esp", "exp",
|
||||
"efr", "eic", "eth", "elt", "edk",
|
||||
]);
|
||||
|
||||
const BP_TIERS = {
|
||||
0: { hp: 1.00, atp: 1.00, exp: 1.00 },
|
||||
1: { hp: 1.10, atp: 1.01, exp: 1.10 },
|
||||
2: { hp: 1.15, atp: 1.02, exp: 1.15 },
|
||||
3: { hp: 1.20, atp: 1.03, exp: 1.20 },
|
||||
4: { hp: 1.30, atp: 1.04, exp: 1.30 },
|
||||
5: { hp: 1.40, atp: 1.05, exp: 1.40 },
|
||||
6: { hp: 1.50, atp: 1.06, exp: 1.50 },
|
||||
7: { hp: 1.75, atp: 1.07, exp: 1.75 },
|
||||
8: { hp: 2.00, atp: 1.08, exp: 2.00 },
|
||||
9: { hp: 2.50, atp: 1.09, exp: 2.50 },
|
||||
10: { hp: 3.00, atp: 1.10, exp: 3.00 },
|
||||
11: { hp: 4.00, atp: 1.10, exp: null },
|
||||
};
|
||||
|
||||
const COLUMN_GROUPS = {
|
||||
stats: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["hp", "HP"],
|
||||
["atp", "ATP"],
|
||||
["dfp", "DFP"],
|
||||
["ata", "ATA"],
|
||||
["evp", "EVP"],
|
||||
["lck", "LCK"],
|
||||
["exp", "EXP"],
|
||||
],
|
||||
resists: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["efr", "EFR"],
|
||||
["eic", "EIC"],
|
||||
["eth", "ETH"],
|
||||
["elt", "ELT"],
|
||||
["edk", "EDK"],
|
||||
["esp", "ESP"],
|
||||
],
|
||||
all: [
|
||||
["enemy", "Enemy"],
|
||||
["mode", "Mode"],
|
||||
["episode", "Episode"],
|
||||
["difficulty", "Difficulty"],
|
||||
["hp", "HP"],
|
||||
["atp", "ATP"],
|
||||
["dfp", "DFP"],
|
||||
["mst", "MST"],
|
||||
["ata", "ATA"],
|
||||
["evp", "EVP"],
|
||||
["lck", "LCK"],
|
||||
["esp", "ESP"],
|
||||
["exp", "EXP"],
|
||||
["efr", "EFR"],
|
||||
["eic", "EIC"],
|
||||
["eth", "ETH"],
|
||||
["elt", "ELT"],
|
||||
["edk", "EDK"],
|
||||
],
|
||||
};
|
||||
|
||||
function currentColumns() {
|
||||
const view = qs("#bestiary-view")?.value || "stats";
|
||||
return COLUMN_GROUPS[view] || COLUMN_GROUPS.stats;
|
||||
}
|
||||
|
||||
const state = {
|
||||
rows: [],
|
||||
index: null,
|
||||
table: null,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
sort: {
|
||||
key: "enemy",
|
||||
dir: "asc",
|
||||
},
|
||||
filters: {
|
||||
mode: "",
|
||||
episode: "",
|
||||
difficulty: "",
|
||||
search: "",
|
||||
},
|
||||
};
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
async function fetchJson(path) {
|
||||
const res = await fetch(path, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`${path}: HTTP ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function setStatus(message, kind = "") {
|
||||
const box = qs("#bestiary-placeholder");
|
||||
if (!box) return;
|
||||
box.innerHTML = `<div class="drops-status ${kind ? `drops-status--${kind}` : ""}">${esc(message)}</div>`;
|
||||
}
|
||||
|
||||
function uniqueSorted(rows, key) {
|
||||
return [...new Set(rows.map((row) => row[key]).filter(Boolean))]
|
||||
.sort((a, b) => String(a).localeCompare(String(b), undefined, { numeric: true }));
|
||||
}
|
||||
|
||||
function fillSelect(select, values, allLabel) {
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
select.innerHTML = `<option value="">${esc(allLabel)}</option>`;
|
||||
for (const value of values) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = value;
|
||||
opt.textContent = value;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
if ([...select.options].some((opt) => opt.value === previous)) {
|
||||
select.value = previous;
|
||||
}
|
||||
}
|
||||
|
||||
function orderedDifficulties(rows) {
|
||||
const present = new Set(rows.map((row) => row.difficulty).filter(Boolean));
|
||||
return Object.keys(DIFFICULTY_ORDER).filter((value) => present.has(value));
|
||||
}
|
||||
|
||||
function bpTier() {
|
||||
const tier = Number(qs("#bestiary-bp-tier")?.value || 0);
|
||||
return BP_TIERS[tier] || BP_TIERS[0];
|
||||
}
|
||||
|
||||
function displayValue(row, key) {
|
||||
const value = row[key];
|
||||
|
||||
if (row.difficulty !== "Ultimate") {
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
const tier = bpTier();
|
||||
|
||||
if (key === "hp") {
|
||||
return Math.round(Number(value || 0) * tier.hp);
|
||||
}
|
||||
|
||||
if (key === "atp") {
|
||||
return Math.round(Number(value || 0) * tier.atp);
|
||||
}
|
||||
|
||||
if (key === "exp" && tier.exp !== null) {
|
||||
return Math.round(Number(value || 0) * tier.exp);
|
||||
}
|
||||
|
||||
return value ?? "";
|
||||
}
|
||||
|
||||
function visibleRows() {
|
||||
const search = state.filters.search.trim().toLowerCase();
|
||||
|
||||
return state.rows.filter((row) => {
|
||||
if (state.filters.mode && row.mode !== state.filters.mode) return false;
|
||||
if (state.filters.episode && row.episode !== state.filters.episode) return false;
|
||||
if (state.filters.difficulty && row.difficulty !== state.filters.difficulty) return false;
|
||||
|
||||
if (search) {
|
||||
const haystack = [row.enemy, row.enemy_key, row.mode, row.episode, row.difficulty]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(search)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function sortValue(row, key) {
|
||||
if (key === "difficulty") return DIFFICULTY_ORDER[row.difficulty] ?? 999;
|
||||
if (NUMERIC_COLUMNS.has(key)) return Number(displayValue(row, key) || 0);
|
||||
return row[key] || "";
|
||||
}
|
||||
|
||||
function sortedRows(rows) {
|
||||
const { key, dir } = state.sort;
|
||||
const factor = dir === "desc" ? -1 : 1;
|
||||
|
||||
return [...rows].sort((a, b) => {
|
||||
const av = sortValue(a, key);
|
||||
const bv = sortValue(b, key);
|
||||
|
||||
if (typeof av === "number" && typeof bv === "number") {
|
||||
return (av - bv) * factor;
|
||||
}
|
||||
|
||||
return String(av).localeCompare(String(bv), undefined, {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
}) * factor;
|
||||
});
|
||||
}
|
||||
|
||||
function sortHeader(key, label) {
|
||||
const active = state.sort.key === key;
|
||||
const arrow = active ? (state.sort.dir === "asc" ? "▲" : "▼") : "";
|
||||
const ariaSort = active ? (state.sort.dir === "asc" ? "ascending" : "descending") : "none";
|
||||
|
||||
return `<th aria-sort="${ariaSort}">
|
||||
<button class="drops-sort-button ${active ? "is-active" : ""}" type="button" data-bestiary-sort="${esc(key)}">
|
||||
<span>${esc(label)}</span>
|
||||
<span class="drops-sort-arrow" aria-hidden="true">${arrow}</span>
|
||||
</button>
|
||||
</th>`;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const box = qs("#bestiary-placeholder");
|
||||
if (!box) return;
|
||||
|
||||
const rows = sortedRows(visibleRows());
|
||||
const totalPages = Math.max(1, Math.ceil(rows.length / state.pageSize));
|
||||
state.page = Math.min(Math.max(1, state.page), totalPages);
|
||||
|
||||
const start = (state.page - 1) * state.pageSize;
|
||||
const shown = rows.slice(start, start + state.pageSize);
|
||||
const end = start + shown.length;
|
||||
|
||||
const columns = currentColumns();
|
||||
if (!columns.some(([key]) => key === state.sort.key)) {
|
||||
state.sort.key = "enemy";
|
||||
state.sort.dir = "asc";
|
||||
}
|
||||
|
||||
const body = shown.map((row) => `
|
||||
<tr>
|
||||
${columns.map(([key]) => `<td data-label="${esc(key)}">${esc(displayValue(row, key))}</td>`).join("")}
|
||||
</tr>
|
||||
`).join("");
|
||||
|
||||
const rangeText = rows.length
|
||||
? ` Showing ${Number(start + 1).toLocaleString()}-${Number(end).toLocaleString()}.`
|
||||
: "";
|
||||
|
||||
box.innerHTML = `
|
||||
<div class="drops-summary">
|
||||
<div>
|
||||
<strong>BB Bestiary</strong>
|
||||
<span>${rows.length.toLocaleString()} matching rows.${rangeText}</span>
|
||||
</div>
|
||||
<span>${state.rows.length.toLocaleString()} total rows</span>
|
||||
</div>
|
||||
<div class="drops-table-wrap">
|
||||
<table class="drops-table bestiary-table">
|
||||
<thead>
|
||||
<tr>${columns.map(([key, label]) => sortHeader(key, label)).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>${body || `<tr><td colspan="${columns.length}">No enemies match these filters.</td></tr>`}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="leaderboard-pager drops-pager">
|
||||
<button type="button" data-bestiary-page="prev" ${state.page <= 1 ? "disabled" : ""}>Previous</button>
|
||||
<span>Page ${state.page} of ${totalPages}</span>
|
||||
<button type="button" data-bestiary-page="next" ${state.page >= totalPages ? "disabled" : ""}>Next</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function populateFilters() {
|
||||
fillSelect(qs("#bestiary-mode"), uniqueSorted(state.rows, "mode"), "All modes");
|
||||
fillSelect(qs("#bestiary-episode"), uniqueSorted(state.rows, "episode"), "All episodes");
|
||||
fillSelect(qs("#bestiary-difficulty"), orderedDifficulties(state.rows), "All difficulties");
|
||||
}
|
||||
|
||||
async function loadBestiary() {
|
||||
setStatus("Loading BB bestiary...");
|
||||
|
||||
state.index = await fetchJson("generated/bestiary/bb/index.json");
|
||||
state.table = state.index.tables?.[0] || null;
|
||||
state.rows = await fetchJson(`generated/bestiary/bb/${state.table.path}`);
|
||||
|
||||
populateFilters();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
qs("#bestiary-mode")?.addEventListener("change", (event) => {
|
||||
state.filters.mode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-episode")?.addEventListener("change", (event) => {
|
||||
state.filters.episode = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-difficulty")?.addEventListener("change", (event) => {
|
||||
state.filters.difficulty = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-view")?.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-search")?.addEventListener("input", (event) => {
|
||||
state.filters.search = event.target.value;
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-bp-tier")?.addEventListener("change", () => {
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
qs("#bestiary-placeholder")?.addEventListener("click", (event) => {
|
||||
const pageButton = event.target.closest("[data-bestiary-page]");
|
||||
if (pageButton) {
|
||||
state.page += pageButton.dataset.bestiaryPage === "next" ? 1 : -1;
|
||||
renderTable();
|
||||
return;
|
||||
}
|
||||
|
||||
const button = event.target.closest("[data-bestiary-sort]");
|
||||
if (!button) return;
|
||||
|
||||
const key = button.dataset.bestiarySort;
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc";
|
||||
} else {
|
||||
state.sort.key = key;
|
||||
state.sort.dir = "asc";
|
||||
}
|
||||
|
||||
state.page = 1;
|
||||
renderTable();
|
||||
});
|
||||
|
||||
loadBestiary().catch((err) => {
|
||||
setStatus(err?.message || "Unable to load bestiary.", "error");
|
||||
});
|
||||
});
|
||||
})();
|
||||
+43
-10
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<link rel="stylesheet" href="style.css?v=bestiary-table-viewer-20260613-3">
|
||||
<script src="app.js?v=saves-synced-20260609-2" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -33,18 +33,51 @@
|
||||
<form class="placeholder-form">
|
||||
<label for="bestiary-version">Version</label>
|
||||
<select id="bestiary-version">
|
||||
<option>V2</option>
|
||||
<option>V3</option>
|
||||
<option>V4</option>
|
||||
<option value="bb">BB</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-episode" data-bestiary-episode-wrap hidden>Episode</label>
|
||||
<select id="bestiary-episode" data-bestiary-episode-wrap hidden>
|
||||
<option>Episode 1</option>
|
||||
<option>Episode 2</option>
|
||||
<option>Episode 4</option>
|
||||
<label for="bestiary-mode">Mode</label>
|
||||
<select id="bestiary-mode">
|
||||
<option value="">All modes</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-episode">Episode</label>
|
||||
<select id="bestiary-episode">
|
||||
<option value="">All episodes</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-difficulty">Difficulty</label>
|
||||
<select id="bestiary-difficulty">
|
||||
<option value="">All difficulties</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-bp-tier">BP Tier</label>
|
||||
<select id="bestiary-bp-tier">
|
||||
<option value="0">No modifier</option>
|
||||
<option value="1">Brutal Peeps +1</option>
|
||||
<option value="2">Brutal Peeps +2</option>
|
||||
<option value="3">Brutal Peeps +3</option>
|
||||
<option value="4">Brutal Peeps +4</option>
|
||||
<option value="5">Brutal Peeps +5</option>
|
||||
<option value="6">Brutal Peeps +6</option>
|
||||
<option value="7">Brutal Peeps +7</option>
|
||||
<option value="8">Brutal Peeps +8</option>
|
||||
<option value="9">Brutal Peeps +9</option>
|
||||
<option value="10">Brutal Peeps +10</option>
|
||||
<option value="11">Brutal Peeps +11</option>
|
||||
</select>
|
||||
<p class="drops-field-note">Applies HP, ATP, and EXP modifiers to Ultimate rows only.</p>
|
||||
|
||||
<label for="bestiary-view">View</label>
|
||||
<select id="bestiary-view">
|
||||
<option value="stats">Stats</option>
|
||||
<option value="resists">Resists</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
|
||||
<label for="bestiary-search">Search</label>
|
||||
<input id="bestiary-search" type="search" placeholder="Enemy name...">
|
||||
|
||||
<label for="bestiary-tier" data-bestiary-tier-wrap>BP Tier</label>
|
||||
<select id="bestiary-tier" data-bestiary-tier-wrap>
|
||||
<option>BP+1</option><option>BP+2</option><option>BP+3</option>
|
||||
@@ -89,6 +122,6 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="placeholder-pages.js?v=basic-pages-fixed-1" defer></script>
|
||||
<script src="bestiary-tables.js?v=bestiary-table-viewer-20260613-3" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"label": "BB",
|
||||
"mode": "bestiary",
|
||||
"tables": [
|
||||
{
|
||||
"label": "BB",
|
||||
"path": "bb.json",
|
||||
"rows": 3216,
|
||||
"version": "bb"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2873,3 +2873,46 @@ button.inline-link,
|
||||
margin: 0.9rem 1rem 1rem;
|
||||
}
|
||||
|
||||
|
||||
.bestiary-box {
|
||||
align-items: stretch;
|
||||
min-height: 360px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.bestiary-table td,
|
||||
.bestiary-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
/* Bestiary table containment */
|
||||
.placeholder-results-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bestiary-box {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bestiary-box .drops-table-wrap {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.bestiary-box .drops-table {
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user