/* global window, history, location */ /** * Hash-based router for the relations preview (web/relations). * * Synchronizes the browser URL with app state across these dimensions: * - page: heroes | items | patches (top-level tab) * - hero: selected hero key + detail sub-tab (skills|core|fears|patches) * - item: selected shop item key (items page) * - patch: selected version string (patches page; latest when absent) * - query params: tags=csv (heroes), q=search text (heroes + items) * * Two-way binding without feedback loops: * - App calls syncStateToUrl() after each state mutation. We use * history.pushState/replaceState, which update the URL silently * (no hashchange event fires) — so applyUrlToState is never re-entered. * - Back/forward navigation fires hashchange → applyUrlToState parses * the new hash, hands a validated patch back to app, and app re-renders. * * Depends on app.js for state shape and render(); installed via installRouter(). */ const ROUTE_DEFAULT = "#/heroes"; const VALID_PAGES = ["heroes", "items", "patches"]; const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"]; let _deps = null; /** * Bind the router. deps = { * getState: () => state, * applyPatch: (patch) => void // validates + merges + renders * } * Call once after state.data is loaded. */ function installRouter(deps) { _deps = deps; window.addEventListener("hashchange", applyUrlToState); } /** Parse a hash string into a state patch (all fields nullable). */ function parseHash(hash) { const out = { page: null, heroKey: null, detailTab: null, itemKey: null, patchVersion: null, tags: null, query: null, itemQuery: null, }; let raw = hash || ""; if (raw.startsWith("#")) raw = raw.slice(1); if (!raw.startsWith("/")) raw = "/" + raw; const qIdx = raw.indexOf("?"); const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw; const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : ""; const segs = pathPart.split("/").filter(Boolean); if (!segs.length) return out; const page = segs[0]; if (!VALID_PAGES.includes(page)) return out; out.page = page; if (page === "heroes") { if (segs[1]) out.heroKey = safeDecode(segs[1]); if (segs[2]) out.detailTab = safeDecode(segs[2]); } else if (page === "items") { if (segs[1]) out.itemKey = safeDecode(segs[1]); } else if (page === "patches") { if (segs[1]) out.patchVersion = safeDecode(segs[1]); } const params = new URLSearchParams(queryPart); const tagsParam = params.get("tags"); if (tagsParam) { out.tags = new Set( tagsParam.split(",").map((s) => safeDecode(s)).filter(Boolean) ); } const qParam = params.get("q"); if (qParam != null) { if (page === "items") out.itemQuery = qParam; else out.query = qParam; } return out; } function safeDecode(s) { try { return decodeURIComponent(s); } catch { return ""; } } /** Serialize current app state into a hash string (with leading '#'). */ function serializeHash(state) { if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT; let hash = "#/" + state.page; if (state.page === "heroes") { if (state.selectedKey) { hash += "/" + encodeURIComponent(state.selectedKey); if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) { hash += "/" + encodeURIComponent(state.detailTab); } } } else if (state.page === "items") { if (state.selectedItemKey) { hash += "/" + encodeURIComponent(state.selectedItemKey); } } else if (state.page === "patches") { // Omit version when it is the latest — bare #/patches means "latest". if (state.selectedPatch) { const patches = (state.data && state.data.patches) || []; const isLatest = patches.length > 0 && patches[0].version === state.selectedPatch; if (!isLatest) { hash += "/" + encodeURIComponent(state.selectedPatch); } } } if (state.page === "heroes") { const qs = new URLSearchParams(); if (state.tagFilters && state.tagFilters.size) { qs.set("tags", Array.from(state.tagFilters).join(",")); } if (state.query) qs.set("q", state.query); const s = qs.toString(); if (s) hash += "?" + s; } else if (state.page === "items") { const qs = new URLSearchParams(); if (state.itemQuery) qs.set("q", state.itemQuery); const s = qs.toString(); if (s) hash += "?" + s; } return hash; } /** Write current state into the URL. Use replace:true for high-frequency * updates (search typing, tag toggles) to avoid history-stack spam. */ function syncStateToUrl({ replace = false } = {}) { if (!_deps) return; const hash = serializeHash(_deps.getState()); if (hash === window.location.hash) return; // no-op when state matches URL if (replace) { history.replaceState(null, "", hash); } else { history.pushState(null, "", hash); } } /** Parse current location.hash, hand it to app for validation+merge, render. * Invoked on hashchange (back/forward / direct location.hash writes) and * once at startup for deep-link support. */ function applyUrlToState() { if (!_deps) return; const patch = parseHash(window.location.hash); _deps.applyPatch(patch); }