/* global window, history, location */ /** * Hash-based router for the Climperor web site (web/relations). * * Synchronizes the browser URL with app state across these dimensions: * - page: heroes | rankings | streamers | trends | mechanics | items | patches (top-level tab) * - hero: selected hero key + detail sub-tab * (skills|core|fears|trends|matchups|matches|streamers|patches; legacy stats → trends) * - rankings: Immortal leaderboard region * #/rankings[/region] (legacy #/rankings/meta[/bracket] → players / china) * - streamers: curated Douyin streamer directory * #/streamers * - trends: medal bracket for the 8-week win/pick board * #/trends[/bracket][?sort=pr] (default bracket legend, sort wr omitted) * - mechanics: applies-effect query (dispel / CC) * #/mechanics[/{effect}] (default effect basic_dispel omitted) * - 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", "rankings", "streamers", "trends", "mechanics", "items", "patches"]; const VALID_DETAIL_TABS = [ "skills", "core", "fears", "trends", "stats", "matchups", "matches", "streamers", "patches", ]; const DEFAULT_RANKING_REGION = "china"; const DEFAULT_TRENDS_BRACKET = "legend"; const DEFAULT_TRENDS_SORT = "wr_end"; const VALID_TRENDS_SORTS = ["wr_end", "pr_end"]; const TRENDS_SORT_URL = { wr_end: "wr", pr_end: "pr" }; const TRENDS_SORT_FROM_URL = { wr: "wr_end", pr: "pr_end", wr_end: "wr_end", pr_end: "pr_end" }; const DEFAULT_MECHANIC_EFFECT = "basic_dispel"; const VALID_MECHANIC_EFFECTS = [ "basic_dispel", "strong_dispel", "root", "disarm", "silence", "mute", "stun", "hex", "break", "sleep", "fear", "taunt", "blind", "leash", "invis", "ethereal", "cyclone", ]; 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, rankingRegion: null, trendsBracket: null, trendsSort: null, mechanicEffect: 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 === "rankings") { // Legacy #/rankings/meta[/bracket] → Immortal players board (default region). if (segs[1] && segs[1] !== "meta") { out.rankingRegion = safeDecode(segs[1]); } } else if (page === "trends") { if (segs[1]) out.trendsBracket = safeDecode(segs[1]); } else if (page === "mechanics") { if (segs[1]) out.mechanicEffect = safeDecode(segs[1]); } 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; } if (page === "trends") { const sortParam = params.get("sort"); if (sortParam && TRENDS_SORT_FROM_URL[sortParam]) { out.trendsSort = TRENDS_SORT_FROM_URL[sortParam]; } } 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); let tab = state.detailTab; if (tab === "stats") tab = "trends"; // normalize legacy alias in URL if (tab && VALID_DETAIL_TABS.includes(tab) && tab !== "stats") { hash += "/" + encodeURIComponent(tab); } } } else if (state.page === "rankings") { // Omit region when it is the default (china) — bare #/rankings means China. const region = state.rankingRegion || DEFAULT_RANKING_REGION; if (region && region !== DEFAULT_RANKING_REGION) { hash += "/" + encodeURIComponent(region); } } else if (state.page === "trends") { // Omit bracket when it is the default (legend) — bare #/trends means legend. const bracket = state.trendsBracket || DEFAULT_TRENDS_BRACKET; if (bracket && bracket !== DEFAULT_TRENDS_BRACKET) { hash += "/" + encodeURIComponent(bracket); } const sort = state.trendsSort || DEFAULT_TRENDS_SORT; if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) { hash += "?sort=" + encodeURIComponent(TRENDS_SORT_URL[sort] || sort); } } else if (state.page === "mechanics") { const effect = state.mechanicEffect || DEFAULT_MECHANIC_EFFECT; if (effect && effect !== DEFAULT_MECHANIC_EFFECT) { hash += "/" + encodeURIComponent(effect); } } 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); }