/* global window, history, location */ /** * Path-based router for the Climperor web site (web/frontend). * * Synchronizes the browser URL with app state across these dimensions: * - page: home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players * - 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) * - matches: star-player recent matches (pro_matches) * /matches[/account_id][?origin=pro|china][&page=N] * (default origin all omitted; page=1 omitted) * - home: logged-in Steam self homepage (players view + auth.account_id) * /home[/{match_id}] * - players: PC post-match player home + match detail (local/OSS JSON) * /players/{account_id}[/{match_id}] * - streamers: curated 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) * * Legacy hash URLs (#/heroes/...) are migrated once to path URLs via replaceState. * * Two-way binding without feedback loops: * - App calls syncStateToUrl() after each state mutation. We use * history.pushState/replaceState, which update the URL silently * (no popstate) — so applyUrlToState is never re-entered. * - Back/forward navigation fires popstate → applyUrlToState parses * the new path, hands a validated patch back to app, and app re-renders. * * API names parseHash / serializeHash are kept for call-site stability; both * now operate on pathname + search (not location.hash). * * Depends on app.js for state shape and render(); installed via installRouter(). */ const ROUTE_DEFAULT = "/heroes"; const VALID_PAGES = [ "home", "heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches", "players", ]; 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_MATCHES_ORIGIN = "all"; const VALID_MATCHES_ORIGINS = ["all", "pro", "china"]; 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; migrateLegacyHashRoute(); window.addEventListener("popstate", applyUrlToState); } /** One-shot: #/heroes/... → /heroes/... (bookmarks / shared links). */ function migrateLegacyHashRoute() { const hash = window.location.hash || ""; if (!hash.startsWith("#/")) return; const next = hash.slice(1) || ROUTE_DEFAULT; history.replaceState(null, "", next); } /** Parse a path (+ optional query) into a state patch (all fields nullable). */ function parseHash(hashOrPath) { const out = { page: null, heroKey: null, detailTab: null, itemKey: null, patchVersion: null, rankingRegion: null, matchesPlayerId: null, matchesOrigin: null, matchesPage: null, playerAccountId: null, playerMatchId: null, trendsBracket: null, trendsSort: null, mechanicEffect: null, tags: null, query: null, itemQuery: null, }; let raw = hashOrPath || ""; 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) { out.page = "heroes"; 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 === "matches") { if (segs[1] && /^\d+$/.test(segs[1])) { out.matchesPlayerId = segs[1]; } } else if (page === "home") { // Logged-in self homepage; optional match detail: /home/{match_id} if (segs[1] && /^\d+$/.test(segs[1])) { out.playerMatchId = segs[1]; } } else if (page === "players") { if (segs[1] && /^\d+$/.test(segs[1])) { out.playerAccountId = segs[1]; } if (segs[2] && /^\d+$/.test(segs[2])) { out.playerMatchId = segs[2]; } } 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]; } } if (page === "matches") { const originParam = params.get("origin"); if (originParam && VALID_MATCHES_ORIGINS.includes(originParam)) { out.matchesOrigin = originParam; } const pageParam = params.get("page"); if (pageParam && /^\d+$/.test(pageParam)) { const n = parseInt(pageParam, 10); if (n >= 1) out.matchesPage = n; } } return out; } function safeDecode(s) { try { return decodeURIComponent(s); } catch { return ""; } } /** Serialize current app state into a path string (leading '/', optional ?query). */ function serializeHash(state) { if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT; let path = "/" + state.page; if (state.page === "heroes") { if (state.selectedKey) { path += "/" + 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") { path += "/" + 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) { path += "/" + encodeURIComponent(region); } } else if (state.page === "matches") { if (state.matchesPlayerId) { path += "/" + encodeURIComponent(String(state.matchesPlayerId)); } } else if (state.page === "home") { if (state.playerMatchId) { path += "/" + encodeURIComponent(String(state.playerMatchId)); } } else if (state.page === "players") { if (state.playerAccountId) { path += "/" + encodeURIComponent(String(state.playerAccountId)); if (state.playerMatchId) { path += "/" + encodeURIComponent(String(state.playerMatchId)); } } } 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) { path += "/" + encodeURIComponent(bracket); } const sort = state.trendsSort || DEFAULT_TRENDS_SORT; if (VALID_TRENDS_SORTS.includes(sort) && sort !== DEFAULT_TRENDS_SORT) { path += "?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) { path += "/" + encodeURIComponent(effect); } } else if (state.page === "items") { if (state.selectedItemKey) { path += "/" + 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) { path += "/" + 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) path += "?" + s; } else if (state.page === "items") { const qs = new URLSearchParams(); if (state.itemQuery) qs.set("q", state.itemQuery); const s = qs.toString(); if (s) path += "?" + s; } else if (state.page === "matches") { const qs = new URLSearchParams(); const origin = state.matchesOrigin || DEFAULT_MATCHES_ORIGIN; if (VALID_MATCHES_ORIGINS.includes(origin) && origin !== DEFAULT_MATCHES_ORIGIN) { qs.set("origin", origin); } const pageNum = Number(state.matchesPage) || 1; if (pageNum > 1) qs.set("page", String(pageNum)); const s = qs.toString(); if (s) path += "?" + s; } return path; } function currentRoutePath() { return (window.location.pathname || "/") + (window.location.search || ""); } /** 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 path = serializeHash(_deps.getState()); if (path === currentRoutePath()) return; // no-op when state matches URL if (replace) { history.replaceState(null, "", path); } else { history.pushState(null, "", path); } } /** Parse current location path+search, hand it to app for validation+merge. * Invoked on popstate (back/forward) and once at startup for deep-link support. */ function applyUrlToState() { if (!_deps) return; const path = currentRoutePath(); const patch = parseHash(path === "/" ? "/heroes" : path); _deps.applyPatch(patch); }