/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION, SITE_ORIGIN */ function trimBase(raw) { return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : ""; } /** OSS base for ability demos; empty => same-origin `/ability-video/...`. */ function abilityVideoBase() { return trimBase(ABILITY_VIDEO_BASE); } function abilityVideoUrl(heroKey, abilityKey) { const rel = `/ability-video/${heroKey}/${abilityKey}.webm`; const base = abilityVideoBase(); return base ? `${base}${rel}` : rel; } /** OSS base for icons/portraits; empty => same-origin relative paths. */ function staticAssetBase() { return trimBase(STATIC_ASSET_BASE); } function assetUrl(rel) { const clean = String(rel || "").replace(/^\//, ""); const base = staticAssetBase(); return base ? `${base}/${clean}` : `/${clean}`; } function attrIconSrc(key) { return assetUrl(`attr/${key}.png`); } function portraitSrc(key) { return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`); } function abilityIconSrc(abilityKey) { return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`); } function innateIconSrc() { return assetUrl("ability/innate.png"); } function talentTreeIconSrc() { return assetUrl("ability/talent_tree.png"); } function itemCatIconSrc(icon) { return assetUrl(`item-cat/${encodeURIComponent(icon)}`); } /** Rank medal / filter icon under /rank/. */ function rankIconSrc(file) { const name = String(file || "rank_icon_0.png").replace(/^.*[\\/]/, ""); return assetUrl(`rank/${name}`); } const ROLE_ICON_FILES = { 核心: "Carry.png", 辅助: "Support.png", 推进: "Pusher.png", 逃生: "Escape.png", 爆发: "Nuker.png", 先手: "Initiator.png", 控制: "Disabler.png", 耐久: "Durable.png", }; function roleIconSrc(file) { return assetUrl(`role-icon/${encodeURIComponent(file)}`); } function siteVersionLabel() { const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : ""; return v ? `v${v}` : ""; } /** Latest fetched_at among volatile site datasets (for hero detail footer). */ function siteDataFetchedAt() { if (!state.data) return null; const candidates = []; const push = (v) => { const ms = parseTimeMs(v); if (Number.isFinite(ms)) candidates.push(ms); }; push(state.data.hero_stats?.fetched_at); push(state.data.leaderboards?.fetched_at); push(state.data.hero_matches?.meta?.fetched_at); push(state.data.pro_matches?.meta?.fetched_at); push(state.data.streamers?.fetched_at); push(state.data.stratz_hero_meta?.fetched_at); push(state.data.stratz_matchup_tops?.fetched_at); push(state.data.hero_items?.meta?.fetched_at); if (!candidates.length) return null; return new Date(Math.max(...candidates)).toISOString(); } function renderHeroesSiteFoot() { const el = $("#heroes-site-foot"); if (!el) return; // Hide under the detail panel when a hero is selected. const show = state.page === "heroes" && !state.selectedKey; el.classList.toggle("hidden", !show); if (!show) { el.textContent = ""; el.removeAttribute("title"); el.setAttribute("aria-hidden", "true"); return; } const parts = []; const ver = siteVersionLabel(); if (ver) parts.push(ver); const fetchedAt = siteDataFetchedAt(); const fetchedInfo = fetchedAt ? formatFriendlyTime(fetchedAt) : { text: "", title: "" }; if (fetchedInfo.text) parts.push(`更新于 ${fetchedInfo.text}`); el.textContent = parts.join(" · "); if (fetchedInfo.title) el.title = fetchedInfo.title; else el.removeAttribute("title"); el.setAttribute("aria-hidden", parts.length ? "false" : "true"); } function siteOrigin() { const configured = typeof SITE_ORIGIN === "string" ? SITE_ORIGIN.trim().replace(/\/+$/, "") : ""; if (configured) return configured; if (typeof location !== "undefined" && location.origin) return location.origin; return ""; } const PAGE_SEO_LABELS = { heroes: "英雄克制与搭档", rankings: "Immortal 排行", streamers: "主播", matches: "明星比赛", trends: "近 8 周走势", mechanics: "机制查询", items: "物品商店", patches: "版本更新", }; const DETAIL_TAB_SEO_LABELS = { skills: "技能", core: "核心装", fears: "怕的装备", trends: "走势", matchups: "对位", matches: "近期比赛", streamers: "主播", patches: "版本改动", }; function setMetaByKey(attr, key, content) { if (!content) return; let el = document.querySelector(`meta[${attr}="${key}"]`); if (!el) { el = document.createElement("meta"); el.setAttribute(attr, key); document.head.appendChild(el); } el.setAttribute("content", content); } function describeStateForSeo(st) { const brand = "上分帝"; const page = st.page || "heroes"; let title = `${PAGE_SEO_LABELS[page] || "DOTA2"} — ${brand}`; let description = "Dota 2 英雄机制克制与搭档、段位走势、机制查询、物品与版本更新。"; let path = "/" + page; try { if (typeof serializeHash === "function") path = serializeHash(st) || "/heroes"; } catch (_) { /* keep path */ } if (!st.data) return { title, description, path }; if (page === "heroes" && st.selectedKey) { const hero = heroByKey(st.selectedKey); const name = (hero && hero.name_loc) || st.selectedKey; const aliases = (hero && hero.aliases) || []; const tab = DETAIL_TAB_SEO_LABELS[st.detailTab] || "详情"; title = `${name} ${tab} / 克制搭档 — ${brand}`; const aliasBit = aliases.length ? `(${aliases.slice(0, 3).join("、")})` : ""; description = `${name}${aliasBit}的 Dota 2 机制克制、被克制与搭档参考,以及技能、出装、走势与对位数据。`; } else if (page === "mechanics") { const mq = st.data.mechanic_query || {}; const labels = mq.labels || {}; const effect = st.mechanicEffect || "basic_dispel"; const label = labels[effect] || effect; title = `${label} — 机制查询 — ${brand}`; const blurb = (mq.blurbs && mq.blurbs[effect]) || ""; description = blurb || `查询 Dota 2 中施加「${label}」的技能与物品。`; } else if (page === "items" && st.selectedItemKey) { const meta = shopItem(st.selectedItemKey); const name = (meta && (meta.name_loc || meta.dname)) || st.selectedItemKey; title = `${name} — 物品 — ${brand}`; description = `${name} 的合成、描述与机制标签(上分帝物品页)。`; } else if (page === "patches") { const ver = st.selectedPatch || ((st.data.patches && st.data.patches[0]) || {}).version; if (ver) { title = `版本 ${ver} — ${brand}`; description = `Dota 2 ${ver} 游戏性更新摘要(上分帝版本页)。`; } } else if (page === "trends") { title = `近 8 周走势 — ${brand}`; description = "各勋章段位近 8 周英雄胜率与上场率走势榜。"; } else if (page === "rankings") { title = `Immortal 排行榜 — ${brand}`; description = "Valve Immortal 四区 Top100 选手榜。"; } else if (page === "streamers") { title = `Dota 2 主播 — ${brand}`; description = "精选 Dota 2 主播目录与高光(抖音 / B 站 / 斗鱼)。"; } else if (page === "matches") { title = `明星比赛 — ${brand}`; description = "明星选手近期职业与国服对局、终局出装与加点。"; } else if (page === "heroes") { title = `英雄克制与搭档 — ${brand}`; description = "按英雄浏览定性克制 / 被克制 / 搭档理由,以及技能、核心装、走势与对位。"; } return { title, description, path }; } function updateDocumentMeta() { if (typeof document === "undefined" || !document.title) return; const { title, description, path } = describeStateForSeo(state); document.title = title; setMetaByKey("name", "description", description); setMetaByKey("property", "og:title", title); setMetaByKey("property", "og:description", description); setMetaByKey("name", "twitter:title", title); setMetaByKey("name", "twitter:description", description); const origin = siteOrigin(); if (origin) { const url = origin + (path.startsWith("/") ? path : `/${path}`); setMetaByKey("property", "og:url", url); let link = document.querySelector('link[rel="canonical"]'); if (!link) { link = document.createElement("link"); link.setAttribute("rel", "canonical"); document.head.appendChild(link); } link.setAttribute("href", url); } } function clearSeoPrerender() { const el = document.getElementById("seo-prerender"); if (el) el.remove(); } const state = { data: null, page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches selectedKey: null, selectedItemKey: null, /** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */ inspect: null, detailTab: "skills", // skills | core | fears | trends | matchups | matches | streamers | patches /** OpenDota / STRATZ bracket key for hero-detail trends: herald…immortal */ statsBracket: "legend", tagFilters: new Set(), query: "", itemQuery: "", /** Currently selected patch version on the 版本 page; null -> latest. */ selectedPatch: null, /** Immortal leaderboard region: china | europe | americas | se_asia */ rankingRegion: "china", /** Top-level matches page: OpenDota account_id string (null = all stars) */ matchesPlayerId: null, /** Top-level matches page: all | pro | china */ matchesOrigin: "all", /** Top-level matches page: 1-based page index */ matchesPage: 1, /** Top-level 走势 page medal bracket */ trendsBracket: "legend", /** Sort key for trends board: wr_end | pr_end */ trendsSort: "wr_end", /** Sort direction for trends board */ trendsSortDir: "desc", /** Mechanics page: applies-effect key (basic_dispel, stun, …) */ mechanicEffect: "basic_dispel", }; const $ = (sel) => document.querySelector(sel); function counterEdge(a, b) { return (state.data.relations.counters || []).find((e) => e.a === a && e.b === b) || null; } function synergyEdge(a, b) { const [x, y] = [a, b].slice().sort(); return (state.data.relations.synergies || []).find((e) => e.a === x && e.b === y) || null; } /** All relation kinds from selected S to peer P. */ function relationsOf(peerKey) { const s = state.selectedKey; if (!s || !peerKey || s === peerKey) return null; const out = { counters: counterEdge(s, peerKey), countered: counterEdge(peerKey, s), synergy: synergyEdge(s, peerKey), }; if (!out.counters && !out.countered && !out.synergy) return null; return out; } function matchesTags(h) { if (!state.tagFilters.size) return true; const tags = new Set(h.tags || []); for (const t of state.tagFilters) { if (!tags.has(t)) return false; } return true; } function matchesQuery(h) { const q = state.query.trim(); if (!q) return true; const qLower = q.toLowerCase(); if ((h.name_loc || "").includes(q)) return true; if ((h.name || "").toLowerCase().includes(qLower)) return true; if ((h.key || "").toLowerCase().includes(qLower)) return true; for (const a of h.aliases || []) { if (String(a).includes(q) || String(a).toLowerCase().includes(qLower)) return true; } for (const a of h.abbr || []) { if (String(a).toLowerCase().includes(qLower)) return true; } return false; } function matchesFilters(h) { return matchesTags(h) && matchesQuery(h); } function renderBoard() { if (!state.data) return; const root = $("#columns"); root.innerHTML = ""; root.className = "columns"; const { by_attr, attr_order, attr_cols, attr_labels } = state.data; for (const attr of attr_order) { const col = document.createElement("section"); col.className = "col"; col.innerHTML = `
${attr_labels[attr]}${attr_labels[attr]}
`; const grid = document.createElement("div"); grid.className = "grid"; // Server already returns in-client pick-grid order. const heroes = by_attr[attr] || []; const cols = (attr_cols && attr_cols[attr]) || 6; grid.style.gridTemplateColumns = `repeat(${cols}, minmax(0, 1fr))`; for (const h of heroes) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "hero"; const abbrHint = h.abbr?.length ? " / " + h.abbr.join(", ") : ""; btn.title = `${h.name_loc}${h.aliases?.length ? " / " + h.aliases.join("、") : ""}${abbrHint}`; if (!matchesFilters(h)) btn.style.display = "none"; const marks = document.createElement("div"); marks.className = "marks"; if (h.key === state.selectedKey) { btn.classList.add("selected"); marks.innerHTML = ``; } else if (state.selectedKey) { const rel = relationsOf(h.key); if (rel) { btn.classList.add("related"); const bits = []; if (rel.counters) { btn.classList.add("is-counter"); bits.push(``); } if (rel.countered) { btn.classList.add("is-countered"); bits.push(``); } if (rel.synergy) { btn.classList.add("is-synergy"); bits.push(``); } marks.innerHTML = bits.join(""); const reasons = [ rel.counters?.reason, rel.countered?.reason, rel.synergy?.reason, ].filter(Boolean); if (reasons.length) btn.title += ` — ${reasons.join(";")}`; } else { btn.classList.add("dim"); } } const img = document.createElement("img"); img.src = portraitSrc(h.key); img.alt = h.name_loc; img.loading = "lazy"; btn.appendChild(img); btn.appendChild(marks); btn.addEventListener("click", () => onHeroClick(h.key)); grid.appendChild(btn); } col.appendChild(grid); root.appendChild(col); } } function onHeroClick(key) { state.selectedKey = state.selectedKey === key ? null : key; state.inspect = null; syncStateToUrl(); render(); } function clearHeroDetail() { if (!state.selectedKey) return false; const drawer = $("#detail-drawer"); if ( drawer && !drawer.classList.contains("is-collapsed") && !detailDrawerClosing ) { collapseDetailDrawerThenClear(); return true; } state.selectedKey = null; state.inspect = null; syncStateToUrl(); render(); return true; } let detailDrawerDrag = null; let detailDrawerSuppressClickUntil = 0; let detailDrawerClosing = false; /** Cap / floor for the in-flow hero detail drawer (px). */ const DETAIL_DRAWER_MAX_H = 640; const DETAIL_DRAWER_MIN_H = 320; /** * Height that leaves topbar + a usable hero-grid strip + role tags visible. * Prefer ~24% of the viewport for the grid; never steal the whole screen. */ function detailDrawerTargetHeight() { const vh = window.innerHeight || 800; const topbar = document.querySelector(".topbar")?.offsetHeight || 60; const toolbar = document.querySelector(".hero-role-toolbar")?.offsetHeight || 52; const chrome = topbar + toolbar; const heroPrefer = Math.round(vh * 0.24); const heroReserve = Math.min( Math.max(140, heroPrefer), Math.max(120, vh - chrome - DETAIL_DRAWER_MIN_H) ); const available = vh - chrome - heroReserve; return Math.max(DETAIL_DRAWER_MIN_H, Math.min(DETAIL_DRAWER_MAX_H, available)); } function applyDetailDrawerHeight() { const drawer = $("#detail-drawer"); const h = detailDrawerTargetHeight(); if (drawer) drawer.style.setProperty("--detail-fixed-h", `${h}px`); return h; } function detailDrawerFullHeight() { const h = applyDetailDrawerHeight(); const panel = $("#detail"); if (panel && panel.offsetHeight) return panel.offsetHeight; return h; } function syncDetailDrawer() { const drawer = $("#detail-drawer"); if (!drawer) return; const open = state.page === "heroes" && !!state.selectedKey && !detailDrawerClosing; drawer.setAttribute("aria-hidden", open ? "false" : "true"); document.body.classList.toggle("detail-drawer-open", open); if (!open) { drawer.classList.add("is-collapsed"); drawer.style.maxHeight = ""; resetDetailDrawerDrag(); return; } applyDetailDrawerHeight(); drawer.classList.remove("is-collapsed"); drawer.style.maxHeight = ""; } function resetDetailDrawerDrag() { const drawer = $("#detail-drawer"); if (drawer) { drawer.classList.remove("is-dragging"); if (!detailDrawerClosing) drawer.style.maxHeight = ""; } detailDrawerDrag = null; } function setDetailDrawerDragOffset(dy) { const drawer = $("#detail-drawer"); if (!drawer) return; const full = detailDrawerDrag?.fullH || detailDrawerFullHeight() || 1; const h = Math.max(0, full - Math.max(0, dy)); drawer.style.maxHeight = `${h}px`; } function endDetailDrawerDrag(dy, velocityY) { const drawer = $("#detail-drawer"); if (!drawer) { resetDetailDrawerDrag(); return; } const full = detailDrawerDrag?.fullH || detailDrawerFullHeight() || 1; const shouldClose = dy > Math.min(140, full * 0.22) || velocityY > 0.85; if (detailDrawerDrag?.moved) { detailDrawerSuppressClickUntil = performance.now() + 280; } if (shouldClose) { collapseDetailDrawerThenClear(); return; } drawer.classList.remove("is-dragging"); drawer.style.maxHeight = ""; detailDrawerDrag = null; } function collapseDetailDrawerThenClear() { const drawer = $("#detail-drawer"); if (!state.selectedKey) return; if (!drawer || detailDrawerClosing) { state.selectedKey = null; state.inspect = null; syncStateToUrl(); render(); return; } detailDrawerClosing = true; const fromH = drawer.offsetHeight || detailDrawerFullHeight(); drawer.classList.add("is-dragging"); drawer.style.maxHeight = `${fromH}px`; void drawer.offsetHeight; drawer.classList.remove("is-dragging"); drawer.classList.add("is-collapsed"); drawer.style.maxHeight = "0px"; document.body.classList.remove("detail-drawer-open"); let done = false; const finish = () => { if (done) return; done = true; drawer.removeEventListener("transitionend", finish); detailDrawerClosing = false; drawer.style.maxHeight = ""; resetDetailDrawerDrag(); state.selectedKey = null; state.inspect = null; syncStateToUrl(); render(); }; const reduce = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches; if (reduce) { finish(); return; } drawer.addEventListener("transitionend", finish); window.setTimeout(finish, 360); } /** True when the event target is drawer chrome (handle / head / empty padding), not interactive content. */ function isDetailDrawerDismissTarget(target) { if (!(target instanceof Element)) return false; if (target.closest(".detail-drawer-handle, .detail-head")) return true; if (target.closest("button, a, input, select, textarea, label, video, .hero-inspect, .detail-tab-body, .detail-tabs, .detail-abilities, .detail-items, .detail-skills-layout, .detail-items-layout, .detail-stats-layout, .detail-matchups-layout, .detail-matches-layout, .detail-streamers-layout, .detail-patches-layout")) { return false; } return target.id === "detail" || target.classList.contains("detail-drawer-panel"); } function bindDetailDrawerGestures() { const drawer = $("#detail-drawer"); const panel = $("#detail"); if (!drawer || !panel) return; panel.addEventListener("click", (e) => { if (state.page !== "heroes" || !state.selectedKey) return; if (performance.now() < detailDrawerSuppressClickUntil) return; if (!isDetailDrawerDismissTarget(e.target)) return; collapseDetailDrawerThenClear(); }); const onPointerDown = (e) => { if (state.page !== "heroes" || !state.selectedKey) return; if (e.button != null && e.button !== 0) return; if (!e.target.closest(".detail-drawer-handle, .detail-head")) return; const fullH = detailDrawerFullHeight(); detailDrawerDrag = { pointerId: e.pointerId, startY: e.clientY, lastY: e.clientY, lastT: performance.now(), dy: 0, velocityY: 0, moved: false, fullH, }; drawer.classList.add("is-dragging"); drawer.style.maxHeight = `${fullH}px`; try { drawer.setPointerCapture(e.pointerId); } catch (_) { /* ignore */ } }; const onPointerMove = (e) => { if (!detailDrawerDrag || e.pointerId !== detailDrawerDrag.pointerId) return; const now = performance.now(); const dy = e.clientY - detailDrawerDrag.startY; const dt = Math.max(1, now - detailDrawerDrag.lastT); detailDrawerDrag.velocityY = (e.clientY - detailDrawerDrag.lastY) / dt; detailDrawerDrag.lastY = e.clientY; detailDrawerDrag.lastT = now; detailDrawerDrag.dy = dy; if (Math.abs(dy) > 4) detailDrawerDrag.moved = true; if (dy > 0) setDetailDrawerDragOffset(dy); else setDetailDrawerDragOffset(0); }; const onPointerUp = (e) => { if (!detailDrawerDrag || e.pointerId !== detailDrawerDrag.pointerId) return; const { dy, velocityY, moved } = detailDrawerDrag; if (!moved && dy <= 0) { resetDetailDrawerDrag(); return; } endDetailDrawerDrag(Math.max(0, dy), velocityY); }; drawer.addEventListener("pointerdown", onPointerDown); drawer.addEventListener("pointermove", onPointerMove); drawer.addEventListener("pointerup", onPointerUp); drawer.addEventListener("pointercancel", onPointerUp); } function renderTagbar() { const bar = $("#tagbar"); if (!bar) return; const order = state.data.tag_order || []; bar.innerHTML = ""; const addLabel = (button, text, iconFile) => { if (iconFile) { const icon = document.createElement("img"); icon.className = "tagbar-role-icon"; icon.src = roleIconSrc(iconFile); icon.alt = ""; icon.setAttribute("aria-hidden", "true"); button.appendChild(icon); } else { const icon = document.createElement("span"); icon.className = "tagbar-all-icon"; icon.setAttribute("aria-hidden", "true"); icon.innerHTML = ''; button.appendChild(icon); } const label = document.createElement("span"); label.textContent = text; button.appendChild(label); }; const selectedHero = state.selectedKey ? heroByKey(state.selectedKey) : null; const heroTags = new Set(selectedHero?.tags || []); const allBtn = document.createElement("button"); allBtn.type = "button"; addLabel(allBtn, "全部"); // "全部" only when no filter and no hero selected; hero roles use `.matched`. allBtn.className = !state.tagFilters.size && !state.selectedKey ? "active" : ""; allBtn.addEventListener("click", () => { state.tagFilters.clear(); syncStateToUrl({ replace: true }); render(); }); bar.appendChild(allBtn); for (const tag of order) { const iconFile = ROLE_ICON_FILES[tag]; if (!iconFile) continue; const btn = document.createElement("button"); btn.type = "button"; addLabel(btn, tag, iconFile); if (state.tagFilters.has(tag)) btn.classList.add("active"); if (heroTags.has(tag)) { btn.classList.add("matched"); btn.title = "该英雄定位"; } btn.addEventListener("click", () => { if (state.tagFilters.has(tag)) state.tagFilters.delete(tag); else state.tagFilters.add(tag); syncStateToUrl({ replace: true }); render(); }); bar.appendChild(btn); } } function heroByKey(key) { return (state.data.heroes || []).find((h) => h.key === key) || null; } function itemMeta(id) { const items = state.data.hero_items?.items || {}; return items[String(id)] || null; } /** Resolve item id → { key, name_loc, … } via match catalog, hero_items, shop. */ let _itemIdIndex = null; function itemMetaFromId(id) { if (id == null) return null; const sid = String(id); const fromMatches = (state.data.hero_matches?.items || {})[sid]; const fromProMatches = (state.data.pro_matches?.items || {})[sid]; const fromCore = itemMeta(id); if (!_itemIdIndex) { _itemIdIndex = new Map(); const shop = state.data.item_shop?.items || {}; for (const [key, row] of Object.entries(shop)) { if (!row || row.id == null) continue; _itemIdIndex.set(String(row.id), { key, dname: row.dname || key, name_loc: row.name_loc || row.dname || key, }); } } const fromShop = _itemIdIndex.get(sid) || null; const base = fromShop || fromCore || fromProMatches || fromMatches; if (!base) return null; const key = base.key || fromMatches?.key || fromCore?.key; return { key, dname: base.dname || fromMatches?.dname || key, name_loc: fromShop?.name_loc || fromCore?.name_loc || fromMatches?.name_loc || fromMatches?.dname || key, }; } function abilityLabelForKey(heroKey, abilityKey) { if (!abilityKey) return ""; const pack = skillEntriesFor(heroKey); if (pack?.entries) { const hit = pack.entries.find((e) => e.ability_key === abilityKey); if (hit?.name_loc) return hit.name_loc; } const cell = (state.data.hero_abilities?.by_hero || {})[heroKey]; const abilities = cell?.abilities; if (Array.isArray(abilities)) { for (const ab of abilities) { if (ab?.key === abilityKey) return ab.name_loc || abilityKey; } } const talents = cell?.talents; if (Array.isArray(talents)) { for (const row of talents) { if (row?.key === abilityKey) return row.name_loc || abilityKey; } } return abilityKey.replace(/^special_bonus_/, "").replace(/_/g, " "); } function coreItemsFor(heroKey) { const cell = (state.data.hero_items?.by_hero || {})[heroKey]; if (cell == null) return null; return Array.isArray(cell) ? cell : []; } /** Relative usage % within the hero's Top-N core list (sum of counts = 100%). */ function coreItemUsagePcts(entries) { if (!Array.isArray(entries) || !entries.length) return []; const sum = entries.reduce((acc, e) => acc + (Number(e.count) || 0), 0); if (sum <= 0) return entries.map(() => null); return entries.map((e) => { const c = Number(e.count) || 0; return Math.round((c / sum) * 1000) / 10; }); } const BRACKET_LABELS = { herald: "先锋", guardian: "卫士", crusader: "中军", archon: "统帅", legend: "传奇", ancient: "万古", divine: "超凡", immortal: "冠绝", }; /** OpenDota / client: Immortal sample is folded into Divine when too small. */ const IMMORTAL_MERGE_TIP = "由于样本过小,冠绝一世玩家的相关数据已与超凡入圣玩家合并。"; /** Valve regional Immortal leaderboard; scores are not comparable across regions. */ const IMMORTAL_LEADERBOARD_TIP = "官方 Immortal 地区榜 · 各区分数不可比"; const POSITION_LABELS = { POSITION_1: "一号位", POSITION_2: "二号位", POSITION_3: "三号位", POSITION_4: "四号位", POSITION_5: "五号位", }; /** Icon file under assets/rank_icons/ for each medal bracket. */ const BRACKET_RANK_ICON = { herald: "rank_icon_1.png", guardian: "rank_icon_2.png", crusader: "rank_icon_3.png", archon: "rank_icon_4.png", legend: "rank_icon_5.png", ancient: "rank_icon_6.png", divine: "rank_icon_7.png", immortal: "rank_icon_8.png", }; /** Recent matches tab: newest N ladder+league games. */ const MATCHES_DISPLAY_LIMIT = 10; const PRO_MATCHES_PAGE_SIZE = 20; function heroStatsPack() { return ( state.data?.hero_stats || { brackets: [], by_hero: {}, totals: {}, fetched_at: null, window_days: 7, window_label_zh: "近约 7 天公开对局", } ); } /** Short Chinese label for the OpenDota recent-match window. */ function heroStatsWindowLabel() { const pack = heroStatsPack(); if (pack.window_label_zh) return String(pack.window_label_zh); const days = Number(pack.window_days) || 7; return `近约 ${days} 天公开对局`; } function heroStatsFor(heroKey) { const cell = (heroStatsPack().by_hero || {})[heroKey]; return cell && typeof cell === "object" ? cell : null; } function pickWinForBracket(cell, bracket) { if (!cell) return null; if (bracket === "pub") return cell.pub || null; if (bracket === "pro") return cell.pro || null; if (bracket === "turbo") return cell.turbo || null; return (cell.brackets && cell.brackets[bracket]) || null; } /** Immortal is merged into Divine when the Immortal sample is empty/tiny. */ function pickWinForTrends(cell, bracket) { if (bracket !== "immortal") return pickWinForBracket(cell, bracket); const divine = pickWinForBracket(cell, "divine") || { pick: 0, win: 0 }; const immortal = pickWinForBracket(cell, "immortal") || { pick: 0, win: 0 }; return { pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0), win: (Number(divine.win) || 0) + (Number(immortal.win) || 0), }; } function totalsForBracket(bracket) { ensureHeroStatsTotals(); const totals = heroStatsPack().totals || {}; if (bracket === "pub") return totals.pub || null; if (bracket === "pro") return totals.pro || null; if (bracket === "turbo") return totals.turbo || null; return (totals.brackets && totals.brackets[bracket]) || null; } function totalsForTrends(bracket) { if (bracket !== "immortal") return totalsForBracket(bracket); const divine = totalsForBracket("divine") || { pick: 0 }; const immortal = totalsForBracket("immortal") || { pick: 0 }; return { pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0), }; } /** If payload lacks totals (old cache), derive pick sums from by_hero. */ function ensureHeroStatsTotals() { const pack = heroStatsPack(); if (!pack || !pack.by_hero) return; const totals = pack.totals; if (totals && totals.pub && Number(totals.pub.pick) > 0) return; const brackets = Array.isArray(pack.brackets) && pack.brackets.length ? pack.brackets : Object.keys(BRACKET_RANK_ICON).filter((k) => k !== "pub"); const out = { pub: { pick: 0 }, brackets: {}, pro: { pick: 0, ban: 0 }, turbo: { pick: 0 }, }; for (const name of brackets) out.brackets[name] = { pick: 0 }; for (const cell of Object.values(pack.by_hero)) { if (!cell || typeof cell !== "object") continue; out.pub.pick += Number(cell.pub?.pick) || 0; out.pro.pick += Number(cell.pro?.pick) || 0; out.pro.ban += Number(cell.pro?.ban) || 0; out.turbo.pick += Number(cell.turbo?.pick) || 0; for (const name of brackets) { out.brackets[name].pick += Number(cell.brackets?.[name]?.pick) || 0; } } pack.totals = out; } /** Matches ≈ sum of hero picks / 10 (each game contributes 10 picks). */ function approxMatches(totalPickSum) { const s = Number(totalPickSum) || 0; return s > 0 ? s / 10 : 0; } function winratePct(pw) { if (!pw) return null; const pick = Number(pw.pick) || 0; const win = Number(pw.win) || 0; if (pick <= 0) return null; return Math.round((win / pick) * 1000) / 10; } /** Pick rate % of matches that included this hero in the sample window. */ function pickratePct(pw, bracket) { if (!pw) return null; const pick = Number(pw.pick) || 0; if (pick <= 0) return null; const matches = approxMatches(totalsForTrends(bracket)?.pick); if (matches <= 0) return null; return Math.round((pick / matches) * 1000) / 10; } /** * Ban rate % — OpenDota heroStats only has pro_ban for the pro scene. * Ranked / pub / turbo return null. */ function banratePct(pw, bracket) { if (bracket !== "pro" || !pw) return null; const ban = Number(pw.ban) || 0; if (ban <= 0) return null; const matches = approxMatches(totalsForBracket("pro")?.pick); if (matches <= 0) return null; return Math.round((ban / matches) * 1000) / 10; } function formatPickCount(n) { const v = Number(n) || 0; if (v >= 10000) return `${(v / 10000).toFixed(v >= 100000 ? 0 : 1)}万`; return String(v); } function formatRatePct(v) { if (v == null || Number.isNaN(v)) return "—"; return `${v}%`; } function formatCounterRate(v) { const n = Number(v); if (!Number.isFinite(n)) return "—"; return `${(n * 100).toFixed(1)}%`; } function fearedItemsFor(heroKey) { const fears = state.data.hero_item_fears; const byHero = fears && fears.by_hero; // null → whole dataset missing; [] → this hero has no feared items if (!byHero || !Object.keys(byHero).length) return null; const cell = byHero[heroKey]; if (cell == null) return []; return Array.isArray(cell) ? cell : []; } function heroAbilityCell(heroKey) { const cell = (state.data.hero_abilities?.by_hero || {})[heroKey]; if (cell == null) return null; // Legacy: by_hero[key] was a bare ability array if (Array.isArray(cell)) return { abilities: cell, talents: [] }; return { abilities: Array.isArray(cell.abilities) ? cell.abilities : [], talents: Array.isArray(cell.talents) ? cell.talents : [], }; } /** Floating talent popover (body-level; avoids #detail overflow clipping). */ let talentPopoverEl = null; let talentPopoverHideTimer = 0; function closeTalentPopover() { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } if (talentPopoverEl) { talentPopoverEl.remove(); talentPopoverEl = null; } document.querySelectorAll(".skill-icon.talent-trigger[aria-expanded='true']").forEach((el) => { el.setAttribute("aria-expanded", "false"); }); } function positionTalentPopover(trigger) { if (!talentPopoverEl || !trigger) return; const gap = 10; const margin = 8; const r = trigger.getBoundingClientRect(); // Measure with provisional place off-screen if needed. let pr = talentPopoverEl.getBoundingClientRect(); if (pr.width < 2) { talentPopoverEl.style.left = "0px"; talentPopoverEl.style.top = "0px"; pr = talentPopoverEl.getBoundingClientRect(); } let left = r.left + r.width / 2 - pr.width / 2; let top = r.top - pr.height - gap; left = Math.max(margin, Math.min(left, window.innerWidth - pr.width - margin)); if (top < margin) { top = r.bottom + gap; talentPopoverEl.classList.add("below"); } else { talentPopoverEl.classList.remove("below"); } talentPopoverEl.style.left = `${Math.round(left)}px`; talentPopoverEl.style.top = `${Math.round(top)}px`; // Keep the beak centered on the tree icon even when the panel is clamped. const beak = talentPopoverEl.querySelector(".talent-tree-beak"); if (beak) { const iconCx = r.left + r.width / 2; const beakX = iconCx - left; beak.style.left = `${Math.round(beakX)}px`; } } function buildTalentTreeCard(talents) { const tree = document.createElement("div"); tree.className = "talent-tree"; tree.setAttribute("aria-label", "天赋树"); const treeLab = document.createElement("div"); treeLab.className = "talent-tree-label"; treeLab.textContent = "天赋树"; tree.appendChild(treeLab); const rows = document.createElement("div"); rows.className = "talent-rows"; for (const lv of [25, 20, 15, 10]) { const row = document.createElement("div"); row.className = "talent-row"; const left = talents.find((t) => t.level === lv && t.side === "left"); const right = talents.find((t) => t.level === lv && t.side === "right"); const leftEl = document.createElement("div"); leftEl.className = "talent-cell"; leftEl.textContent = left?.name_loc || "—"; leftEl.title = left?.name_loc || ""; const mid = document.createElement("div"); mid.className = "talent-level"; const midNum = document.createElement("span"); midNum.className = "talent-level-num"; midNum.textContent = String(lv); mid.appendChild(midNum); const rightEl = document.createElement("div"); rightEl.className = "talent-cell"; rightEl.textContent = right?.name_loc || "—"; rightEl.title = right?.name_loc || ""; row.appendChild(leftEl); row.appendChild(mid); row.appendChild(rightEl); rows.appendChild(row); } tree.appendChild(rows); return tree; } function openTalentPopover(trigger, talents) { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } if (talentPopoverEl) { positionTalentPopover(trigger); trigger.setAttribute("aria-expanded", "true"); return; } const pop = document.createElement("div"); pop.className = "talent-popover"; pop.setAttribute("role", "dialog"); pop.setAttribute("aria-label", "天赋树"); pop.appendChild(buildTalentTreeCard(talents)); const beak = document.createElement("div"); beak.className = "talent-tree-beak"; beak.setAttribute("aria-hidden", "true"); pop.appendChild(beak); pop.addEventListener("mouseenter", () => { if (talentPopoverHideTimer) { clearTimeout(talentPopoverHideTimer); talentPopoverHideTimer = 0; } }); pop.addEventListener("mouseleave", () => { talentPopoverHideTimer = setTimeout(closeTalentPopover, 120); }); document.body.appendChild(pop); talentPopoverEl = pop; trigger.setAttribute("aria-expanded", "true"); positionTalentPopover(trigger); } function wireTalentTrigger(btn, talents) { btn.addEventListener("mouseenter", () => openTalentPopover(btn, talents)); btn.addEventListener("mouseleave", () => { talentPopoverHideTimer = setTimeout(closeTalentPopover, 120); }); btn.addEventListener("click", (e) => { e.stopPropagation(); if (talentPopoverEl && btn.getAttribute("aria-expanded") === "true") { closeTalentPopover(); } else { openTalentPopover(btn, talents); } }); } /** * Ability icon loader. * Innates (and shard/scepter chips of innate abilities) use the shared gold-droplet * badge — many innate keys 404 on Steam CDN, matching dota2.com. * Other abilities load `ability/{key}.png` with a quiet miss state on error. */ function setAbilityIcon(img, abilityKey, isInnate) { if (isInnate) { img.src = innateIconSrc(); img.onerror = null; return; } img.src = abilityIconSrc(abilityKey); img.onerror = () => { img.onerror = null; img.removeAttribute("src"); img.alt = ""; img.classList.add("missing"); }; } /** Copy dota2.com detail-pane fields from a raw ability row onto an entry. */ const ABILITY_DETAIL_FIELDS = [ "target_label", "affects_label", "damage_label", "immunity_label", "cast_range", "cast_point", "channel_time", "cooldown", "mana_cost", "lore_loc", ]; function withAbilityDetail(ent, ab) { for (const f of ABILITY_DETAIL_FIELDS) ent[f] = ab[f] || ""; ent.specials = Array.isArray(ab.specials) ? ab.specials : []; return ent; } /** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style). * Cached per hero key; invalidated when data reloads. */ const _skillEntriesCache = new Map(); function skillEntriesFor(heroKey) { if (_skillEntriesCache.has(heroKey)) return _skillEntriesCache.get(heroKey); const cell = heroAbilityCell(heroKey); if (cell == null) return null; const entries = []; const base = []; for (const ab of cell.abilities) { if (ab.granted_by_shard || ab.granted_by_scepter) continue; base.push( withAbilityDetail( { id: `ability:${ab.key}`, kind: "ability", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.desc_loc || "", label: ab.is_innate ? "先天技能" : "", dispellable: ab.dispellable || "none", is_innate: !!ab.is_innate, }, ab ) ); } // Official order among abilities: innate (circular) first, then Q/W/E/R. // Talent-tree trigger is prepended separately in the icon row. for (const ent of base) { if (ent.is_innate) entries.push(ent); } for (const ent of base) { if (!ent.is_innate) entries.push(ent); } for (const ab of cell.abilities) { if (ab.granted_by_shard || (ab.shard_loc && ab.has_shard)) { entries.push( withAbilityDetail( { id: `shard:${ab.key}`, kind: "shard", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.granted_by_shard ? ab.desc_loc || ab.shard_loc || "" : ab.shard_loc || ab.desc_loc || "", label: "魔晶技能升级", dispellable: ab.dispellable || "none", // Keep innate flag so Aghs chips of innates (e.g. ember_spirit_immolation) // use the shared droplet icon + circular style; CDN has no per-key PNG. is_innate: !!ab.is_innate, }, ab ) ); } } for (const ab of cell.abilities) { if (ab.granted_by_scepter || (ab.scepter_loc && ab.has_scepter)) { entries.push( withAbilityDetail( { id: `scepter:${ab.key}`, kind: "scepter", ability_key: ab.key, name_loc: ab.name_loc || ab.key, desc_loc: ab.granted_by_scepter ? ab.desc_loc || ab.scepter_loc || "" : ab.scepter_loc || ab.desc_loc || "", label: "神杖技能升级", dispellable: ab.dispellable || "none", is_innate: !!ab.is_innate, }, ab ) ); } } const result = { entries, talents: cell.talents }; _skillEntriesCache.set(heroKey, result); return result; } const DISPEL_LABEL = { yes: "可驱散", strong_only: "仅强驱散", no: "不可驱散", none: "", }; function selectInspectSkill(id) { state.inspect = id ? { type: "skill", id } : null; renderDetail(); } function selectInspectItem(key) { if (!key) return; state.inspect = { type: "item", key }; renderDetail(); } function resolveItemDetail(key) { if (!key) return null; const shop = shopItem(key); if (shop) return shop; const metaExtra = (state.data.items_meta || {})[key] || null; const items = state.data.hero_items?.items || {}; for (const row of Object.values(items)) { if (row && row.key === key) { return { key, name_loc: row.name_loc || row.dname || metaExtra?.name_loc || key, dname: row.dname || key, cost: row.cost ?? metaExtra?.cost, components: metaExtra?.components || [], builds_into: metaExtra?.builds_into || [], desc_loc: metaExtra?.desc_loc || row.desc_loc || "", }; } } if (metaExtra) { return { key, name_loc: metaExtra.name_loc || key, cost: metaExtra.cost, components: metaExtra.components || [], builds_into: metaExtra.builds_into || [], desc_loc: metaExtra.desc_loc || "", }; } return { key, name_loc: key, components: [], builds_into: [] }; } function appendItemIcon(list, { key, name, title, badge }) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "item"; btn.title = title || name || key || ""; btn.setAttribute("aria-label", title || name || key || ""); if ( key && state.inspect && state.inspect.type === "item" && state.inspect.key === key ) { btn.classList.add("selected"); } if (key) { const img = document.createElement("img"); img.alt = name || key; img.loading = "lazy"; setItemIcon(img, key); btn.appendChild(img); btn.addEventListener("click", () => selectInspectItem(key)); } else { btn.textContent = name || "?"; btn.disabled = true; } if (badge != null && badge !== "") { const mark = document.createElement("span"); mark.className = "item-usage-badge"; mark.textContent = typeof badge === "number" ? `${badge}%` : String(badge); btn.appendChild(mark); } list.appendChild(btn); return btn; } function buildSkillsPanel(heroKey) { closeTalentPopover(); const absBlock = document.createElement("div"); absBlock.className = "detail-abilities"; absBlock.setAttribute("aria-label", "技能"); const skillPack = skillEntriesFor(heroKey); if (skillPack == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无技能数据"; absBlock.appendChild(miss); return absBlock; } const { entries, talents } = skillPack; const activeId = state.inspect?.type === "skill" ? state.inspect.id : null; const icons = document.createElement("div"); icons.className = "skill-icons"; icons.setAttribute("role", "tablist"); icons.setAttribute("aria-label", "技能与升级"); if (talents.length) { const talentBtn = document.createElement("button"); talentBtn.type = "button"; talentBtn.className = "skill-icon talent-trigger"; talentBtn.setAttribute("aria-label", "天赋树"); talentBtn.setAttribute("aria-expanded", "false"); talentBtn.setAttribute("aria-haspopup", "dialog"); talentBtn.title = "天赋树"; const talentImg = document.createElement("img"); talentImg.src = talentTreeIconSrc(); talentImg.alt = "天赋树"; talentImg.loading = "lazy"; talentBtn.appendChild(talentImg); wireTalentTrigger(talentBtn, talents); icons.appendChild(talentBtn); } for (const ent of entries) { const btn = document.createElement("button"); btn.type = "button"; const isSel = !!(activeId && ent.id === activeId); btn.className = "skill-icon" + (ent.kind !== "ability" ? ` kind-${ent.kind}` : "") + (ent.is_innate ? " innate" : "") + (isSel ? " active selected" : ""); btn.setAttribute("role", "tab"); btn.setAttribute("aria-selected", isSel ? "true" : "false"); btn.title = [ent.name_loc, ent.label].filter(Boolean).join(" · "); const img = document.createElement("img"); img.alt = ent.name_loc; img.loading = "lazy"; setAbilityIcon(img, ent.ability_key, !!ent.is_innate); btn.appendChild(img); if (ent.kind === "shard" || ent.kind === "scepter") { const mark = document.createElement("span"); mark.className = "skill-aghs-mark"; mark.textContent = ent.kind === "shard" ? "晶" : "A"; btn.appendChild(mark); } btn.addEventListener("click", () => selectInspectSkill(ent.id)); icons.appendChild(btn); } absBlock.appendChild(icons); return absBlock; } function buildCoreItemsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-items detail-tab-panel"; block.setAttribute("aria-label", "核心装备"); const entries = coreItemsFor(heroKey); if (entries == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无装备数据"; block.appendChild(miss); return block; } const list = document.createElement("div"); list.className = "item-list"; if (!entries.length) { const empty = document.createElement("span"); empty.className = "detail-muted"; empty.textContent = "暂无"; list.appendChild(empty); } else { const pcts = coreItemUsagePcts(entries); for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const pct = pcts[i]; const meta = itemMeta(entry.id); const key = meta?.key; const name = meta?.name_loc || meta?.dname || (key ? key : `#${entry.id}`); const pctLabel = pct != null ? `${pct}%` : null; const tipParts = [name]; if (pctLabel) tipParts.push(`相对热度 ${pctLabel}`); if (entry.count != null) tipParts.push(`count ${entry.count}`); appendItemIcon(list, { key, name, title: tipParts.join(" · "), badge: pctLabel, }); } } block.appendChild(list); return block; } function matchRowDisplayOk(row) { if (!row || typeof row !== "object") return false; if (!row.won) return false; if (row.origin === "public") { const tier = Number(row.rank_tier ?? row.avg_rank_tier); // Legend+ : OpenDota rank_tier >= 50 if (!Number.isFinite(tier) || tier < 50) return false; } return true; } function heroMatchesFor(heroKey) { const cell = (state.data.hero_matches?.by_hero || {})[heroKey]; let rows = null; if (cell != null) { if (Array.isArray(cell)) rows = cell; else if (Array.isArray(cell.matches)) rows = cell.matches; } const proCell = (state.data.pro_matches?.by_hero || {})[heroKey]; const proRows = Array.isArray(proCell?.matches) ? proCell.matches : []; if (!rows && !proRows.length) return null; const merged = [...(rows || []), ...proRows]; const seen = new Set(); const out = []; for (const row of merged.sort( (a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0) )) { const mid = Number(row?.match_id); if (!mid || seen.has(mid)) continue; if (!matchRowDisplayOk(row)) continue; seen.add(mid); out.push(row); } return out; } /** Pad 0–9 → "00"…"09" for HH:mm. */ function pad2(n) { return String(n).padStart(2, "0"); } /** * Parse unix sec/ms, ISO string, or Date → epoch ms. Date-only YYYY-MM-DD is * treated as Asia/Shanghai midnight (avoids UTC-day shift for CN dates). */ function parseTimeMs(input) { if (input == null || input === "") return NaN; if (input instanceof Date) { const t = input.getTime(); return Number.isFinite(t) ? t : NaN; } if (typeof input === "number") { if (!Number.isFinite(input) || input <= 0) return NaN; return input > 1e12 ? input : input * 1000; } const s = String(input).trim(); if (!s) return NaN; if (/^\d+(\.\d+)?$/.test(s)) { const n = Number(s); if (!Number.isFinite(n) || n <= 0) return NaN; return n > 1e12 ? n : n * 1000; } const dayOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); if (dayOnly) { const t = new Date( `${dayOnly[1]}-${dayOnly[2]}-${dayOnly[3]}T00:00:00+08:00` ).getTime(); return Number.isFinite(t) ? t : NaN; } const t = Date.parse(s); return Number.isFinite(t) ? t : NaN; } /** Calendar + clock parts in Asia/Shanghai (site audience). */ function shanghaiParts(ms) { const parts = new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Shanghai", year: "numeric", month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit", hour12: false, }).formatToParts(new Date(ms)); const get = (type) => { const hit = parts.find((p) => p.type === type); return hit ? hit.value : ""; }; let hour = get("hour"); // Some engines emit "24" for midnight under hour12:false. if (hour === "24") hour = "00"; return { year: Number(get("year")), month: Number(get("month")), day: Number(get("day")), hour: Number(hour), minute: Number(get("minute")), }; } /** * Absolute wall time for CN UI. * Same year: "6/13 19:00"; other year: "2024/6/13 19:00". * dateOnly omits the clock. */ function formatAbsoluteTime(input, { dateOnly = false } = {}) { const ms = parseTimeMs(input); if (!Number.isFinite(ms)) return ""; const p = shanghaiParts(ms); const now = shanghaiParts(Date.now()); const date = p.year === now.year ? `${p.month}/${p.day}` : `${p.year}/${p.month}/${p.day}`; if (dateOnly) return date; return `${date} ${pad2(p.hour)}:${pad2(p.minute)}`; } /** Full absolute for tooltips: "2026/7/29 16:48". */ function formatAbsoluteTimeFull(input) { const ms = parseTimeMs(input); if (!Number.isFinite(ms)) return ""; const p = shanghaiParts(ms); return `${p.year}/${p.month}/${p.day} ${pad2(p.hour)}:${pad2(p.minute)}`; } /** * Hybrid friendly time (CN feed convention): * 刚刚 → N分钟前 → N小时前 → 昨天[ HH:mm] → N天前 → N周前 → M/D[ HH:mm]. * Returns { text, title } so callers can set hover absolute. */ function formatFriendlyTime(input, { dateOnly = false, now = Date.now() } = {}) { const ms = parseTimeMs(input); if (!Number.isFinite(ms)) return { text: "", title: "" }; const title = formatAbsoluteTimeFull(ms); const abs = () => formatAbsoluteTime(ms, { dateOnly }); const diff = now - ms; // Clock skew / future → absolute. if (diff < -60_000) return { text: abs(), title }; const sec = Math.floor(Math.max(0, diff) / 1000); if (sec < 45) return { text: "刚刚", title }; if (sec < 3600) { return { text: `${Math.max(1, Math.floor(sec / 60))}分钟前`, title }; } if (sec < 86400) { return { text: `${Math.max(1, Math.floor(sec / 3600))}小时前`, title }; } const p = shanghaiParts(ms); const n = shanghaiParts(now); const dayStart = (parts) => Date.UTC(parts.year, parts.month - 1, parts.day); const dayDiff = Math.round((dayStart(n) - dayStart(p)) / 86400000); if (dayDiff === 1) { const text = dateOnly ? "昨天" : `昨天 ${pad2(p.hour)}:${pad2(p.minute)}`; return { text, title }; } if (dayDiff >= 2 && dayDiff < 7) { return { text: `${dayDiff}天前`, title }; } if (dayDiff >= 7 && dayDiff < 45) { return { text: `${Math.max(1, Math.floor(dayDiff / 7))}周前`, title }; } return { text: abs(), title }; } function formatMatchDuration(sec) { const n = Number(sec) || 0; if (n <= 0) return "—"; const m = Math.floor(n / 60); const s = n % 60; return `${m}:${String(s).padStart(2, "0")}`; } const RANK_TIER_MEDALS = [ null, "herald", "guardian", "crusader", "archon", "legend", "ancient", "divine", "immortal", ]; /** OpenDota rank_tier → { medalKey, stars, label, iconFile }. */ function parseRankTier(tier) { const t = Number(tier); if (!Number.isFinite(t) || t < 10) return null; const medal = Math.min(8, Math.max(1, Math.floor(t / 10))); const stars = Math.max(0, t % 10); const medalKey = RANK_TIER_MEDALS[medal]; if (!medalKey) return null; const base = BRACKET_LABELS[medalKey] || medalKey; let label = base; if (medalKey === "immortal") { label = base; } else if (stars > 0) { label = `${base} ${stars}`; } return { medalKey, stars, label, iconFile: BRACKET_RANK_ICON[medalKey] || null, }; } function matchPlayerDisplayName(row) { // Never show account id. Prefer display_name / pro name (jikroy) / Steam persona. for (const key of ["display_name", "name", "personaname"]) { const raw = row?.[key]; if (typeof raw === "string" && raw.trim()) return raw.trim(); } return "匿名玩家"; } function appendMatchItemIcon(list, id, timeSec) { const meta = itemMetaFromId(id); const key = meta?.key || null; const name = meta?.name_loc || meta?.dname || (key ? key : `#${id}`); if (!key) { // Skip unknown ids — do not leave empty placeholder slots. return; } const el = document.createElement("span"); el.className = "match-icon"; const timeLabel = formatMatchItemTime(timeSec); el.title = timeLabel ? `${name} · ${timeLabel}` : name; const img = document.createElement("img"); img.alt = name; img.loading = "lazy"; setItemIcon(img, key, () => { // Drop the slot entirely when neither local nor OSS has the icon. el.remove(); }); el.appendChild(img); if (timeLabel) { const t = document.createElement("span"); t.className = "match-icon-time"; t.textContent = timeLabel; el.appendChild(t); } list.appendChild(el); } /** OpenDota-style MM:SS (supports negative pre-game seconds). */ function formatMatchItemTime(sec) { if (sec == null || sec === "") return null; const n = Number(sec); if (!Number.isFinite(n)) return null; const neg = n < 0; const abs = Math.abs(Math.trunc(n)); const m = Math.floor(abs / 60); const s = abs % 60; const body = `${m}:${String(s).padStart(2, "0")}`; return neg ? `-${body}` : body; } function appendMatchBackpackGlyph(row) { const glyph = document.createElement("span"); glyph.className = "match-backpack-glyph"; glyph.setAttribute("aria-hidden", "true"); glyph.title = "背包"; glyph.innerHTML = '"; row.appendChild(glyph); } function appendMatchAbilityIcon(list, heroKey, abilityKey, level) { const name = abilityLabelForKey(heroKey, abilityKey); const el = document.createElement("span"); el.className = "match-icon match-ability"; el.title = `Lv${level} ${name}`; const img = document.createElement("img"); const isTalent = String(abilityKey || "").startsWith("special_bonus_"); if (isTalent) { img.src = talentTreeIconSrc(); } else { setAbilityIcon(img, abilityKey, false); } img.alt = name; img.loading = "lazy"; el.appendChild(img); const lv = document.createElement("span"); lv.className = "match-ability-lv"; lv.textContent = String(level); el.appendChild(lv); list.appendChild(el); } function buildMatchCard(heroKey, row, opts = {}) { const card = document.createElement("article"); card.className = "match-card" + (row.won ? " is-win" : " is-loss"); const head = document.createElement("div"); head.className = "match-card-head"; const result = document.createElement("span"); result.className = "match-result"; result.textContent = row.won ? "胜" : "负"; head.appendChild(result); if (opts.showHero) { const hKey = heroKey || row.hero_key || null; const hero = hKey ? heroByKey(hKey) : null; const heroBtn = document.createElement(hKey ? "button" : "span"); heroBtn.className = "match-hero"; heroBtn.type = hKey ? "button" : undefined; const himg = document.createElement("img"); himg.className = "match-hero-icon"; himg.alt = hero?.name_loc || hKey || ""; himg.loading = "lazy"; if (hKey) himg.src = portraitSrc(hKey); heroBtn.appendChild(himg); const hname = document.createElement("span"); hname.className = "match-hero-name"; hname.textContent = hero?.name_loc || hKey || "未知英雄"; heroBtn.appendChild(hname); if (hKey) { heroBtn.title = `查看 ${hero?.name_loc || hKey}`; heroBtn.addEventListener("click", () => { state.page = "heroes"; state.selectedKey = hKey; state.detailTab = "matches"; state.selectedItemKey = null; state.inspect = null; syncStateToUrl(); render(); }); } head.appendChild(heroBtn); } const player = document.createElement("div"); player.className = "match-player"; const rankTier = row.rank_tier ?? row.avg_rank_tier; const rank = parseRankTier(rankTier); if (rank?.iconFile) { const medal = document.createElement("img"); medal.className = "match-rank-icon"; medal.src = rankIconSrc(rank.iconFile); let tip = rank.label; if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`; if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位"; medal.alt = tip; medal.title = tip; medal.loading = "lazy"; player.appendChild(medal); } const nameEl = document.createElement(row.account_id ? "a" : "span"); nameEl.className = "match-player-name"; nameEl.textContent = matchPlayerDisplayName(row); if (row.account_id) { nameEl.href = `https://www.dotabuff.com/players/${row.account_id}`; nameEl.target = "_blank"; nameEl.rel = "noopener noreferrer"; nameEl.title = "在 Dotabuff 查看选手"; } player.appendChild(nameEl); if (!rank?.iconFile && row.leaderboard_rank) { const rankLab = document.createElement("span"); rankLab.className = "match-rank-label"; rankLab.textContent = `#${row.leaderboard_rank}`; player.appendChild(rankLab); } head.appendChild(player); const kda = document.createElement("span"); kda.className = "match-kda"; kda.textContent = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}`; head.appendChild(kda); const dur = document.createElement("span"); dur.className = "match-duration"; dur.textContent = formatMatchDuration(row.duration); head.appendChild(dur); const origin = document.createElement("span"); origin.className = "match-origin"; const kind = matchOriginKind(row); if (kind === "china") { origin.textContent = "国服"; origin.classList.add("match-origin-china"); } else if (kind === "ladder") { origin.textContent = "天梯"; } else if (kind === "pro") { origin.textContent = "职业"; origin.classList.add("match-origin-pro"); } else { origin.textContent = "联赛"; } head.appendChild(origin); if (row.league_name) { const league = document.createElement("span"); league.className = "match-league"; league.textContent = row.league_name; league.title = row.league_name; head.appendChild(league); } const whenInfo = formatFriendlyTime(row.start_time); if (whenInfo.text) { const timeEl = document.createElement("span"); timeEl.className = "match-time"; timeEl.textContent = whenInfo.text; if (whenInfo.title) timeEl.title = whenInfo.title; head.appendChild(timeEl); } const links = document.createElement("span"); links.className = "match-links"; if (row.match_id) { const matchId = document.createElement("span"); matchId.className = "match-id"; matchId.textContent = String(row.match_id); links.appendChild(matchId); const matchLink = document.createElement("a"); matchLink.className = "match-opendota"; matchLink.href = `https://www.dotabuff.com/matches/${row.match_id}`; matchLink.target = "_blank"; matchLink.rel = "noopener noreferrer"; matchLink.textContent = "详情"; matchLink.title = "在 Dotabuff 查看本场比赛"; links.appendChild(matchLink); } if (links.childNodes.length) head.appendChild(links); card.appendChild(head); const inv = document.createElement("div"); inv.className = "match-inv"; inv.setAttribute("aria-label", "出装"); const itemsRow = document.createElement("div"); itemsRow.className = "match-items"; const itemIds = Array.isArray(row.items) ? row.items : []; const itemTimes = Array.isArray(row.item_times) ? row.item_times : []; itemIds.forEach((id, i) => appendMatchItemIcon(itemsRow, id, itemTimes[i])); if (row.item_neutral) { const neut = document.createElement("span"); neut.className = "match-neutral-slot"; appendMatchItemIcon(neut, row.item_neutral, row.item_neutral_time); if (neut.childNodes.length) itemsRow.appendChild(neut); } inv.appendChild(itemsRow); const backpack = Array.isArray(row.backpack) ? row.backpack : []; if (backpack.length) { const bpRow = document.createElement("div"); bpRow.className = "match-backpack"; bpRow.setAttribute("aria-label", "背包"); appendMatchBackpackGlyph(bpRow); const bpTimes = Array.isArray(row.backpack_times) ? row.backpack_times : []; backpack.forEach((id, i) => appendMatchItemIcon(bpRow, id, bpTimes[i])); inv.appendChild(bpRow); } card.appendChild(inv); const ups = Array.isArray(row.ability_upgrades) ? row.ability_upgrades : []; const skillsRow = document.createElement("div"); skillsRow.className = "match-skills"; skillsRow.setAttribute("aria-label", "加点"); if (!ups.length) { const missSkill = document.createElement("span"); missSkill.className = "detail-muted"; missSkill.textContent = "无加点数据"; skillsRow.appendChild(missSkill); } else { ups.forEach((key, i) => appendMatchAbilityIcon(skillsRow, heroKey, key, i + 1) ); } card.appendChild(skillsRow); return card; } function buildMatchList(heroKey, rows, opts = {}) { const list = document.createElement("div"); list.className = "match-list"; for (const row of rows) { list.appendChild(buildMatchCard(heroKey || row.hero_key, row, opts)); } return list; } function buildMatchesPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-matches-panel detail-tab-panel"; block.setAttribute("aria-label", "近期比赛"); const rows = heroMatchesFor(heroKey); if (rows == null) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无比赛数据"; block.appendChild(miss); return block; } const displayRows = (rows || []).slice(0, MATCHES_DISPLAY_LIMIT); if (!displayRows.length) { const empty = document.createElement("div"); empty.className = "detail-muted"; empty.textContent = "暂无近期比赛"; block.appendChild(empty); return block; } block.appendChild(buildMatchList(heroKey, displayRows)); return block; } function bracketSelectOptions() { const pack = heroStatsPack(); const brackets = Array.isArray(pack.brackets) && pack.brackets.length ? pack.brackets : [ "herald", "guardian", "crusader", "archon", "legend", "ancient", "divine", "immortal", ]; // Trends UI mirrors the in-game medal row (no pub / pro / turbo chips). return brackets.filter((k) => BRACKET_RANK_ICON[k]); } function buildInfoTipButton(tipText, extraClass = "") { const tipBtn = document.createElement("button"); tipBtn.type = "button"; tipBtn.className = extraClass ? `bracket-info-btn ${extraClass}` : "bracket-info-btn"; tipBtn.setAttribute("aria-label", tipText); tipBtn.title = tipText; tipBtn.textContent = "i"; tipBtn.addEventListener("click", (e) => { e.stopPropagation(); tipBtn.classList.toggle("open"); }); const bubble = document.createElement("div"); bubble.className = "bracket-info-bubble"; bubble.setAttribute("role", "tooltip"); bubble.textContent = tipText; tipBtn.appendChild(bubble); return tipBtn; } /** Horizontal rank-medal picker (images only; label via title/aria). */ function buildBracketIconPicker({ selectedKey = null, onChange = null, ariaLabel = "统计段位", showImmortalTip = true, } = {}) { const current = selectedKey != null ? selectedKey : state.statsBracket; const row = document.createElement("div"); row.className = "bracket-icon-picker"; row.setAttribute("role", "radiogroup"); row.setAttribute("aria-label", ariaLabel); for (const key of bracketSelectOptions()) { const wrap = document.createElement("div"); wrap.className = "bracket-icon-wrap" + (key === "immortal" ? " has-tip" : ""); const btn = document.createElement("button"); btn.type = "button"; btn.className = "bracket-icon-btn" + (key === current ? " active" : ""); btn.setAttribute("role", "radio"); btn.setAttribute("aria-checked", key === current ? "true" : "false"); const label = BRACKET_LABELS[key] || key; btn.title = label; btn.setAttribute("aria-label", label); const file = BRACKET_RANK_ICON[key]; const img = document.createElement("img"); img.src = rankIconSrc(file || "rank_icon_1.png"); img.alt = label; img.loading = "lazy"; img.decoding = "async"; img.draggable = false; btn.appendChild(img); btn.addEventListener("click", () => { if (current === key) return; if (typeof onChange === "function") { onChange(key); } else { state.statsBracket = key; renderDetail(); } }); wrap.appendChild(btn); if (showImmortalTip && key === "immortal") { wrap.appendChild(buildInfoTipButton(IMMORTAL_MERGE_TIP)); } row.appendChild(wrap); } return row; } function stratzMetaPack() { return ( state.data?.stratz_hero_meta || { by_hero: {}, totals: {}, meta_board: {}, brackets: [], fetched_at: null, window_label_zh: null, latest_window_label_zh: "最近 1 周", attribution: "https://stratz.com", } ); } function stratzMetaFor(heroKey) { const cell = (stratzMetaPack().by_hero || {})[heroKey]; return cell && typeof cell === "object" ? cell : null; } function stratzMatchupPack() { return ( state.data?.stratz_matchup_tops || { by_hero: {}, fetched_at: null, started_at: null, finished_at: null, attribution: "https://stratz.com", scope: { kind: "global_aggregate", label_zh: "全局聚合(未按段位 / 分路 / 周过滤)", }, stats: {}, } ); } function stratzMatchupsFor(heroKey) { const cell = (stratzMatchupPack().by_hero || {})[heroKey]; return cell && typeof cell === "object" ? cell : null; } function heroKeyById(id) { const n = Number(id); if (!Number.isFinite(n)) return null; for (const h of state.data?.heroes || []) { if (Number(h.id) === n) return h.key; } return null; } function stratzLatestFor(cell, bracket) { if (!cell) return null; return (cell.latest && cell.latest[bracket]) || null; } function stratzPickratePct(pw, bracket) { if (!pw) return null; const pick = Number(pw.pick) || 0; if (pick <= 0) return null; const total = Number((stratzMetaPack().totals || {})[bracket]?.pick) || 0; const matches = approxMatches(total); if (matches <= 0) return null; return Math.round((pick / matches) * 1000) / 10; } function formatAdvantage(v) { if (v == null || Number.isNaN(Number(v))) return "—"; const n = Number(v); return n >= 0 ? `+${n.toFixed(1)}` : n.toFixed(1); } function formatWeekTrendLabel(weekUnix, index) { if (index === 0) return "本周"; if (index === 1) return "上周"; const ts = Number(weekUnix); if (!Number.isFinite(ts) || ts <= 0) return `${index}周前`; const d = new Date(ts * 1000); return `${d.getMonth() + 1}/${d.getDate()}`; } function buildWeekSparkline(weeks) { const wrap = document.createElement("div"); wrap.className = "trends-week-chart"; wrap.setAttribute("aria-label", "近 8 周胜率"); if (!Array.isArray(weeks) || weeks.length < 2) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无周走势"; wrap.appendChild(miss); return wrap; } const head = document.createElement("div"); head.className = "trends-section-label trends-week-head"; head.textContent = "近 8 周胜率"; wrap.appendChild(head); // Newest-first (same order as stored latest→oldest). const ordered = weeks.slice(); const wrs = ordered.map((w) => { if (w.wr != null) return Number(w.wr) * 100; const pick = Number(w.pick) || 0; const win = Number(w.win) || 0; return pick > 0 ? (win / pick) * 100 : null; }); const known = wrs.filter((x) => x != null); // Zoom to this hero's 8-week range so 52.1% vs 52.4% still reads as length. let scaleMin = known.length ? Math.min(...known) : 48; let scaleMax = known.length ? Math.max(...known) : 52; const pad = Math.max(0.4, (scaleMax - scaleMin) * 0.35); scaleMin -= pad; scaleMax += pad; if (scaleMax - scaleMin < 1.2) { const mid = (scaleMax + scaleMin) / 2; scaleMin = mid - 0.6; scaleMax = mid + 0.6; } const span = scaleMax - scaleMin; const best = known.length ? Math.max(...known) : null; const worst = known.length ? Math.min(...known) : null; const list = document.createElement("div"); list.className = "trends-week-list"; for (let i = 0; i < ordered.length; i++) { const w = ordered[i]; const wr = wrs[i]; const older = i + 1 < wrs.length ? wrs[i + 1] : null; const delta = wr != null && older != null ? Math.round((wr - older) * 10) / 10 : null; const row = document.createElement("div"); row.className = "trends-week-row" + (i === 0 ? " is-latest" : ""); if (wr != null && best != null && wr === best && best !== worst) { row.classList.add("is-best"); } const lab = document.createElement("span"); lab.className = "trends-week-label"; lab.textContent = formatWeekTrendLabel(w.week, i); const val = document.createElement("span"); val.className = "trends-week-wr" + (wr == null ? "" : wr >= 50 ? " is-up" : " is-down"); val.textContent = wr == null ? "—" : `${wr.toFixed(1)}%`; const dlt = document.createElement("span"); dlt.className = "trends-week-delta"; if (delta == null) { dlt.textContent = ""; dlt.classList.add("is-empty"); } else if (delta === 0) { dlt.textContent = "0"; dlt.classList.add("is-flat"); } else if (delta > 0) { dlt.textContent = `+${delta.toFixed(1)}`; dlt.classList.add("is-up"); } else { dlt.textContent = delta.toFixed(1); dlt.classList.add("is-down"); } const track = document.createElement("div"); track.className = "trends-week-track"; track.setAttribute("aria-hidden", "true"); if (wr != null) { // Left-aligned fill within zoomed range (not anchored at 50%). const pct = Math.max(8, Math.min(100, ((wr - scaleMin) / span) * 100)); const fill = document.createElement("div"); fill.className = "trends-week-fill" + (wr >= 50 ? " is-up" : " is-down"); fill.style.width = `${pct}%`; track.appendChild(fill); } const pick = document.createElement("span"); pick.className = "trends-week-pick detail-muted"; pick.textContent = formatPickCount(w.pick); row.appendChild(lab); row.appendChild(val); row.appendChild(dlt); row.appendChild(track); row.appendChild(pick); const deltaTip = delta == null ? "" : delta === 0 ? " · 环比持平" : ` · 环比 ${delta > 0 ? "+" : ""}${delta.toFixed(1)}pp`; row.title = wr == null ? "无数据" : `胜率 ${wr.toFixed(1)}% · ${formatPickCount(w.pick)} 场${deltaTip}`; list.appendChild(row); } wrap.appendChild(list); return wrap; } function buildPositionRow(positions, bracket) { const row = document.createElement("div"); row.className = "trends-positions"; row.setAttribute("aria-label", "分路胜率"); const map = (positions && positions[bracket]) || {}; let any = false; for (const pos of Object.keys(POSITION_LABELS)) { const cell = map[pos]; const pick = Number(cell?.pick) || 0; if (pick <= 0) continue; any = true; const wr = winratePct(cell); const chip = document.createElement("div"); chip.className = "trends-pos-chip"; chip.title = `${POSITION_LABELS[pos]} · ${formatPickCount(pick)} 场`; const lab = document.createElement("span"); lab.className = "trends-pos-label"; lab.textContent = POSITION_LABELS[pos]; const val = document.createElement("span"); val.className = "trends-pos-value"; val.textContent = formatRatePct(wr); chip.appendChild(lab); chip.appendChild(val); row.appendChild(chip); } if (!any) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无分路样本(该段位最近一周场次不足)"; row.appendChild(miss); } return row; } function buildStatsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-stats-panel detail-tab-panel trends-panel"; block.setAttribute("aria-label", "走势"); const stratzCell = stratzMetaFor(heroKey); const odotaCell = heroStatsFor(heroKey); if (!stratzCell && !odotaCell) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无走势数据"; block.appendChild(miss); return block; } if (!bracketSelectOptions().includes(state.statsBracket)) { state.statsBracket = "legend"; } const bracket = state.statsBracket; const useStratz = !!stratzCell; const rankBar = document.createElement("div"); rankBar.className = "trends-rank-bar"; rankBar.appendChild(buildBracketIconPicker()); block.appendChild(rankBar); if (useStratz) { const hint = document.createElement("div"); hint.className = "trends-window-hint detail-muted"; hint.textContent = stratzMetaPack().latest_window_label_zh || "最近 1 周"; block.appendChild(hint); } let wr; let pr; let games; if (useStratz) { const pw = stratzLatestFor(stratzCell, bracket); wr = winratePct(pw); pr = stratzPickratePct(pw, bracket); games = Number(pw?.pick) || 0; } else { const pw = pickWinForTrends(odotaCell, bracket); wr = winratePct(pw); pr = pickratePct(pw, bracket); games = Number(pw?.pick) || 0; } const cards = document.createElement("div"); cards.className = "trends-metric-cards"; for (const def of [ { key: "wr", label: "胜率", value: formatRatePct(wr), tone: "wr" }, { key: "pr", label: "上场率", value: formatRatePct(pr), tone: "pr" }, { key: "games", label: "场次", value: games > 0 ? formatPickCount(games) : "—", tone: "games", }, ]) { const card = document.createElement("div"); card.className = `trends-metric-card tone-${def.tone}`; const lab = document.createElement("div"); lab.className = "trends-metric-label"; lab.textContent = def.label; const val = document.createElement("div"); val.className = "trends-metric-value"; val.textContent = def.value; card.appendChild(lab); card.appendChild(val); cards.appendChild(card); } block.appendChild(cards); if (useStratz) { const weeks = (stratzCell.weeks && stratzCell.weeks[bracket]) || []; block.appendChild(buildPositionRow(stratzCell.positions, bracket)); block.appendChild(buildWeekSparkline(weeks)); } if (!useStratz) { const foot = document.createElement("div"); foot.className = "detail-muted detail-stats-note"; const pack = heroStatsPack(); const fetchedInfo = pack.fetched_at ? formatFriendlyTime(pack.fetched_at) : { text: "", title: "" }; const fetched = fetchedInfo.text ? `拉取于 ${fetchedInfo.text}` : ""; foot.textContent = [ `OpenDota ${heroStatsWindowLabel()}`, BRACKET_LABELS[bracket] || bracket, fetched, ] .filter(Boolean) .join(" · "); if (fetchedInfo.title) foot.title = fetchedInfo.title; block.appendChild(foot); } return block; } function formatMatchupWr(wr) { if (wr == null || Number.isNaN(Number(wr))) return "—"; return `${(Number(wr) * 100).toFixed(1)}%`; } function buildMatchupColumn(title, entries, scoreKey, scoreLabel) { const col = document.createElement("div"); col.className = "matchup-col"; const h = document.createElement("h3"); h.className = "matchup-col-title"; h.textContent = title; col.appendChild(h); const list = document.createElement("div"); list.className = "matchup-list"; if (!entries || !entries.length) { const empty = document.createElement("div"); empty.className = "detail-muted"; empty.textContent = "暂无"; list.appendChild(empty); col.appendChild(list); return col; } for (const row of entries) { const peerKey = heroKeyById(row.hero_id); const hero = peerKey ? heroByKey(peerKey) : null; const item = document.createElement("button"); item.type = "button"; item.className = "matchup-row"; if (peerKey) { item.addEventListener("click", () => { state.selectedKey = peerKey; state.detailTab = "matchups"; state.inspect = null; syncStateToUrl(); render(); }); } else { item.disabled = true; } const img = document.createElement("img"); img.className = "matchup-portrait"; img.src = peerKey ? portraitSrc(peerKey) : ""; img.alt = hero?.name_loc || peerKey || ""; img.loading = "lazy"; img.decoding = "async"; const mid = document.createElement("div"); mid.className = "matchup-mid"; const name = document.createElement("span"); name.className = "matchup-name"; name.textContent = hero?.name_loc || peerKey || `#${row.hero_id}`; const meta = document.createElement("span"); meta.className = "matchup-meta detail-muted"; const wrText = formatMatchupWr(row.wr); meta.textContent = [`胜率 ${wrText}`, `${formatPickCount(row.games)} 场`].join( " · " ); mid.appendChild(name); mid.appendChild(meta); const score = document.createElement("span"); score.className = "matchup-score"; score.textContent = formatAdvantage(row[scoreKey]); score.title = [ `STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`, `对局胜率 ${wrText}`, `${formatPickCount(row.games)} 场`, ].join(" · "); item.appendChild(img); item.appendChild(mid); item.appendChild(score); list.appendChild(item); } col.appendChild(list); return col; } function buildMatchupsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-matchups-panel detail-tab-panel"; block.setAttribute("aria-label", "数据对位"); const pack = stratzMatchupPack(); const cell = stratzMatchupsFor(heroKey); if (!cell) { const miss = document.createElement("div"); miss.className = "detail-muted"; miss.textContent = "暂无对位数据"; block.appendChild(miss); return block; } const grid = document.createElement("div"); grid.className = "matchup-grid"; grid.appendChild( buildMatchupColumn("克制", cell.counters, "advantage", "STRATZ 相对优势") ); grid.appendChild( buildMatchupColumn("被克", cell.countered, "advantage", "STRATZ 相对劣势") ); grid.appendChild( buildMatchupColumn("搭档", cell.synergies, "synergy", "STRATZ synergy") ); block.appendChild(grid); const notes = document.createElement("div"); notes.className = "matchup-notes detail-muted"; const hint = document.createElement("p"); hint.className = "matchup-note-line"; hint.textContent = "数据来自 STRATZ(相对优势,全局聚合)。"; notes.appendChild(hint); const stale = cell.stale === true; if (stale) { const line = document.createElement("p"); line.className = "matchup-note-line is-stale"; line.textContent = "本轮更新失败,当前仍是上一版数据,可能已过时。"; notes.appendChild(line); } const fetchedInfo = formatFriendlyTime( cell.fetched_at || pack.fetched_at ); if (fetchedInfo.text) { const time = document.createElement("p"); time.className = "matchup-note-time"; time.textContent = `更新于 ${fetchedInfo.text}`; if (fetchedInfo.title) time.title = fetchedInfo.title; notes.appendChild(time); } block.appendChild(notes); return block; } function buildFearItemsPanel(heroKey) { const block = document.createElement("div"); block.className = "detail-items detail-tab-panel"; block.setAttribute("aria-label", "被克装备"); const fears = fearedItemsFor(heroKey); const list = document.createElement("div"); list.className = "item-list"; if (fears == null) { const miss = document.createElement("span"); miss.className = "detail-muted"; miss.textContent = "暂无克制装备数据"; list.appendChild(miss); } else if (!fears.length) { const empty = document.createElement("span"); empty.className = "detail-muted"; empty.textContent = "暂无"; list.appendChild(empty); } else { for (const entry of fears) { const key = entry.item; const name = entry.name_loc || key; const reason = entry.reason || ""; const stats = entry.stats || null; const rawPurchaseRate = stats?.purchase_rate; const hasPurchaseRate = rawPurchaseRate != null && rawPurchaseRate !== "" && Number.isFinite(Number(rawPurchaseRate)); const purchaseRate = hasPurchaseRate ? formatCounterRate(stats.purchase_rate) : null; const tipParts = [name, reason].filter(Boolean); if (hasPurchaseRate) { tipParts.push(`对阵购买率 ${purchaseRate}`); if (stats.games) tipParts.push(`样本 ${stats.games} 场`); } const item = document.createElement("div"); item.className = "fear-item"; appendItemIcon(item, { key, name, title: tipParts.join(" · "), badge: purchaseRate, }); list.appendChild(item); } } block.appendChild(list); return block; } function mountSkillInspect(root, skillEnt, heroKey) { root.classList.remove("empty"); // Head: name (+ shard/scepter label). The large icon is intentionally // omitted — the selected skill is already highlighted in the icon row above. const head = document.createElement("div"); head.className = "skill-info-head"; const titles = document.createElement("div"); titles.className = "skill-info-titles"; const nameEl = document.createElement("div"); nameEl.className = "skill-info-name"; nameEl.textContent = skillEnt.name_loc; titles.appendChild(nameEl); if (skillEnt.label) { const lab = document.createElement("div"); lab.className = "skill-info-label" + (skillEnt.kind === "shard" ? " shard" : skillEnt.kind === "scepter" ? " scepter" : ""); lab.textContent = skillEnt.label; titles.appendChild(lab); } head.appendChild(titles); // Official-page layout: demo clip on the left, name/desc column on the right. const body = document.createElement("div"); body.className = "skill-info-body"; // Official demo clip: local `/ability-video/...` (serve_relations) or OSS base // from config.js (static export / Pages). Hidden when the file is missing. if (heroKey && skillEnt.ability_key) { const video = document.createElement("video"); video.className = "skill-info-video"; video.src = abilityVideoUrl(heroKey, skillEnt.ability_key); video.muted = true; video.loop = true; video.autoplay = true; video.playsInline = true; video.crossOrigin = "anonymous"; video.addEventListener("error", () => video.remove()); body.appendChild(video); } const main = document.createElement("div"); main.className = "skill-info-main"; main.appendChild(head); const desc = document.createElement("div"); desc.className = "skill-info-desc"; desc.textContent = stripItemDesc(skillEnt.desc_loc) || "暂无描述"; main.appendChild(desc); // Params panel, dota2.com.cn layout: a two-column "generic" grid of meta // attributes + a flowing grid of special values, so the column fills its // width instead of stacking a single narrow list. const params = document.createElement("div"); params.className = "skill-params"; const appendParam = (parent, label, value) => { const row = document.createElement("div"); row.className = "skill-param"; const lab = document.createElement("span"); lab.className = "skill-param-label"; // Defensive: stale caches may still carry Valve in special labels. lab.textContent = stripItemDesc(label) + ":"; const val = document.createElement("span"); val.className = "skill-param-value"; val.textContent = value; row.appendChild(lab); row.appendChild(val); parent.appendChild(row); }; const metaRows = [ ["技能", skillEnt.target_label], ["影响", skillEnt.affects_label], ["伤害类型", skillEnt.damage_label], ["无视技能免疫", skillEnt.immunity_label], [ "可被驱散", skillEnt.kind === "ability" ? DISPEL_LABEL[skillEnt.dispellable] || "" : "", ], ["施法距离", skillEnt.cast_range], ["施法前摇", skillEnt.cast_point], ["吟唱时间", skillEnt.channel_time], ].filter(([, v]) => v); if (metaRows.length) { const generic = document.createElement("div"); generic.className = "skill-generic"; for (const [label, value] of metaRows) appendParam(generic, label, value); params.appendChild(generic); } const specials = Array.isArray(skillEnt.specials) ? skillEnt.specials : []; if (specials.length) { const specific = document.createElement("div"); specific.className = "skill-specific"; for (const sp of specials) appendParam(specific, sp.label, sp.value); params.appendChild(specific); } if (params.childElementCount) { main.appendChild(params); } // Bottom cluster (costs + lore) shares one margin-top:auto so lore is not // pushed past the column height after the sunk cooldown row. const foot = document.createElement("div"); foot.className = "skill-info-foot"; const passiveLike = skillEnt.target_label === "被动" || skillEnt.target_label === "光环"; if (!passiveLike && (skillEnt.cooldown || skillEnt.mana_cost)) { const costs = document.createElement("div"); costs.className = "skill-bottom"; if (skillEnt.cooldown) { const cd = document.createElement("span"); cd.className = "skill-stat cooldown"; const ico = document.createElement("img"); ico.className = "skill-stat-icon"; ico.src = assetUrl("ui-icon/cooldown.png"); ico.alt = ""; const txt = document.createElement("span"); txt.className = "skill-stat-text"; txt.textContent = skillEnt.cooldown; cd.appendChild(ico); cd.appendChild(txt); costs.appendChild(cd); } if (skillEnt.mana_cost) { const mp = document.createElement("span"); mp.className = "skill-stat mana"; const ico = document.createElement("span"); ico.className = "skill-stat-icon mana"; const txt = document.createElement("span"); txt.className = "skill-stat-text"; txt.textContent = skillEnt.mana_cost; mp.appendChild(ico); mp.appendChild(txt); costs.appendChild(mp); } foot.appendChild(costs); } if (skillEnt.lore_loc) { const lore = document.createElement("div"); lore.className = "skill-info-lore"; lore.textContent = stripItemDesc(skillEnt.lore_loc); foot.appendChild(lore); } if (foot.childElementCount) { main.appendChild(foot); } body.appendChild(main); root.appendChild(body); } function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) { root.classList.remove("empty"); const wrap = document.createElement("div"); wrap.className = "item-detail-top"; const nameRow = document.createElement("div"); nameRow.className = "item-detail-name-row"; const title = document.createElement("h3"); title.className = "item-detail-name"; title.textContent = meta.name_loc || meta.key; nameRow.appendChild(title); if (meta.cost != null) { const cost = document.createElement("span"); cost.className = "detail-item-cost"; cost.textContent = String(meta.cost); nameRow.appendChild(cost); } wrap.appendChild(nameRow); const head = document.createElement("div"); head.className = "item-detail-head"; if (!hideIcon) { const icon = document.createElement("img"); icon.className = "item-detail-large-icon"; icon.src = itemIconSrc(meta.key); icon.alt = meta.name_loc || meta.key; head.appendChild(icon); } const info = document.createElement("div"); info.className = "item-detail-info"; const descText = stripItemDesc(meta.desc_loc); if (descText) { const desc = document.createElement("div"); desc.className = "skill-info-desc"; desc.textContent = descText; info.appendChild(desc); } else { const muted = document.createElement("div"); muted.className = "detail-muted"; muted.textContent = meta.section_label || "物品"; info.appendChild(muted); } head.appendChild(info); wrap.appendChild(head); const comps = meta.components || []; if (comps.length) { const recipe = document.createElement("div"); recipe.className = "item-detail-recipe"; const recipeLabel = document.createElement("div"); recipeLabel.className = "craft-label"; recipeLabel.textContent = "合成"; recipe.appendChild(recipeLabel); const row = document.createElement("div"); row.className = "craft-row"; for (const key of comps) { row.appendChild(makeCraftChip(key, { onPick: onPickItem })); } recipe.appendChild(row); wrap.appendChild(recipe); } const ups = meta.builds_into || []; if (ups.length) { const upBlock = document.createElement("div"); upBlock.className = "item-detail-upgrades"; const upLabel = document.createElement("div"); upLabel.className = "craft-label"; upLabel.textContent = "可升级为"; upBlock.appendChild(upLabel); const row = document.createElement("div"); row.className = "craft-row craft-upgrades"; for (const key of ups) { row.appendChild(makeCraftChip(key, { onPick: onPickItem })); } upBlock.appendChild(row); wrap.appendChild(upBlock); } root.appendChild(wrap); } /** First normal ability (typically Q); never default to talent tree or innate. */ function defaultSkillEntry(heroKey) { const pack = skillEntriesFor(heroKey); if (!pack?.entries?.length) return null; return ( pack.entries.find((e) => e.kind === "ability" && !e.is_innate) || null ); } function defaultItemKey(heroKey) { if (state.detailTab === "core") { const entries = coreItemsFor(heroKey); if (!entries?.length) return null; return itemMeta(entries[0].id)?.key || null; } if (state.detailTab === "fears") { const fears = fearedItemsFor(heroKey); if (!fears?.length) return null; const first = fears[0]; return typeof first === "string" ? first : first?.item || first?.key || null; } return null; } function ensureHeroInspectDefault(heroKey) { if (state.inspect) { if (state.inspect.type === "skill") { const pack = skillEntriesFor(heroKey); if (pack?.entries?.some((e) => e.id === state.inspect.id)) return; } if (state.inspect.type === "item") { // Keep only if still on an items tab; otherwise fall through to tab default. if (state.detailTab === "core" || state.detailTab === "fears") return; } } if (state.detailTab === "skills") { const first = defaultSkillEntry(heroKey); if (first) { state.inspect = { type: "skill", id: first.id }; return; } } else if (state.detailTab === "core" || state.detailTab === "fears") { const key = defaultItemKey(heroKey); if (key) { state.inspect = { type: "item", key }; return; } } state.inspect = null; } function renderHeroInspect(heroKey) { const pane = document.createElement("div"); pane.className = "hero-inspect"; pane.setAttribute("aria-label", "选中详情"); ensureHeroInspectDefault(heroKey); if (!state.inspect) { pane.classList.add("empty"); pane.textContent = state.detailTab === "skills" ? "点击技能查看详情" : "点击装备查看详情与合成"; return pane; } if (state.inspect.type === "skill") { const pack = skillEntriesFor(heroKey); const ent = pack?.entries?.find((e) => e.id === state.inspect.id); if (!ent) { pane.classList.add("empty"); pane.textContent = "点击技能查看详情"; return pane; } mountSkillInspect(pane, ent, heroKey); return pane; } const meta = resolveItemDetail(state.inspect.key); if (!meta) { pane.classList.add("empty"); pane.textContent = "未找到物品数据"; return pane; } mountItemInspect(pane, meta, { onPickItem: selectInspectItem, hideIcon: true }); return pane; } function fmtGain(v) { if (v == null || Number.isNaN(Number(v))) return ""; const n = Number(v); const s = Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, ""); return `+${s}`; } function fmtNum(v, digits = 1) { if (v == null || Number.isNaN(Number(v))) return ""; const n = Number(v); if (Number.isInteger(n)) return String(n); return n.toFixed(digits).replace(/\.?0+$/, ""); } function fmtRegen(v) { if (v == null || Number.isNaN(Number(v))) return null; return `+${fmtNum(v, 2)}`; } /** Shared HP/Mana bar scale: max base resource across all heroes (cached). */ let _resourceBarScaleCache = null; function resourceBarScale() { if (_resourceBarScaleCache != null) return _resourceBarScaleCache; let max = 0; for (const h of state.data?.heroes || []) { if (h.health != null) max = Math.max(max, Number(h.health) || 0); if (h.mana != null) max = Math.max(max, Number(h.mana) || 0); } _resourceBarScaleCache = max > 0 ? max : 1000; return _resourceBarScaleCache; } /** Fill width % on shared scale; clamp so tiny values stay visible. */ function resourceBarPct(value, scale) { const MIN_PCT = 8; const n = Number(value); if (!Number.isFinite(n) || n <= 0 || !(scale > 0)) return MIN_PCT; return Math.max(MIN_PCT, Math.min(100, (n / scale) * 100)); } function appendVitalBar(parent, kind, label, value, regen, scale) { const bar = document.createElement("div"); bar.className = `hero-stat-bar ${kind}`; const pct = resourceBarPct(value, scale); bar.innerHTML = `` + `
` + `${label}` + `${value}` + (regen ? `${regen}` : "") + `` + `
`; parent.appendChild(bar); } /** Left column: HP/Mana + STR/AGI/INT (skills tab only). */ function buildHeroVitalsAttrs(hero) { if (hero.base_str == null && hero.health == null) return null; const strip = document.createElement("div"); strip.className = "hero-stats"; strip.setAttribute("aria-label", "基础属性"); const vitals = document.createElement("div"); vitals.className = "hero-stats-vitals"; const scale = resourceBarScale(); if (hero.health != null) { appendVitalBar( vitals, "hp", "生命", hero.health, fmtRegen(hero.health_regen), scale ); } if (hero.mana != null) { appendVitalBar( vitals, "mana", "魔法", hero.mana, fmtRegen(hero.mana_regen), scale ); } if (vitals.childNodes.length) strip.appendChild(vitals); const attrs = document.createElement("div"); attrs.className = "hero-stats-attrs"; const primary = hero.attr || "all"; const attrDefs = [ { key: "str", label: "力量", base: hero.base_str, gain: hero.str_gain }, { key: "agi", label: "敏捷", base: hero.base_agi, gain: hero.agi_gain }, { key: "int", label: "智力", base: hero.base_int, gain: hero.int_gain }, ]; for (const a of attrDefs) { if (a.base == null) continue; const cell = document.createElement("div"); cell.className = "hero-stat-attr" + (primary === a.key || primary === "all" ? " primary" : ""); cell.setAttribute("title", a.label); cell.innerHTML = `${a.label}` + `${a.base}` + `${fmtGain(a.gain)}`; attrs.appendChild(cell); } if (attrs.childNodes.length) strip.appendChild(attrs); return strip.childNodes.length ? strip : null; } /** Official dota2.com.cn herostatic/stats icons (served from /ui-icon/). */ function combatStatIconSrc(name) { return assetUrl(`ui-icon/${name}`); } /** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */ function buildHeroCombatStats(hero) { // Rows: [iconFile, labelZh, value] — icons match dota2.com.cn hero pages. const combatGroups = [ { title: "攻击", rows: [ hero.damage_min != null && hero.damage_max != null ? ["icon_damage.png", "伤害", `${hero.damage_min}–${hero.damage_max}`] : null, hero.attack_rate != null ? ["icon_attack_time.png", "间隔", fmtNum(hero.attack_rate, 1)] : null, hero.attack_range != null ? ["icon_attack_range.png", "距离", String(hero.attack_range)] : null, // Melee heroes often have projectile_speed 0 in OpenDota — skip those. hero.projectile_speed != null && Number(hero.projectile_speed) > 0 ? [ "icon_projectile_speed.png", "弹道", String(hero.projectile_speed), ] : null, ].filter(Boolean), }, { title: "防御", rows: [ hero.armor != null ? ["icon_armor.png", "护甲", fmtNum(hero.armor, 1)] : null, hero.magic_resist != null ? ["icon_magic_resist.png", "魔抗", `${hero.magic_resist}%`] : null, ].filter(Boolean), }, { title: "机动性", rows: [ hero.move_speed != null ? ["icon_movement_speed.png", "移速", String(hero.move_speed)] : null, // OpenDota omits the engine default 0.6; show it like dota2.com.cn. [ "icon_turn_rate.png", "转身", fmtNum(hero.turn_rate != null ? hero.turn_rate : 0.6, 1), ], [ "icon_vision.png", "视野", `${hero.vision_day != null ? hero.vision_day : 1800} / ${ hero.vision_night != null ? hero.vision_night : 800 }`, ], ].filter(Boolean), }, ].filter((g) => g.rows.length); if (!combatGroups.length) return null; const panel = document.createElement("aside"); panel.className = "hero-stats hero-stats-combat-panel"; panel.setAttribute("aria-label", "战斗属性"); const combat = document.createElement("div"); combat.className = "hero-stats-combat"; combat.style.setProperty("--combat-cols", String(combatGroups.length)); combat.style.setProperty( "--combat-rows", String(Math.max(...combatGroups.map((g) => g.rows.length))) ); for (const g of combatGroups) { const section = document.createElement("section"); section.className = "hero-stats-combat-section"; const title = document.createElement("h4"); title.className = "hero-stats-combat-title"; title.textContent = g.title; section.appendChild(title); const list = document.createElement("dl"); list.className = "hero-stats-combat-list"; for (const [icon, label, val] of g.rows) { const row = document.createElement("div"); row.className = "hero-stat-row"; const dt = document.createElement("dt"); const img = document.createElement("img"); img.className = "hero-stat-icon"; img.src = combatStatIconSrc(icon); img.alt = label; img.title = label; img.width = 20; img.height = 20; img.decoding = "async"; dt.appendChild(img); const dd = document.createElement("dd"); dd.textContent = val; row.appendChild(dt); row.appendChild(dd); list.appendChild(row); } section.appendChild(list); combat.appendChild(section); } panel.appendChild(combat); return panel; } function renderDetail() { const root = $("#detail"); if (!root) return; root.innerHTML = ""; if (!state.selectedKey) { root.classList.add("empty"); root.innerHTML = ""; syncDetailDrawer(); return; } root.classList.remove("empty"); const hero = heroByKey(state.selectedKey); if (!hero) { root.classList.add("empty"); root.textContent = "未找到英雄数据"; syncDetailDrawer(); return; } const handle = document.createElement("button"); handle.type = "button"; handle.className = "detail-drawer-handle"; handle.setAttribute("aria-label", "下滑或点击关闭详情"); root.appendChild(handle); const head = document.createElement("div"); head.className = "detail-head"; const title = document.createElement("h2"); title.className = "detail-name"; title.textContent = hero.name_loc || hero.key; head.appendChild(title); const enParts = []; if (hero.name) enParts.push(hero.name); const abbrs = (hero.abbr || []).map((a) => String(a || "").trim()).filter(Boolean); if (abbrs.length) { enParts.push(abbrs.map((a) => a.toUpperCase()).join(" / ")); } if (enParts.length) { const meta = document.createElement("p"); meta.className = "detail-name-meta"; meta.textContent = enParts.join(" · "); head.appendChild(meta); } root.appendChild(head); const tabDefs = [ { id: "skills", label: "技能" }, { id: "core", label: "核心装备" }, { id: "fears", label: "被克装备" }, { id: "trends", label: "走势" }, { id: "matchups", label: "对位" }, { id: "matches", label: "近期比赛" }, { id: "streamers", label: "主播" }, { id: "patches", label: "改动" }, ]; if (state.detailTab === "stats") state.detailTab = "trends"; if (!tabDefs.some((t) => t.id === state.detailTab)) { state.detailTab = "skills"; } const tabbar = document.createElement("div"); tabbar.className = "detail-tabs"; tabbar.setAttribute("role", "tablist"); tabbar.setAttribute("aria-label", "详情分页"); for (const t of tabDefs) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "detail-tab" + (state.detailTab === t.id ? " active" : ""); btn.setAttribute("role", "tab"); btn.setAttribute("aria-selected", state.detailTab === t.id ? "true" : "false"); btn.textContent = t.label; btn.addEventListener("click", () => { state.detailTab = t.id; // Reset inspect to the tab default (first skill / first item). state.inspect = null; ensureHeroInspectDefault(state.selectedKey); syncStateToUrl(); renderDetail(); }); tabbar.appendChild(btn); } root.appendChild(tabbar); // Resolve default inspect BEFORE icon rows so .active / .selected match the pane. ensureHeroInspectDefault(state.selectedKey); // Skills: 血/蓝+三维 | 技能 | 攻击距离等. Items tabs: icons + inspect only. // Trends: WR / pick / ban cards + bracket table. Patches: changelog list. if (state.detailTab === "patches") { const layout = document.createElement("div"); layout.className = "detail-patches-layout"; const body = document.createElement("div"); body.className = "detail-tab-body patch-changes"; body.appendChild(buildPatchChangesPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "trends" || state.detailTab === "stats") { const layout = document.createElement("div"); layout.className = "detail-stats-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; body.appendChild(buildStatsPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "matchups") { const layout = document.createElement("div"); layout.className = "detail-matchups-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; body.appendChild(buildMatchupsPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "matches") { const layout = document.createElement("div"); layout.className = "detail-matches-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; body.appendChild(buildMatchesPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "streamers") { const layout = document.createElement("div"); layout.className = "detail-streamers-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; body.appendChild(buildHeroStreamersPanel(state.selectedKey)); layout.appendChild(body); root.appendChild(layout); } else if (state.detailTab === "skills") { const layout = document.createElement("div"); layout.className = "detail-skills-layout"; const left = document.createElement("aside"); left.className = "detail-skills-stats"; const vitalsAttrs = buildHeroVitalsAttrs(hero); if (vitalsAttrs) left.appendChild(vitalsAttrs); layout.appendChild(left); const center = document.createElement("div"); center.className = "detail-skills-main"; center.appendChild(buildSkillsPanel(state.selectedKey)); center.appendChild(renderHeroInspect(state.selectedKey)); layout.appendChild(center); const combat = buildHeroCombatStats(hero); if (combat) { const right = document.createElement("div"); right.className = "detail-skills-combat"; right.appendChild(combat); layout.appendChild(right); } root.appendChild(layout); } else { // Same outer height as skills layout; icons + fixed inspect box. const layout = document.createElement("div"); layout.className = "detail-items-layout"; const body = document.createElement("div"); body.className = "detail-tab-body"; if (state.detailTab === "core") { body.appendChild(buildCoreItemsPanel(state.selectedKey)); } else { body.appendChild(buildFearItemsPanel(state.selectedKey)); } layout.appendChild(body); layout.appendChild(renderHeroInspect(state.selectedKey)); root.appendChild(layout); } syncDetailDrawer(); } function shopCatalog() { return state.data.item_shop || { basic: { sections: [] }, upgraded: { sections: [] }, items: {} }; } function shopItem(key) { return (shopCatalog().items || {})[key] || null; } function matchesItemQuery(key, meta) { const q = state.itemQuery.trim(); if (!q) return true; const qLower = q.toLowerCase(); const name = meta?.name_loc || key; if (name.includes(q)) return true; if (key.toLowerCase().includes(qLower)) return true; if ((meta?.name || "").toLowerCase().includes(qLower)) return true; return false; } function onShopItemClick(key) { state.selectedItemKey = state.selectedItemKey === key ? null : key; state.selectedKey = null; syncStateToUrl(); render(); } function makeShopItemButton(key) { const meta = shopItem(key) || { key, name_loc: key }; const btn = document.createElement("button"); btn.type = "button"; btn.className = "shop-item"; const cost = meta.cost != null ? ` ${meta.cost}` : ""; btn.title = `${meta.name_loc || key}${cost}`; if (key === state.selectedItemKey) btn.classList.add("selected"); if (!matchesItemQuery(key, meta)) btn.style.display = "none"; const img = document.createElement("img"); img.src = itemIconSrc(key); img.alt = meta.name_loc || key; img.loading = "lazy"; btn.appendChild(img); if (meta.charges != null && meta.charges > 0) { const ch = document.createElement("span"); ch.className = "shop-charges"; ch.textContent = String(meta.charges); btn.appendChild(ch); } btn.addEventListener("click", () => onShopItemClick(key)); return btn; } function appendShopColumn(parent, sec) { const col = document.createElement("section"); col.className = "shop-column"; col.dataset.section = sec.id || ""; if (sec.icon) { const cat = document.createElement("img"); cat.className = "shop-column-cat"; cat.src = itemCatIconSrc(sec.icon); cat.alt = sec.label || ""; cat.title = sec.label || ""; col.appendChild(cat); } const head = document.createElement("h4"); head.className = "shop-column-label"; head.textContent = sec.label || sec.id || ""; col.appendChild(head); for (const key of sec.items || []) { col.appendChild(makeShopItemButton(key)); } parent.appendChild(col); } function renderItemShop() { const root = $("#item-shop"); if (!root) return; root.innerHTML = ""; root.className = "item-shop layout-cn"; const shop = shopCatalog(); const basicSecs = shop.basic?.sections || []; const upSecs = shop.upgraded?.sections || []; if (!basicSecs.length && !upSecs.length) { root.classList.add("empty"); root.textContent = "暂无商店数据"; return; } const basicWrap = document.createElement("div"); basicWrap.className = "shop-group"; for (const sec of basicSecs) appendShopColumn(basicWrap, sec); const upWrap = document.createElement("div"); upWrap.className = "shop-group"; for (const sec of upSecs) appendShopColumn(upWrap, sec); root.appendChild(basicWrap); root.appendChild(upWrap); } function itemIconSrc(key) { const stem = key.startsWith("recipe_") ? "recipe" : key; return assetUrl(`item/${encodeURIComponent(stem)}.png`); } /** Project OSS base for static icons (same bucket as deploy). */ const PROJECT_OSS_ASSET_BASE = "https://climperor.oss-cn-shanghai.aliyuncs.com"; function itemIconOssSrc(key) { const stem = key.startsWith("recipe_") ? "recipe" : key; return `${PROJECT_OSS_ASSET_BASE}/item/${encodeURIComponent(stem)}.png`; } /** * Prefer configured STATIC_ASSET_BASE / same-origin, then fall back to project OSS * so match builds still show neutrals / aegis after local cache misses. */ function setItemIcon(img, key, onMiss) { if (!key) { if (typeof onMiss === "function") onMiss(); return; } const primary = itemIconSrc(key); const oss = itemIconOssSrc(key); img.src = primary; img.onerror = () => { if (img.dataset.ossTried === "1" || primary === oss) { img.onerror = null; img.removeAttribute("src"); if (typeof onMiss === "function") onMiss(); return; } img.dataset.ossTried = "1"; img.src = oss; }; } function makeCraftChip(key, { clickable = true, onPick = null } = {}) { const meta = resolveItemDetail(key) || shopItem(key) || { key, name_loc: key.startsWith("recipe_") ? "卷轴" : key, }; const btn = document.createElement("button"); btn.type = "button"; btn.className = "craft-chip"; if (meta.is_recipe || key.startsWith("recipe_")) btn.classList.add("is-recipe"); const cost = meta.cost != null ? ` ${meta.cost}` : ""; btn.title = `${meta.name_loc || key}${cost}`; if (!clickable || meta.is_recipe || key.startsWith("recipe_")) { btn.disabled = true; } else { btn.addEventListener("click", () => { if (typeof onPick === "function") onPick(key); }); } const img = document.createElement("img"); img.src = itemIconSrc(key); img.alt = meta.name_loc || key; btn.appendChild(img); if (meta.is_recipe || key.startsWith("recipe_")) { const badge = document.createElement("span"); badge.className = "craft-recipe-cost"; badge.textContent = meta.cost != null ? String(meta.cost) : "卷轴"; btn.appendChild(badge); } return btn; } function stripItemDesc(text) { return String(text || "") .replace(//gi, "\n") .replace(/<\/?h1[^>]*>/gi, "\n") .replace(/<[^>]+>/g, "") .replace(/ /g, " ") .replace(/%[A-Za-z0-9_]+%/g, "?") .replace(/\n{3,}/g, "\n\n") .trim(); } function renderItemDetail() { const root = $("#item-detail-box"); if (!root) return; root.innerHTML = ""; if (!state.selectedItemKey) { root.classList.add("empty"); root.textContent = "点击物品查看详情与合成"; return; } const meta = resolveItemDetail(state.selectedItemKey); if (!meta) { root.classList.add("empty"); root.textContent = "未找到物品数据"; return; } mountItemInspect(root, meta, { onPickItem: (key) => { state.selectedItemKey = key; syncStateToUrl(); render(); }, }); } // Stat-icon labels for patch hero_notes (icon field). Attr values map to the // bundled attr/{agi,str,int,all}.png icons; everything else renders as a chip. const STAT_LABELS = { agility: "敏捷", strength: "力量", intelligence: "智力", all: "全才", agi: "敏捷", str: "力量", int: "智力", damage: "攻击力", damage_min: "最小攻击", damage_max: "最大攻击", armor: "护甲", magic_resistance: "魔抗", magic_resist: "魔抗", movement: "移速", movement_speed: "移速", attack_speed: "攻速", attack_rate: "攻速", attack_range: "攻击距离", attack_time: "攻击间隔", sight_range_day: "白天视野", sight_range_night: "夜间视野", vision_day: "白天视野", vision_night: "夜间视野", health: "生命", mana: "魔法", health_regen: "生命回复", mana_regen: "魔法回复", turn_rate: "转身", projectile_speed: "弹速", cooldown: "冷却", mana_cost: "魔法消耗", duration: "持续时间", cast_range: "施法距离", channel_time: "持续施法", }; const STAT_ATTR_ICON = { agility: "agi", strength: "str", intelligence: "int", all: "all", agi: "agi", str: "str", int: "int", }; function statChip(icon) { if (!icon) return ""; const a = STAT_ATTR_ICON[icon]; if (a) return ``; const label = STAT_LABELS[icon] || icon; return `${escapeHtml(label)}`; } function patchNoteHtml(note) { // Valve notes carry
, , . // Keep line breaks; strip the rest so raw tags never leak into the UI. const plain = stripItemDesc(note); return escapeHtml(plain).replace(/\n/g, "
"); } function renderNotesList(notes) { if (!notes || !notes.length) return ""; const items = notes.map((n) => { const indent = Math.max(0, (n.indent_level || 1) - 1) * 1.2; const raw = String(n.note || ""); if (n.hide_dot) { if (/^\s*$/i.test(raw)) { return `
  • `; } return `
  • ${patchNoteHtml(n.note)}
  • `; } return `
  • ${patchNoteHtml(n.note)}
  • `; }).join(""); return `
      ${items}
    `; } function renderHeroNotes(notes) { if (!notes || !notes.length) return ""; const items = notes.map((n) => { const indent = Math.max(0, (n.indent_level || 1) - 1) * 1.2; const chip = n.icon ? statChip(n.icon) : ""; if (n.hide_dot) { return `
  • ${chip}${patchNoteHtml(n.note)}
  • `; } return `
  • ${chip}${patchNoteHtml(n.note)}
  • `; }).join(""); return `
      ${items}
    `; } /** Cached set of innate ability keys (from hero_abilities) so patch rendering * can use the bundled innate.png badge instead of per-key CDN icons that 404. */ let _innateAbilityKeys = null; function innateAbilityKeys() { if (_innateAbilityKeys) return _innateAbilityKeys; const s = new Set(); const byHero = (state.data && state.data.hero_abilities && state.data.hero_abilities.by_hero) || {}; for (const cell of Object.values(byHero)) { for (const ab of (cell.abilities || [])) { if (ab && ab.is_innate && ab.key) s.add(ab.key); } } _innateAbilityKeys = s; return s; } function patchAbilityBlockHtml(ab, lookup) { const ameta = (lookup.abilities || {})[String(ab.ability_id)]; const akey = ameta ? ameta.key : ""; const aname = ameta ? ameta.name_loc : "# " + ab.ability_id; const innate = akey && innateAbilityKeys().has(akey); const aicon = akey ? `` : ""; return `
    ${aicon}
    ${escapeHtml(aname)}${renderNotesList(ab.ability_notes)}
    `; } function renderItemEntry(entry, lookup) { const meta = (lookup.items || {})[String(entry.ability_id)]; const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "# " + entry.ability_id; const icon = key ? `` : ""; return `
    ${icon}
    ${escapeHtml(name)}${renderNotesList(entry.ability_notes)}
    `; } function renderHeroEntry(hero, lookup) { const meta = (lookup.heroes || {})[String(hero.hero_id)]; const key = meta ? meta.key : ""; const name = meta ? meta.name_loc : "其它单位"; const portrait = key ? `` : ""; let html = `
    ${portrait}${escapeHtml(name)}
    `; html += renderHeroNotes(hero.hero_notes); for (const ab of (hero.abilities || [])) { html += patchAbilityBlockHtml(ab, lookup); } html += `
    `; return html; } function renderPatchSection(root, title, html) { if (!html) return; const sec = document.createElement("section"); sec.className = "patch-section"; sec.innerHTML = `

    ${escapeHtml(stripItemDesc(title))}

    ${html}`; root.appendChild(sec); } function buildPatchChangesPanel(heroKey) { const wrap = document.createElement("div"); wrap.className = "patch-changes"; const hero = heroByKey(heroKey); const patches = state.data.patches || []; const details = state.data.patch_details || {}; const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; if (!hero || !patches.length) { wrap.textContent = "近一年无版本改动"; return wrap; } const hid = Number(hero.id); const blocks = []; for (const p of patches) { // patches is newest-first; skip versions whose detail wasn't fetched. const det = details[p.version]; if (!det) continue; const hentry = (det.heroes || []).find((h) => Number(h.hero_id) === hid); if (!hentry) continue; const hasNotes = (hentry.hero_notes || []).length > 0; const hasAbilities = (hentry.abilities || []).length > 0; if (!hasNotes && !hasAbilities) continue; const patchDate = formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) || p.date || ""; let html = `
    ${escapeHtml(p.version)}${escapeHtml(patchDate)}
    `; html += renderHeroNotes(hentry.hero_notes); for (const ab of (hentry.abilities || [])) { html += patchAbilityBlockHtml(ab, lookup); } blocks.push(`
    ${html}
    `); } if (!blocks.length) { wrap.textContent = "近一年无版本改动"; return wrap; } wrap.innerHTML = blocks.join(""); return wrap; } function ensureDefaultPatch() { const patches = state.data?.patches || []; if (!patches.length) return; if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) { state.selectedPatch = patches[0].version; } } function renderPatches() { if (!state.data) return; const select = $("#patch-select"); const verEl = $("#patch-ver"); const root = $("#patches-detail"); if (!root) return; const patches = state.data.patches || []; if (!patches.length) { if (select) select.innerHTML = ""; if (verEl) verEl.textContent = ""; root.innerHTML = '
    暂无版本数据
    '; return; } ensureDefaultPatch(); if (select) { select.innerHTML = patches .map((p) => { const major = p.website ? " ★" : ""; const sel = p.version === state.selectedPatch ? " selected" : ""; const patchDate = formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) || p.date || ""; return ``; }) .join(""); select.onchange = () => { state.selectedPatch = select.value; syncStateToUrl(); renderPatches(); const board = $("#patches-view"); if (board) board.scrollTo(0, 0); }; } const cur = patches.find((p) => p.version === state.selectedPatch) || patches[0]; if (verEl) verEl.textContent = (cur && cur.version) || ""; const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; const details = state.data.patch_details || {}; const det = details[state.selectedPatch]; if (!det) { root.innerHTML = '
    该版本详情暂未收录
    '; return; } root.innerHTML = ""; const generalHtml = (det.general_notes || []) .map((g) => { const t = g.title ? `

    ${escapeHtml(g.title)}

    ` : ""; return `
    ${t}${renderNotesList(g.generic)}
    `; }) .join(""); renderPatchSection(root, "综合改动", generalHtml); const itemsHtml = (det.items || []) .map((e) => renderItemEntry(e, lookup)) .join(""); renderPatchSection(root, "物品改动", itemsHtml); const neutralHtml = (det.neutral_items || []) .map((e) => { if (e.is_general_note || e.ability_id === -1) { return e.title ? `

    ${escapeHtml(e.title)}

    ` : ""; } return renderItemEntry(e, lookup); }) .join(""); renderPatchSection(root, "中立物品改动", neutralHtml); const heroesHtml = (det.heroes || []) .map((h) => renderHeroEntry(h, lookup)) .join(""); renderPatchSection(root, "英雄改动", heroesHtml); } function escapeHtml(s) { return s.replace(/&/g, "&").replace(//g, ">"); } const RANKING_REGION_LABELS = { china: "中国", europe: "欧洲", americas: "美洲", se_asia: "东南亚", }; /** ISO 3166-1 alpha-2 → 中文国名(Valve 榜 country 字段)。 */ const COUNTRY_ZH = { af: "阿富汗", ag: "安提瓜和巴布达", ai: "安圭拉", al: "阿尔巴尼亚", ao: "安哥拉", ar: "阿根廷", as: "美属萨摩亚", at: "奥地利", au: "澳大利亚", ax: "奥兰", az: "阿塞拜疆", be: "比利时", bo: "玻利维亚", br: "巴西", by: "白俄罗斯", ca: "加拿大", ch: "瑞士", cl: "智利", cn: "中国", cz: "捷克", de: "德国", dz: "阿尔及利亚", fi: "芬兰", fk: "福克兰群岛", gd: "格林纳达", gf: "法属圭亚那", gr: "希腊", id: "印度尼西亚", in: "印度", jo: "约旦", jp: "日本", kz: "哈萨克斯坦", la: "老挝", lb: "黎巴嫩", md: "摩尔多瓦", mn: "蒙古", mp: "北马里亚纳", my: "马来西亚", ni: "尼加拉瓜", nl: "荷兰", pe: "秘鲁", ph: "菲律宾", pl: "波兰", rs: "塞尔维亚", ru: "俄罗斯", sg: "新加坡", sk: "斯洛伐克", th: "泰国", ua: "乌克兰", us: "美国", uz: "乌兹别克斯坦", va: "梵蒂冈", vn: "越南", // Common extras ae: "阿联酋", bd: "孟加拉国", bg: "保加利亚", dk: "丹麦", eg: "埃及", es: "西班牙", fr: "法国", gb: "英国", hk: "中国香港", hr: "克罗地亚", hu: "匈牙利", ie: "爱尔兰", il: "以色列", iq: "伊拉克", ir: "伊朗", it: "意大利", kh: "柬埔寨", kr: "韩国", lt: "立陶宛", lv: "拉脱维亚", mm: "缅甸", mx: "墨西哥", no: "挪威", np: "尼泊尔", nz: "新西兰", pk: "巴基斯坦", pt: "葡萄牙", ro: "罗马尼亚", sa: "沙特阿拉伯", se: "瑞典", si: "斯洛文尼亚", tr: "土耳其", tw: "中国台湾", uk: "英国", }; function countryLabelZh(code) { if (!code) return ""; const key = String(code).trim().toLowerCase(); if (!key) return ""; return COUNTRY_ZH[key] || key.toUpperCase(); } function leaderboardsData() { return state.data?.leaderboards || { default_region: "china", region_order: ["china", "europe", "americas", "se_asia"], regions: {}, }; } function ensureDefaultRankingRegion() { const lb = leaderboardsData(); const order = lb.region_order || Object.keys(lb.regions || {}); const valid = new Set(order); Object.keys(lb.regions || {}).forEach((k) => valid.add(k)); if (!state.rankingRegion || !valid.has(state.rankingRegion)) { state.rankingRegion = lb.default_region || order[0] || "china"; } } function renderRankings() { if (!state.data) return; const regionsEl = $("#rankings-regions"); const body = $("#rankings-body"); const sub = $("#rankings-sub"); const titleKicker = $("#rankings-kicker"); const tipSlot = $("#rankings-tip-slot"); if (!body) return; if (tipSlot) { tipSlot.innerHTML = ""; tipSlot.appendChild( buildInfoTipButton(IMMORTAL_LEADERBOARD_TIP, "rankings-info-btn") ); } if (titleKicker) { titleKicker.textContent = "Immortal 排行榜"; } const lb = leaderboardsData(); const regions = lb.regions || {}; const order = (lb.region_order || []).filter((k) => regions[k]); const keys = order.length ? order : Object.keys(regions); if (!keys.length) { if (regionsEl) regionsEl.innerHTML = ""; if (sub) sub.textContent = ""; body.innerHTML = '
    暂无排行数据
    '; return; } ensureDefaultRankingRegion(); if (!regions[state.rankingRegion]) { state.rankingRegion = keys[0]; } if (regionsEl) { regionsEl.innerHTML = keys .map((key) => { const label = (regions[key] && regions[key].label_zh) || RANKING_REGION_LABELS[key] || key; const active = key === state.rankingRegion ? " active" : ""; return ``; }) .join(""); regionsEl.querySelectorAll(".rankings-region-btn").forEach((btn) => { btn.addEventListener("click", () => { const region = btn.dataset.region; if (!region || region === state.rankingRegion) return; state.rankingRegion = region; syncStateToUrl(); renderRankings(); const board = $("#rankings-view"); if (board) board.scrollTo(0, 0); }); }); } const cur = regions[state.rankingRegion] || {}; const postedInfo = formatFriendlyTime(cur.time_posted); if (sub) { sub.textContent = postedInfo.text ? `更新于 ${postedInfo.text}` : ""; if (postedInfo.title) sub.title = postedInfo.title; else sub.removeAttribute("title"); } const rows = Array.isArray(cur.top100) ? cur.top100 : []; if (!rows.length) { body.innerHTML = '
    该地区暂无榜单数据
    '; return; } const trs = rows .map((row) => { const rank = row.rank != null ? String(row.rank) : ""; const name = escapeHtml(row.name || ""); const team = row.team_tag ? escapeHtml(row.team_tag) : ""; const country = escapeHtml(countryLabelZh(row.country)); return `${escapeHtml(rank)}${name}${team}${country}`; }) .join(""); body.innerHTML = ` ${trs}
    排名选手战队国籍
    `; } function proMatchesData() { return ( (state.data && state.data.pro_matches) || { meta: {}, items: {}, pros: {}, by_pro: {}, by_hero: {}, } ); } /** OpenDota China region ids / cluster ids used to label 国服 pubs. */ const MATCH_CHINA_REGIONS = new Set([12, 13, 17, 18, 20, 25]); const MATCH_CHINA_CLUSTERS = new Set([ 221, 222, 223, 224, 225, 227, 231, 232, 235, 236, 413, 414, 415, 417, ]); const MATCH_ORIGIN_FILTERS = [ { id: "all", label: "全部" }, { id: "pro", label: "职业" }, { id: "china", label: "国服" }, ]; function isRankedPubMatch(row) { return row?.origin === "public" || Number(row?.lobby_type) === 7; } function isChinaPubMatch(row) { if (!isRankedPubMatch(row)) return false; const cluster = Number(row.cluster); const region = Number(row.region); return ( (Number.isFinite(region) && MATCH_CHINA_REGIONS.has(region)) || (Number.isFinite(cluster) && MATCH_CHINA_CLUSTERS.has(cluster)) ); } /** Classify a match row for badges / filters: pro | league | china | ladder. */ function matchOriginKind(row) { if (isRankedPubMatch(row)) return isChinaPubMatch(row) ? "china" : "ladder"; if (row?.origin === "pro") return "pro"; return "league"; } function matchPassesOriginFilter(row, origin) { const want = origin || "all"; if (want === "all") return true; if (want === "pro") return !isRankedPubMatch(row); if (want === "china") return isChinaPubMatch(row); return true; } function proPlayerOptions(originFilter) { const pack = proMatchesData(); const byPro = pack.by_pro || {}; const prosMeta = pack.pros || {}; const rows = []; for (const [sid, cell] of Object.entries(byPro)) { if (!cell || typeof cell !== "object") continue; const matches = Array.isArray(cell.matches) ? cell.matches : []; const filtered = matches.filter((row) => matchPassesOriginFilter(row, originFilter) ); if (!filtered.length) continue; const meta = prosMeta[sid] || {}; const name = cell.name || meta.name || (filtered[0] && matchPlayerDisplayName(filtered[0])) || sid; const team = cell.team_tag || meta.team_tag || ""; rows.push({ account_id: String(cell.account_id || sid), name, team_tag: team, match_count: filtered.length, }); } rows.sort( (a, b) => String(a.name).localeCompare(String(b.name), "zh") || String(a.account_id).localeCompare(String(b.account_id)) ); return rows; } function proMatchRows(playerId, originFilter) { const pack = proMatchesData(); const byPro = pack.by_pro || {}; const out = []; const seen = new Set(); const want = playerId ? String(playerId) : null; for (const [sid, cell] of Object.entries(byPro)) { if (!cell || typeof cell !== "object") continue; if (want && String(cell.account_id || sid) !== want) continue; const matches = Array.isArray(cell.matches) ? cell.matches : []; for (const row of matches) { if (!row || typeof row !== "object") continue; if (!matchPassesOriginFilter(row, originFilter)) continue; const mid = Number(row.match_id); if (!mid || seen.has(mid)) continue; seen.add(mid); out.push({ ...row, account_id: row.account_id || cell.account_id || Number(sid) || null, name: row.name || cell.name || null, display_name: row.display_name || row.name || cell.name || row.personaname || null, hero_key: row.hero_key || null, }); } } out.sort( (a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0) ); return out; } function renderMatchesPage() { const body = $("#matches-body"); const originsEl = $("#matches-origins"); const playersEl = $("#matches-players"); if (!body) return; const origin = MATCH_ORIGIN_FILTERS.some((o) => o.id === state.matchesOrigin) ? state.matchesOrigin : "all"; if (origin !== state.matchesOrigin) state.matchesOrigin = origin; const packHasAny = Object.values(proMatchesData().by_pro || {}).some( (cell) => Array.isArray(cell?.matches) && cell.matches.length ); const options = proPlayerOptions(origin); const hasData = packHasAny; if (originsEl) { if (!hasData) { originsEl.innerHTML = ""; } else { originsEl.innerHTML = MATCH_ORIGIN_FILTERS.map((o) => { const active = origin === o.id ? " active" : ""; return ``; }).join(""); originsEl.querySelectorAll("[data-origin]").forEach((btn) => { btn.addEventListener("click", () => { const next = btn.getAttribute("data-origin") || "all"; if (!MATCH_ORIGIN_FILTERS.some((o) => o.id === next)) return; state.matchesOrigin = next; state.matchesPage = 1; syncStateToUrl(); renderMatchesPage(); const board = $("#matches-view"); if (board) board.scrollTop = 0; }); }); } } if (playersEl) { if (!hasData) { playersEl.innerHTML = ""; } else { const chips = [ ``, ]; for (const p of options) { const active = state.matchesPlayerId === p.account_id ? " active" : ""; const label = p.team_tag ? `${escapeHtml(p.name)} · ${escapeHtml(p.team_tag)}` : escapeHtml(p.name); chips.push( `` ); } playersEl.innerHTML = chips.join(""); playersEl.querySelectorAll("[data-player]").forEach((btn) => { btn.addEventListener("click", () => { const id = btn.getAttribute("data-player") || ""; state.matchesPlayerId = id || null; state.matchesPage = 1; syncStateToUrl(); renderMatchesPage(); const board = $("#matches-view"); if (board) board.scrollTop = 0; }); }); } } if (!hasData) { body.innerHTML = '
    暂无比赛数据(请运行 python web/fetch_pro_matches.py)
    '; return; } if ( state.matchesPlayerId && !options.some((p) => p.account_id === state.matchesPlayerId) ) { state.matchesPlayerId = null; } const allRows = proMatchRows(state.matchesPlayerId, origin); const total = allRows.length; const pageSize = PRO_MATCHES_PAGE_SIZE; const pageCount = Math.max(1, Math.ceil(total / pageSize) || 1); let page = Number(state.matchesPage) || 1; if (page < 1) page = 1; if (page > pageCount) page = pageCount; if (page !== state.matchesPage) { state.matchesPage = page; syncStateToUrl({ replace: true }); } const start = (page - 1) * pageSize; const rows = allRows.slice(start, start + pageSize); const summary = total ? `共 ${total} 场 · 第 ${page}/${pageCount} 页` : ""; if (!rows.length) { body.innerHTML = state.matchesPlayerId ? '
    该选手暂无符合筛选的近期比赛
    ' : '
    暂无符合筛选的近期比赛
    '; return; } const wrap = document.createElement("div"); wrap.className = "matches-page-wrap"; wrap.appendChild(buildMatchList(null, rows, { showHero: true })); wrap.appendChild( buildMatchesFoot(summary, page, pageCount, (next) => { state.matchesPage = next; syncStateToUrl(); renderMatchesPage(); const board = $("#matches-view"); if (board) board.scrollTop = 0; }) ); body.replaceChildren(wrap); } function buildMatchesFoot(summaryText, page, pageCount, onGo) { const foot = document.createElement("div"); foot.className = "matches-foot"; if (pageCount > 1) { foot.appendChild(buildMatchesPager(page, pageCount, onGo)); } if (summaryText) { const summary = document.createElement("p"); summary.className = "matches-foot-summary"; summary.textContent = summaryText; foot.appendChild(summary); } return foot; } function buildMatchesPager(page, pageCount, onGo) { const nav = document.createElement("nav"); nav.className = "matches-pager"; nav.setAttribute("aria-label", "比赛分页"); const mkBtn = (label, target, disabled, current) => { const btn = document.createElement("button"); btn.type = "button"; btn.className = "matches-pager-btn" + (current ? " active" : ""); btn.textContent = label; btn.disabled = !!disabled; if (current) btn.setAttribute("aria-current", "page"); if (!disabled && !current) { btn.addEventListener("click", () => onGo(target)); } return btn; }; nav.appendChild(mkBtn("上一页", page - 1, page <= 1, false)); const windowSize = 5; let from = Math.max(1, page - Math.floor(windowSize / 2)); let to = Math.min(pageCount, from + windowSize - 1); from = Math.max(1, to - windowSize + 1); if (from > 1) { nav.appendChild(mkBtn("1", 1, false, page === 1)); if (from > 2) { const ell = document.createElement("span"); ell.className = "matches-pager-ell"; ell.textContent = "…"; nav.appendChild(ell); } } for (let i = from; i <= to; i++) { nav.appendChild(mkBtn(String(i), i, false, i === page)); } if (to < pageCount) { if (to < pageCount - 1) { const ell = document.createElement("span"); ell.className = "matches-pager-ell"; ell.textContent = "…"; nav.appendChild(ell); } nav.appendChild( mkBtn(String(pageCount), pageCount, false, page === pageCount) ); } nav.appendChild(mkBtn("下一页", page + 1, page >= pageCount, false)); return nav; } function streamersData() { return ( (state.data && state.data.streamers) || { fetched_at: null, source: "manual+douyin", platform_meta: {}, streamers: [], } ); } function streamerList() { const rows = streamersData().streamers; if (!Array.isArray(rows)) return []; return [...rows].sort((a, b) => { const la = a && a.is_live === true ? 1 : 0; const lb = b && b.is_live === true ? 1 : 0; if (la !== lb) return lb - la; const fa = Number(a.follower_count); const fb = Number(b.follower_count); const na = Number.isFinite(fa) && fa >= 0 ? fa : -1; const nb = Number.isFinite(fb) && fb >= 0 ? fb : -1; return nb - na; }); } function streamerAvatarSrc(row) { const av = row && row.avatar; if (!av || typeof av !== "string") return ""; const base = av.replace(/^.*[\\/]/, ""); if (!base) return ""; return assetUrl(`streamer-avatar/${encodeURIComponent(base)}`); } function streamerVideoSrc(row) { const v = row && row.video; if (!v || typeof v !== "string") return ""; const base = v.replace(/^.*[\\/]/, ""); if (!base || base.startsWith("_")) return ""; return assetUrl(`streamer-video/${encodeURIComponent(base)}`); } /** Poster next to the clip: foo.mp4 → foo.jpg (optional override `video_poster`). */ function streamerVideoPosterSrc(row) { const explicit = row && row.video_poster; if (explicit && typeof explicit === "string") { const base = explicit.replace(/^.*[\\/]/, ""); if (base && !base.startsWith("_")) { return assetUrl(`streamer-video/${encodeURIComponent(base)}`); } } const v = row && row.video; if (!v || typeof v !== "string") return ""; const base = v.replace(/^.*[\\/]/, ""); if (!base || base.startsWith("_")) return ""; const stem = base.replace(/\.(mp4|webm)$/i, ""); if (!stem) return ""; return assetUrl(`streamer-video/${encodeURIComponent(`${stem}.jpg`)}`); } /** Parse optional streamers.json `video_aspect` ("1916/2317" or [w, h]). */ function parseStreamerVideoAspect(row) { const raw = row && row.video_aspect; if (Array.isArray(raw) && raw.length >= 2) { const w = Number(raw[0]); const h = Number(raw[1]); if (w > 0 && h > 0) return { w, h }; } if (typeof raw === "string") { const m = raw.trim().match(/^(\d+(?:\.\d+)?)\s*[/:\s]\s*(\d+(?:\.\d+)?)$/); if (m) { const w = Number(m[1]); const h = Number(m[2]); if (w > 0 && h > 0) return { w, h }; } } return null; } function applyStreamerVideoCrop(frame, video, bounds) { if (!bounds) return false; frame.style.aspectRatio = `${bounds.contentW} / ${bounds.contentH}`; frame.classList.add("is-letterbox-crop"); // object-position % aligns that point of the video with the same point of the // box; padTop/(padTop+padBottom) places the content band in frame (not top/bottom). const padY = (bounds.padTop || 0) + (bounds.padBottom || 0); if (padY > 0) { const yPct = ((bounds.padTop || 0) / padY) * 100; video.style.objectPosition = `center ${yPct}%`; } else { video.style.objectPosition = "center center"; } frame.dataset.letterboxCrop = "1"; return true; } /** Douyin-style portrait: shorter crop frame + more pad below gameplay band. */ function estimatePortraitLetterbox(w, h) { const contentH = Math.max(Math.round(w * 9 / 16), Math.round(h * 0.68)); const padTotal = Math.max(0, h - contentH); const padTop = Math.round(padTotal * 0.38); return { contentW: w, contentH, padTop, padBottom: padTotal - padTop, padLeft: 0, padRight: 0, }; } /** Build crop bounds from explicit content size; Douyin bias when height is shorter. */ function boundsFromContentAspect(w, h, contentW, contentH) { const cw = Math.min(contentW, w); const ch = Math.min(contentH, h); const padTotal = Math.max(0, h - ch); const padTop = Math.round(padTotal * 0.38); return { contentW: cw, contentH: ch, padTop, padBottom: padTotal - padTop, padLeft: 0, padRight: 0, }; } /** Fit streamer clip frame; landscape stays contain; portrait uses cover crop frame. */ function fitStreamerVideoFrame(video, frame, row) { const w = video.videoWidth; const h = video.videoHeight; if (!w || !h) return; const fitRaw = row && row.video_fit; const forceContain = fitRaw === "contain"; const forceCrop = fitRaw === "cover" || row?.video_crop === true; frame.classList.remove("is-letterbox-crop", "is-pending"); delete frame.dataset.letterboxCrop; frame.style.height = ""; frame.style.backgroundImage = ""; video.style.objectPosition = ""; frame.style.aspectRatio = `${w} / ${h}`; frame.classList.toggle("is-landscape", w >= h); if (forceContain) return; const manualAspect = parseStreamerVideoAspect(row); if (manualAspect) { applyStreamerVideoCrop( frame, video, boundsFromContentAspect(w, h, manualAspect.w, manualAspect.h), ); return; } const portraitish = h > w * (forceCrop ? 1.02 : 1.15); if (!portraitish) return; // Prefer heuristic over canvas scan (CORS / dark frames are unreliable). applyStreamerVideoCrop(frame, video, estimatePortraitLetterbox(w, h)); } function pauseOtherStreamerVideos(current) { document.querySelectorAll("video.streamer-video").forEach((el) => { if (el !== current && !el.paused) el.pause(); }); } /** Comfortable default when user unmutes (native controls start at 1.0). */ const STREAMER_VIDEO_DEFAULT_VOLUME = 0.35; /** Per-clip opt-in: user unmutes via native video controls only. */ function streamerVideoUserSoundOn(video) { return video instanceof HTMLVideoElement && video.dataset.streamerSoundOn === "1"; } function bindStreamerVideoSoundToggle(video) { if (!(video instanceof HTMLVideoElement) || video.dataset.streamerSoundBound) return; video.dataset.streamerSoundBound = "1"; video.addEventListener("volumechange", () => { if (video.muted) { delete video.dataset.streamerSoundOn; return; } video.dataset.streamerSoundOn = "1"; // First unmute: if still at browser max, drop to a comfortable level once. // Later slider moves (including intentional max) are left alone. if ( video.dataset.streamerVolComfort !== "1" && video.volume >= 0.98 ) { video.volume = STREAMER_VIDEO_DEFAULT_VOLUME; } video.dataset.streamerVolComfort = "1"; }); } /** * Viewport-tiered clip loading: * - far: no src (cancel download) * - near (~1 screen): attach src + preload=metadata * - play (mid-screen band): preload=auto + play on canplay (not canplaythrough) * At most one clip uses preload=auto so large mp4s do not contend. */ /** @type {Map} mid-band visibility ratio */ const streamerVideoVisibility = new Map(); /** @type {Set} within ~1 viewport of screen */ const streamerVideoNear = new Set(); let streamerVideoPlayObserver = null; let streamerVideoNearObserver = null; let streamerVideoReconcileQueued = false; /** Attach / upgrade deferred clip URL. */ function ensureStreamerVideoSrc(video, { preload = "metadata" } = {}) { if (!(video instanceof HTMLVideoElement)) return false; const src = video.dataset.streamerSrc || ""; if (!src) return false; if (video.dataset.streamerSrcAttached !== "1") { video.src = src; video.dataset.streamerSrcAttached = "1"; } if (video.preload !== preload) video.preload = preload; return true; } /** Drop src so the browser can cancel an in-flight fetch. */ function unloadStreamerVideoSrc(video) { if (!(video instanceof HTMLVideoElement)) return; if (video.dataset.streamerSrcAttached !== "1") return; video.pause(); video.removeAttribute("src"); video.load(); delete video.dataset.streamerSrcAttached; video.preload = "none"; const frame = video.closest(".streamer-video-frame"); if (frame && video.videoWidth === 0) frame.classList.add("is-pending"); } function playStreamerVideo(video) { if (!(video instanceof HTMLVideoElement)) return; ensureStreamerVideoSrc(video, { preload: "auto" }); pauseOtherStreamerVideos(video); // Scroll autoplay stays muted unless the user explicitly unmuted this clip. if (!streamerVideoUserSoundOn(video)) { video.muted = true; video.setAttribute("muted", ""); } const tryPlay = () => { const p = video.play(); if (p && typeof p.catch === "function") { p.catch(() => { video.muted = true; video.setAttribute("muted", ""); delete video.dataset.streamerSoundOn; const p2 = video.play(); if (p2 && typeof p2.catch === "function") p2.catch(() => {}); }); } }; // HAVE_FUTURE_DATA — enough to start; do not wait for canplaythrough. if (video.readyState >= 3) { tryPlay(); return; } if (video.dataset.streamerCanplayBound === "1") return; video.dataset.streamerCanplayBound = "1"; const onReady = () => { delete video.dataset.streamerCanplayBound; video.removeEventListener("canplay", onReady); video.removeEventListener("error", onErr); if (pickActiveStreamerVideo() === video) tryPlay(); }; const onErr = () => { delete video.dataset.streamerCanplayBound; video.removeEventListener("canplay", onReady); video.removeEventListener("error", onErr); }; video.addEventListener("canplay", onReady); video.addEventListener("error", onErr); } function pickActiveStreamerVideo() { const vh = window.innerHeight || document.documentElement.clientHeight || 1; const mid = vh * 0.45; let best = null; let bestScore = -Infinity; for (const [video, ratio] of streamerVideoVisibility) { if (!(video instanceof HTMLVideoElement) || !video.isConnected) continue; if (!(ratio > 0)) continue; const rect = video.getBoundingClientRect(); if (rect.height <= 0 || rect.width <= 0) continue; const videoMid = (rect.top + rect.bottom) / 2; const dist = Math.abs(videoMid - mid); const score = ratio * vh - dist; if (score > bestScore) { bestScore = score; best = video; } } return best; } /** Apply near / play / far tiers; only the active clip gets preload=auto. */ function reconcileStreamerVideoLoads() { const active = pickActiveStreamerVideo(); document.querySelectorAll("video.streamer-video").forEach((v) => { if (!(v instanceof HTMLVideoElement) || !v.isConnected) return; if (!v.dataset.streamerSrc) return; const near = streamerVideoNear.has(v) || v === active; if (v === active) { ensureStreamerVideoSrc(v, { preload: "auto" }); if (v.paused) playStreamerVideo(v); return; } if (!v.paused) v.pause(); if (near) { ensureStreamerVideoSrc(v, { preload: "metadata" }); } else { unloadStreamerVideoSrc(v); } }); } function queueReconcileStreamerVideoLoads() { if (streamerVideoReconcileQueued) return; streamerVideoReconcileQueued = true; requestAnimationFrame(() => { streamerVideoReconcileQueued = false; reconcileStreamerVideoLoads(); }); } function syncStreamerVideoPlayback() { queueReconcileStreamerVideoLoads(); } function resetStreamerVideoObserver() { if (streamerVideoPlayObserver) { streamerVideoPlayObserver.disconnect(); streamerVideoPlayObserver = null; } if (streamerVideoNearObserver) { streamerVideoNearObserver.disconnect(); streamerVideoNearObserver = null; } streamerVideoVisibility.clear(); streamerVideoNear.clear(); streamerVideoReconcileQueued = false; } function ensureStreamerVideoObservers() { if (typeof IntersectionObserver !== "function") return; if (!streamerVideoPlayObserver) { streamerVideoPlayObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const video = entry.target; if (!(video instanceof HTMLVideoElement)) continue; streamerVideoVisibility.set( video, entry.isIntersecting ? entry.intersectionRatio : 0, ); } queueReconcileStreamerVideoLoads(); }, { root: null, rootMargin: "-12% 0px -32% 0px", threshold: [0, 0.05, 0.1, 0.2, 0.35, 0.5, 0.75, 1], }, ); } if (!streamerVideoNearObserver) { streamerVideoNearObserver = new IntersectionObserver( (entries) => { for (const entry of entries) { const video = entry.target; if (!(video instanceof HTMLVideoElement)) continue; if (entry.isIntersecting) streamerVideoNear.add(video); else streamerVideoNear.delete(video); } queueReconcileStreamerVideoLoads(); }, { // ~1 viewport above/below: warm metadata only, no full download. root: null, rootMargin: "100% 0px 100% 0px", threshold: 0, }, ); } } function observeStreamerVideo(video) { bindStreamerVideoSoundToggle(video); ensureStreamerVideoObservers(); if (streamerVideoNearObserver) streamerVideoNearObserver.observe(video); if (streamerVideoPlayObserver) streamerVideoPlayObserver.observe(video); } /** Compact Chinese count: 1234 / 1.2万 / 1.2亿 */ function formatStreamerCount(n) { if (n == null || n === "" || Number.isNaN(Number(n))) return "—"; const v = Number(n); if (!Number.isFinite(v) || v < 0) return "—"; if (v < 10000) return String(Math.round(v)); if (v < 100000000) { const wan = v / 10000; const text = wan >= 100 ? String(Math.round(wan)) : wan.toFixed(1).replace(/\.0$/, ""); return `${text}万`; } const yi = v / 100000000; const text = yi >= 100 ? String(Math.round(yi)) : yi.toFixed(1).replace(/\.0$/, ""); return `${text}亿`; } /** Account id + social counts for the middle column (between avatar and follow). */ function buildStreamerMetaLine(row) { const platform = String(row.platform || "").toLowerCase(); const uniqueId = row.unique_id || ""; const parts = []; if (uniqueId) { parts.push({ label: platform === "douyin" ? "抖音号" : platform === "bilibili" ? "UID" : platform === "douyu" ? "房间号" : "账号", value: String(uniqueId), account: true, }); } if (platform === "bilibili") { if (Number.isFinite(Number(row.following_count))) { parts.push({ label: "关注", value: formatStreamerCount(row.following_count) }); } } else if (platform === "douyu") { if (Number.isFinite(Number(row.following_count))) { parts.push({ label: "关注", value: formatStreamerCount(row.following_count) }); } if (Number.isFinite(Number(row.total_favorited))) { parts.push({ label: "播放", value: formatStreamerCount(row.total_favorited) }); } } else if (Number.isFinite(Number(row.total_favorited))) { parts.push({ label: "获赞", value: formatStreamerCount(row.total_favorited) }); } if (Number.isFinite(Number(row.follower_count))) { parts.push({ label: "粉丝", value: formatStreamerCount(row.follower_count) }); } if (!parts.length) return null; const meta = document.createElement("div"); meta.className = "streamer-meta"; parts.forEach((part, i) => { if (i > 0) { const sep = document.createElement("span"); sep.className = "streamer-meta-sep"; sep.setAttribute("aria-hidden", "true"); meta.appendChild(sep); } const item = document.createElement("span"); item.className = part.account ? "streamer-meta-item is-account" : "streamer-meta-item is-stat"; const label = document.createElement("span"); label.className = "streamer-meta-label"; label.textContent = part.label; const value = document.createElement("span"); value.className = "streamer-meta-value"; value.textContent = part.value; if (part.account) { item.append(label, document.createTextNode(":"), value); } else { item.append(value, label); } meta.appendChild(item); }); return meta; } function buildStreamerHeroTags(heroes) { const heroRow = document.createElement("div"); heroRow.className = "streamer-heroes"; for (const key of heroes.slice(0, 8)) { const h = heroByKey(key); const chip = document.createElement("button"); chip.type = "button"; chip.className = "streamer-hero-chip"; chip.title = (h && h.name_loc) || key; const himg = document.createElement("img"); himg.src = portraitSrc(key); himg.alt = (h && h.name_loc) || key; // Eager + high: same priority as streamer avatars vs video preloads. himg.loading = "eager"; himg.decoding = "async"; try { himg.fetchPriority = "high"; } catch { /* older engines */ } chip.appendChild(himg); chip.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); state.page = "heroes"; state.selectedKey = key; state.detailTab = "streamers"; state.inspect = null; syncStateToUrl(); render(); }); heroRow.appendChild(chip); } return heroRow; } function buildStreamerCard(row) { const card = document.createElement("article"); card.className = "streamer-card"; const sid = row.id || ""; if (sid) card.dataset.streamerId = sid; const nick = row.nickname || sid || "未命名主播"; const signature = row.signature || ""; // Row 1: avatar | nickname (flex) | follow const head = document.createElement("div"); head.className = "streamer-card-head"; // Badge/animation only when the probe confirms live; the avatar still // links to the room whenever live_url exists (offline rooms stay reachable). const isLive = row.is_live === true; const liveHref = row.live_url ? String(row.live_url) : ""; const avatarWrap = liveHref ? document.createElement("a") : document.createElement("div"); avatarWrap.className = isLive ? "streamer-avatar-wrap is-live" : "streamer-avatar-wrap"; if (liveHref) { avatarWrap.href = liveHref; avatarWrap.target = "_blank"; avatarWrap.rel = "noopener noreferrer"; avatarWrap.title = isLive ? "进入直播间" : "前往直播间(未开播)"; avatarWrap.setAttribute( "aria-label", isLive ? `${nick} 直播中,点击进入直播间` : `${nick} 的直播间(当前未开播)` ); } if (isLive) { // Douyin: static ring + pulsing ring (expand/fade) beside shrinking avatar. const ringBase = document.createElement("span"); ringBase.className = "streamer-live-ring"; ringBase.setAttribute("aria-hidden", "true"); const ringPulse = document.createElement("span"); ringPulse.className = "streamer-live-ring streamer-live-ring-pulse"; ringPulse.setAttribute("aria-hidden", "true"); avatarWrap.appendChild(ringBase); avatarWrap.appendChild(ringPulse); } const avatarInner = document.createElement("span"); avatarInner.className = "streamer-avatar-inner"; const avatarSrc = streamerAvatarSrc(row); if (avatarSrc) { const img = document.createElement("img"); img.className = "streamer-avatar"; img.src = avatarSrc; img.alt = nick; // Eager + high: avatars must win over sequential video preloads on HTTP/1.1. img.loading = "eager"; img.decoding = "async"; try { img.fetchPriority = "high"; } catch { /* older engines */ } img.addEventListener( "error", () => { const ph = document.createElement("div"); ph.className = "streamer-avatar streamer-avatar-fallback"; ph.textContent = (nick || "?").slice(0, 1); img.replaceWith(ph); }, { once: true }, ); avatarInner.appendChild(img); } else { const ph = document.createElement("div"); ph.className = "streamer-avatar streamer-avatar-fallback"; ph.textContent = (nick || "?").slice(0, 1); avatarInner.appendChild(ph); } avatarWrap.appendChild(avatarInner); if (isLive) { const liveBadge = document.createElement("span"); liveBadge.className = "streamer-live-badge"; liveBadge.textContent = "直播"; avatarWrap.appendChild(liveBadge); } head.appendChild(avatarWrap); // Middle column: nickname + account/stats only (signature is a full-width row below). const info = document.createElement("div"); info.className = "streamer-card-info"; const nameEl = document.createElement("h3"); nameEl.className = "streamer-name"; nameEl.textContent = nick; info.appendChild(nameEl); const metaEl = buildStreamerMetaLine(row); if (metaEl) info.appendChild(metaEl); head.appendChild(info); const actions = document.createElement("div"); actions.className = "streamer-actions"; if (row.profile_url) { const profile = document.createElement("a"); profile.className = "streamer-btn streamer-btn-follow"; profile.href = row.profile_url; profile.target = "_blank"; profile.rel = "noopener noreferrer"; profile.textContent = "关注"; actions.appendChild(profile); } if (actions.childNodes.length) head.appendChild(actions); card.appendChild(head); // Signature on its own row — long bios must not squeeze the avatar/follow row. if (signature) { const sig = document.createElement("p"); sig.className = "streamer-signature"; sig.textContent = signature; sig.title = signature; card.appendChild(sig); } const heroes = Array.isArray(row.heroes) ? row.heroes.filter(Boolean) : []; if (heroes.length) { card.appendChild(buildStreamerHeroTags(heroes)); } const videoSrc = streamerVideoSrc(row); if (videoSrc) { const vwrap = document.createElement("div"); vwrap.className = "streamer-video-wrap"; const frame = document.createElement("div"); // Compact skeleton until metadata; avoid full-bleed 9:16 black void. frame.className = "streamer-video-frame is-pending"; const earlyAspect = parseStreamerVideoAspect(row); if (earlyAspect) { frame.style.aspectRatio = `${earlyAspect.w} / ${earlyAspect.h}`; frame.classList.toggle("is-landscape", earlyAspect.w >= earlyAspect.h); } const video = document.createElement("video"); video.className = "streamer-video"; // Defer src until viewport near / play tier attaches it. video.dataset.streamerSrc = videoSrc; video.preload = "none"; video.loop = true; video.controls = true; video.playsInline = true; video.setAttribute("controls", ""); video.setAttribute("playsinline", ""); // Autoplay muted; user unmutes via native controls when they want sound. video.muted = true; video.setAttribute("muted", ""); video.volume = STREAMER_VIDEO_DEFAULT_VOLUME; const posterSrc = streamerVideoPosterSrc(row); if (posterSrc) { video.poster = posterSrc; frame.classList.add("has-poster"); frame.style.backgroundImage = `url("${posterSrc}")`; } if (row.video_title) video.setAttribute("aria-label", String(row.video_title)); // Canvas letterbox scan needs CORS when clips are served from OSS. if (staticAssetBase()) video.crossOrigin = "anonymous"; const fitFrameToVideo = () => fitStreamerVideoFrame(video, frame, row); video.addEventListener("loadedmetadata", fitFrameToVideo); video.addEventListener("loadeddata", fitFrameToVideo); video.addEventListener("canplay", fitFrameToVideo); video.addEventListener("error", () => { // Ignore empties before the viewport tier attaches src. if (video.dataset.streamerSrcAttached !== "1") return; vwrap.remove(); }); frame.appendChild(video); vwrap.appendChild(frame); observeStreamerVideo(video); card.appendChild(vwrap); } return card; } function buildStreamerCards(rows, { emptyText } = {}) { resetStreamerVideoObserver(); const wrap = document.createElement("div"); wrap.className = "streamers-grid"; if (!rows.length) { const empty = document.createElement("div"); empty.className = "streamers-empty"; empty.textContent = emptyText || "暂无主播数据"; wrap.appendChild(empty); return wrap; } for (const row of rows) { wrap.appendChild(buildStreamerCard(row)); } // Avatars + hero portraits first; then viewport-tier video attach. waitStreamerImagesThenSyncVideos(wrap); return wrap; } /** Prefer images before reconciling which clip to warm/play. */ function waitStreamerImagesThenSyncVideos(wrap) { const imgs = [ ...wrap.querySelectorAll("img.streamer-avatar"), ...wrap.querySelectorAll(".streamer-hero-chip img"), ]; const start = () => { if (!wrap.isConnected) return; queueReconcileStreamerVideoLoads(); }; if (!imgs.length) { start(); return; } let settled = 0; let started = false; const kick = () => { if (started) return; started = true; start(); }; const onOne = () => { settled += 1; if (settled >= imgs.length) kick(); }; for (const img of imgs) { // complete + broken (naturalWidth 0) still counts as settled. if (img.complete) { onOne(); continue; } img.addEventListener("load", onOne, { once: true }); img.addEventListener("error", onOne, { once: true }); } // Soft cap so a hung image cannot block video forever. setTimeout(kick, 2500); } function buildHeroStreamersPanel(heroKey) { const panel = document.createElement("div"); panel.className = "detail-streamers-panel streamers-center-wrap"; const rows = streamerList().filter((row) => { const heroes = row.heroes; return Array.isArray(heroes) && heroes.includes(heroKey); }); panel.appendChild( buildStreamerCards(rows, { emptyText: "暂无收录常玩该英雄的主播", }) ); return panel; } function renderStreamers() { if (!state.data) return; const body = $("#streamers-body"); const foot = $("#streamers-foot"); if (!body) return; const data = streamersData(); const rows = streamerList(); const fetchedInfo = formatFriendlyTime(data.fetched_at); body.innerHTML = ""; body.appendChild( buildStreamerCards(rows, { emptyText: "暂无主播数据" }) ); if (foot) { foot.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : ""; if (fetchedInfo.title) foot.title = fetchedInfo.title; else foot.removeAttribute("title"); foot.setAttribute("aria-hidden", fetchedInfo.text ? "false" : "true"); } } /** Sync one rendered card's live ring/badge/title with row.is_live (in place). */ function syncStreamerCardLive(card, row) { const wrap = card.querySelector(".streamer-avatar-wrap"); if (!wrap) return; const isLive = row.is_live === true; if (wrap.classList.contains("is-live") === isLive) return; const nick = row.nickname || row.id || "未命名主播"; wrap.classList.toggle("is-live", isLive); wrap .querySelectorAll(".streamer-live-ring, .streamer-live-badge") .forEach((el) => el.remove()); if (isLive) { const ringBase = document.createElement("span"); ringBase.className = "streamer-live-ring"; ringBase.setAttribute("aria-hidden", "true"); const ringPulse = document.createElement("span"); ringPulse.className = "streamer-live-ring streamer-live-ring-pulse"; ringPulse.setAttribute("aria-hidden", "true"); wrap.insertBefore(ringPulse, wrap.firstChild); wrap.insertBefore(ringBase, wrap.firstChild); const liveBadge = document.createElement("span"); liveBadge.className = "streamer-live-badge"; liveBadge.textContent = "直播"; wrap.appendChild(liveBadge); } if (wrap.tagName === "A") { wrap.title = isLive ? "进入直播间" : "前往直播间(未开播)"; wrap.setAttribute( "aria-label", isLive ? `${nick} 直播中,点击进入直播间` : `${nick} 的直播间(当前未开播)` ); } } /** * Visit-triggered live refresh: the Pages Function coalesces concurrent * visitors through a 5-minute edge cache; the local development server uses * the same platform probes with a short in-memory cache. Fires at most once * per page load. */ let liveStatusFetched = false; function refreshStreamerLiveStatus() { if (liveStatusFetched) return; liveStatusFetched = true; fetch("/api/live-status") .then(async (res) => { if (!res.ok) return null; const ctype = (res.headers.get("content-type") || "").toLowerCase(); if (!ctype.includes("application/json")) return null; return res.json(); }) .then((payload) => { const probed = payload && payload.streamers; if (!probed || typeof probed !== "object") return; // Empty map (local stub / total probe error) → keep data.json fallback. if (!Object.keys(probed).length) return; const rows = (state.data && state.data.streamers && state.data.streamers.streamers) || []; let changed = false; for (const row of rows) { const cell = row && row.id ? probed[row.id] : null; if (!cell || typeof cell.is_live !== "boolean") continue; // A failed/stale probe is unknown, never evidence that a room is live. const isLive = cell.stale ? false : cell.is_live; if (cell.stale) console.info(`live-status: ${row.id} probe stale; hiding badge`); if (row.is_live !== isLive) { row.is_live = isLive; changed = true; } } if (!changed) return; // Re-render list views so live streamers bubble to the front; otherwise // sync any leftover cards in place (rows already merged for later paints). if (state.page === "streamers") { renderStreamers(); } else if ( state.page === "heroes" && state.detailTab === "streamers" && state.selectedKey ) { renderDetail(); } else { document .querySelectorAll(".streamer-card[data-streamer-id]") .forEach((card) => { const row = rows.find((r) => r && r.id === card.dataset.streamerId); if (row) syncStreamerCardLive(card, row); }); } }) .catch(() => { /* endpoint missing/unreachable: keep data.json is_live */ }); } const TRENDS_MIN_END_PICK = 200; const TRENDS_TOP_N = 100; function weekWinrateFrac(w) { if (!w) return null; if (w.wr != null && Number.isFinite(Number(w.wr))) return Number(w.wr); const pick = Number(w.pick) || 0; const win = Number(w.win) || 0; return pick > 0 ? win / pick : null; } function formatFracAsPct(frac) { if (frac == null || Number.isNaN(frac)) return "—"; return formatRatePct(Math.round(Number(frac) * 1000) / 10); } function formatDeltaPp(deltaFrac) { if (deltaFrac == null || Number.isNaN(deltaFrac)) { return { text: "—", cls: "" }; } const pp = Math.round(Number(deltaFrac) * 1000) / 10; if (pp === 0) return { text: "0%", cls: "is-flat" }; if (pp > 0) return { text: `+${pp}%`, cls: "is-up" }; return { text: `${pp}%`, cls: "is-down" }; } /** Compact SVG sparkline (values oldest→newest, left→right). */ function buildMiniSparkline(values, colorClass) { const known = (values || []).filter((v) => v != null && Number.isFinite(v)); if (known.length < 2) { return ''; } const w = 64; const h = 20; const padX = 4; const padY = 3; let lo = Math.min(...known); let hi = Math.max(...known); if (hi - lo < 1e-6) { lo -= 0.01; hi += 0.01; } const n = values.length; const pts = []; for (let i = 0; i < n; i++) { const v = values[i]; if (v == null || !Number.isFinite(v)) continue; const x = padX + (n <= 1 ? 0 : (i / (n - 1)) * (w - padX * 2)); const y = padY + (1 - (v - lo) / (hi - lo)) * (h - padY * 2); pts.push({ x, y }); } if (pts.length < 2) { return ''; } const line = pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); const dots = pts .map( (p) => `` ) .join(""); return ``; } function buildTrendsBoardRows(bracket) { const pack = stratzMetaPack(); const byHero = pack.by_hero || {}; const totalsByWeek = new Map(); for (const cell of Object.values(byHero)) { const weeks = (cell && cell.weeks && cell.weeks[bracket]) || []; for (const w of weeks) { const wk = w.week; if (wk == null) continue; totalsByWeek.set(wk, (totalsByWeek.get(wk) || 0) + (Number(w.pick) || 0)); } } const rows = []; for (const [key, cell] of Object.entries(byHero)) { const weeks = (cell && cell.weeks && cell.weeks[bracket]) || []; if (!Array.isArray(weeks) || weeks.length < 2) continue; const latest = weeks[0]; const oldest = weeks[weeks.length - 1]; const endPick = Number(latest.pick) || 0; if (endPick < TRENDS_MIN_END_PICK) continue; const chron = weeks.slice().reverse(); const wrSeries = chron.map(weekWinrateFrac); const prSeries = chron.map((w) => { const tot = totalsByWeek.get(w.week) || 0; if (tot <= 0) return null; return (Number(w.pick) || 0) / (tot / 10); }); const wrStart = weekWinrateFrac(oldest); const wrEnd = weekWinrateFrac(latest); const prStart = prSeries[0]; const prEnd = prSeries[prSeries.length - 1]; rows.push({ key, wrStart, wrEnd, wrDelta: wrStart != null && wrEnd != null ? wrEnd - wrStart : null, wrSeries, prStart, prEnd, prDelta: prStart != null && prEnd != null ? prEnd - prStart : null, prSeries, endPick, }); } const dir = state.trendsSortDir === "asc" ? 1 : -1; const field = state.trendsSort === "pr_end" ? "prEnd" : "wrEnd"; rows.sort((a, b) => { const av = a[field]; const bv = b[field]; if (av == null && bv == null) return a.key.localeCompare(b.key); if (av == null) return 1; if (bv == null) return -1; if (av !== bv) return (av - bv) * dir; return a.key.localeCompare(b.key); }); return rows.slice(0, TRENDS_TOP_N); } function renderTrends() { const body = $("#trends-body"); const bracketsEl = $("#trends-brackets"); const sortEl = $("#trends-sort"); const sub = $("#trends-sub"); const kicker = $("#trends-kicker"); if (!body) return; const pack = stratzMetaPack(); const byHero = pack.by_hero || {}; const hasWeeks = Object.values(byHero).some( (c) => c && c.weeks && Object.keys(c.weeks).length ); if (!hasWeeks) { if (bracketsEl) bracketsEl.innerHTML = ""; if (sortEl) sortEl.innerHTML = ""; if (sub) sub.textContent = ""; body.innerHTML = '
    暂无走势数据
    '; return; } const brackets = (pack.brackets || Object.keys(BRACKET_LABELS)).filter((b) => Object.prototype.hasOwnProperty.call(BRACKET_LABELS, b) ); if (!brackets.includes(state.trendsBracket)) { state.trendsBracket = brackets.includes("legend") ? "legend" : brackets[0]; } const weeksTake = Number(pack.weeks_take) || 8; if (kicker) kicker.textContent = `过去 ${weeksTake} 周走势`; if (bracketsEl) { bracketsEl.innerHTML = ""; bracketsEl.appendChild( buildBracketIconPicker({ selectedKey: state.trendsBracket, ariaLabel: "走势段位", showImmortalTip: false, onChange: (key) => { state.trendsBracket = key; syncStateToUrl(); renderTrends(); const board = $("#trends-view"); if (board) board.scrollTo(0, 0); }, }) ); } if (sortEl) { sortEl.innerHTML = [ { id: "wr_end", label: "按胜率" }, { id: "pr_end", label: "按上场率" }, ] .map((m) => { const active = state.trendsSort === m.id ? " active" : ""; return ``; }) .join(""); sortEl.querySelectorAll(".trends-sort-btn").forEach((btn) => { btn.addEventListener("click", () => { const key = btn.dataset.sort; if (!key || key === state.trendsSort) return; state.trendsSort = key; state.trendsSortDir = "desc"; syncStateToUrl(); renderTrends(); const board = $("#trends-view"); if (board) board.scrollTo(0, 0); }); }); } const fetchedInfo = pack.fetched_at ? formatFriendlyTime(pack.fetched_at) : { text: "", title: "" }; if (sub) { sub.textContent = fetchedInfo.text ? `更新于 ${fetchedInfo.text}` : ""; if (fetchedInfo.title) sub.title = fetchedInfo.title; else sub.removeAttribute("title"); } const rows = buildTrendsBoardRows(state.trendsBracket); if (!rows.length) { body.innerHTML = '
    该段位暂无走势数据
    '; return; } const sortArrow = (key) => { if (state.trendsSort !== key) return ""; return state.trendsSortDir === "asc" ? " ↑" : " ↓"; }; const sortCls = (key) => "trends-sort-th" + (state.trendsSort === key ? " is-active" : ""); const trs = rows .map((row, i) => { const hero = heroByKey(row.key); const name = escapeHtml(hero?.name_loc || row.key); const portrait = escapeHtml(portraitSrc(row.key)); const wrDelta = formatDeltaPp(row.wrDelta); const prDelta = formatDeltaPp(row.prDelta); return ` ${i + 1} ${name} ${formatFracAsPct(row.wrStart)} ${buildMiniSparkline(row.wrSeries, "is-wr")} ${formatFracAsPct(row.wrEnd)} ${wrDelta.text} ${formatFracAsPct(row.prStart)} ${buildMiniSparkline(row.prSeries, "is-pr")} ${formatFracAsPct(row.prEnd)} ${prDelta.text} `; }) .join(""); body.innerHTML = ` ${trs}`; const applyTrendsSortClick = (key) => { if (!key) return; if (state.trendsSort === key) { state.trendsSortDir = state.trendsSortDir === "desc" ? "asc" : "desc"; } else { state.trendsSort = key; state.trendsSortDir = "desc"; } syncStateToUrl(); renderTrends(); }; body.querySelectorAll("th.trends-sort-th").forEach((th) => { th.addEventListener("click", () => applyTrendsSortClick(th.dataset.sort)); }); body.querySelectorAll("tr.trends-row").forEach((tr) => { tr.addEventListener("click", () => { const key = tr.dataset.hero; if (!key || !heroByKey(key)) return; state.page = "heroes"; state.selectedKey = key; state.detailTab = "trends"; state.statsBracket = state.trendsBracket; state.inspect = null; syncStateToUrl(); render(); }); }); } function mechanicQueryMeta() { return state.data.mechanic_query || { order: [], labels: {}, blurbs: {}, groups: [] }; } function mechanicBlurb(effect) { const blurbs = (mechanicQueryMeta().blurbs) || {}; return blurbs[effect] || ""; } function collectMechanicAbilities(effect) { const byHero = (state.data.hero_abilities && state.data.hero_abilities.by_hero) || {}; const out = []; for (const hero of state.data.heroes || []) { const cell = byHero[hero.key]; if (!cell) continue; for (const ab of cell.abilities || []) { if (!ab || !ab.key) continue; if (!(ab.tags || []).includes(effect)) continue; out.push({ hero, ability: ab }); } } out.sort((a, b) => { const an = a.hero.name_loc || a.hero.key; const bn = b.hero.name_loc || b.hero.key; if (an !== bn) return an.localeCompare(bn, "zh"); return (a.ability.name_loc || a.ability.key).localeCompare( b.ability.name_loc || b.ability.key, "zh" ); }); return out; } function collectMechanicItems(effect) { const meta = state.data.items_meta || {}; const out = []; const seen = new Set(); for (const row of Object.values(meta)) { if (!row || !row.key || seen.has(row.key)) continue; if (!(row.tags || []).includes(effect)) continue; seen.add(row.key); out.push(row); } out.sort((a, b) => { const ac = a.cost == null ? 1e9 : Number(a.cost); const bc = b.cost == null ? 1e9 : Number(b.cost); if (ac !== bc) return ac - bc; return (a.name_loc || a.key).localeCompare(b.name_loc || b.key, "zh"); }); return out; } function renderMechanicsAside() { const root = $("#mechanics-effects"); if (!root) return; const mq = mechanicQueryMeta(); const labels = mq.labels || {}; const groups = mq.groups && mq.groups.length ? mq.groups : [{ label: "机制", keys: mq.order || [] }]; root.innerHTML = ""; for (const g of groups) { const wrap = document.createElement("div"); wrap.className = "mechanics-group"; const title = document.createElement("div"); title.className = "mechanics-group-label"; title.textContent = g.label || ""; wrap.appendChild(title); for (const key of g.keys || []) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "rankings-region-btn"; if (key === state.mechanicEffect) btn.classList.add("active"); btn.textContent = labels[key] || key; btn.dataset.effect = key; btn.addEventListener("click", () => { if (state.mechanicEffect === key) return; state.mechanicEffect = key; syncStateToUrl(); renderMechanics(); }); wrap.appendChild(btn); } root.appendChild(wrap); } } function renderMechanics() { renderMechanicsAside(); const body = $("#mechanics-body"); if (!body) return; const mq = mechanicQueryMeta(); const labels = mq.labels || {}; const effect = state.mechanicEffect || (mq.order && mq.order[0]) || "basic_dispel"; state.mechanicEffect = effect; const label = labels[effect] || effect; const abilities = collectMechanicAbilities(effect); const items = collectMechanicItems(effect); const blurb = mechanicBlurb(effect); body.innerHTML = ""; const head = document.createElement("header"); head.className = "mechanics-blurb"; const headTitle = document.createElement("h3"); headTitle.className = "mechanics-blurb-title"; headTitle.textContent = label; head.appendChild(headTitle); const headText = document.createElement("p"); headText.className = "mechanics-blurb-text"; headText.textContent = blurb || ("列出施加「" + label + "」的技能与物品。"); head.appendChild(headText); body.appendChild(head); const skillsSec = document.createElement("section"); skillsSec.className = "mechanics-section"; const skillsTitle = document.createElement("h3"); skillsTitle.className = "mechanics-section-title"; skillsTitle.innerHTML = "技能 " + abilities.length + ""; skillsSec.appendChild(skillsTitle); const skillsList = document.createElement("div"); skillsList.className = "mechanics-list"; if (!abilities.length) { const empty = document.createElement("p"); empty.className = "mechanics-empty"; empty.textContent = "暂无匹配技能"; skillsList.appendChild(empty); } else { skillsList.classList.add("mechanics-skill-grid"); for (const row of abilities) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "mechanics-skill-card"; const portrait = document.createElement("span"); portrait.className = "mechanics-skill-portrait"; const heroImg = document.createElement("img"); heroImg.className = "mechanics-hero-wide"; heroImg.src = portraitSrc(row.hero.key); heroImg.alt = row.hero.name_loc || row.hero.key; heroImg.loading = "lazy"; portrait.appendChild(heroImg); const foot = document.createElement("span"); foot.className = "mechanics-skill-foot"; const abImg = document.createElement("img"); abImg.className = "mechanics-ability"; abImg.src = abilityIconSrc(row.ability.key); abImg.alt = ""; abImg.loading = "lazy"; abImg.onerror = function () { this.onerror = null; this.src = assetUrl("ability/innate.png"); }; const textWrap = document.createElement("span"); textWrap.className = "mechanics-text"; const primary = document.createElement("span"); primary.className = "mechanics-primary"; primary.textContent = row.ability.name_loc || row.ability.key; const secondary = document.createElement("span"); secondary.className = "mechanics-secondary"; secondary.textContent = row.hero.name_loc || row.hero.key; textWrap.appendChild(primary); textWrap.appendChild(secondary); foot.appendChild(abImg); foot.appendChild(textWrap); btn.appendChild(portrait); btn.appendChild(foot); btn.title = `${row.hero.name_loc || row.hero.key} · ${row.ability.name_loc || row.ability.key}`; btn.addEventListener("click", () => { state.page = "heroes"; state.selectedKey = row.hero.key; state.detailTab = "skills"; state.inspect = { type: "skill", id: "ability:" + row.ability.key }; syncStateToUrl(); render(); }); skillsList.appendChild(btn); } } skillsSec.appendChild(skillsList); body.appendChild(skillsSec); if (items.length) { const itemsSec = document.createElement("section"); itemsSec.className = "mechanics-section"; const itemsTitle = document.createElement("h3"); itemsTitle.className = "mechanics-section-title"; itemsTitle.innerHTML = "物品 " + items.length + ""; itemsSec.appendChild(itemsTitle); const itemsList = document.createElement("div"); itemsList.className = "mechanics-list"; for (const row of items) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "mechanics-row"; const itemImg = document.createElement("img"); itemImg.className = "mechanics-item"; itemImg.src = itemIconSrc(row.key); itemImg.alt = ""; itemImg.loading = "lazy"; itemImg.onerror = function () { this.onerror = null; this.src = itemIconOssSrc(row.key); }; const textWrap = document.createElement("span"); textWrap.className = "mechanics-text"; const primary = document.createElement("span"); primary.className = "mechanics-primary"; primary.textContent = row.name_loc || row.key; const secondary = document.createElement("span"); secondary.className = "mechanics-secondary"; secondary.textContent = row.key; textWrap.appendChild(primary); textWrap.appendChild(secondary); btn.appendChild(itemImg); btn.appendChild(textWrap); if (row.cost != null && row.cost !== "") { const cost = document.createElement("span"); cost.className = "mechanics-cost"; cost.textContent = String(row.cost); btn.appendChild(cost); } btn.addEventListener("click", () => { state.page = "items"; state.selectedItemKey = row.key; state.selectedKey = null; state.inspect = null; syncStateToUrl(); render(); }); itemsList.appendChild(btn); } itemsSec.appendChild(itemsList); body.appendChild(itemsSec); } } function setPage(page) { if ( page !== "heroes" && page !== "rankings" && page !== "matches" && page !== "streamers" && page !== "trends" && page !== "mechanics" && page !== "items" && page !== "patches" ) return; closeTalentPopover(); state.page = page; if (page === "heroes") { state.selectedItemKey = null; } else if (page === "rankings") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } else if (page === "matches") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } else if (page === "streamers") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } else if (page === "trends") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } else if (page === "mechanics") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } else if (page === "items") { state.selectedKey = null; state.inspect = null; } else if (page === "patches") { state.selectedKey = null; state.selectedItemKey = null; state.inspect = null; } syncStateToUrl(); render(); } function syncChrome() { document.querySelectorAll(".main-tab").forEach((btn) => { btn.classList.toggle("active", btn.dataset.page === state.page); }); const detailDrawer = $("#detail-drawer"); const heroesTb = $("#heroes-toolbar"); const heroSearch = $("#q"); const itemSearch = $("#q-item"); const heroesView = $("#heroes-view"); const rankingsView = $("#rankings-view"); const matchesView = $("#matches-view"); const streamersView = $("#streamers-view"); const trendsView = $("#trends-view"); const mechanicsView = $("#mechanics-view"); const itemsView = $("#items-view"); const patchesView = $("#patches-view"); if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes"); if (detailDrawer) detailDrawer.classList.toggle("hidden", state.page !== "heroes"); if (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes"); if (itemSearch) itemSearch.classList.toggle("hidden", state.page !== "items"); if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes"); if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings"); if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches"); if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers"); if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends"); if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics"); if (itemsView) itemsView.classList.toggle("hidden", state.page !== "items"); if (patchesView) patchesView.classList.toggle("hidden", state.page !== "patches"); // Drawer open animation needs detail content laid out first; heroes path syncs in renderDetail. if (state.page !== "heroes") syncDetailDrawer(); } function render() { syncChrome(); if (state.page === "heroes") { renderTagbar(); renderBoard(); renderDetail(); } else if (state.page === "rankings") { renderRankings(); } else if (state.page === "matches") { renderMatchesPage(); } else if (state.page === "streamers") { renderStreamers(); } else if (state.page === "trends") { renderTrends(); } else if (state.page === "mechanics") { renderMechanics(); } else if (state.page === "items") { renderItemDetail(); renderItemShop(); } else { renderPatches(); } renderHeroesSiteFoot(); updateDocumentMeta(); } // Search-box URL sync timers (debounced replace so typing does not spam history). let heroSearchSyncTimer = 0; let itemSearchSyncTimer = 0; const SEARCH_SYNC_DEBOUNCE_MS = 300; /** * Apply a router-produced state patch (from a parsed URL) to state, then render. * Validates every field against loaded data so bad deep-links degrade gracefully * (unknown hero/item/version is dropped rather than crashing the UI). */ function applyPatch(patch) { if (!state.data) { render(); return; } // Page (default heroes on bad/missing). if ( patch.page && ["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page) ) { state.page = patch.page; } else { state.page = "heroes"; } // Hero + detail sub-tab. if (patch.heroKey && heroByKey(patch.heroKey)) { state.selectedKey = patch.heroKey; if ( patch.detailTab && ["skills", "core", "fears", "trends", "stats", "matchups", "matches", "streamers", "patches"].includes(patch.detailTab) ) { state.detailTab = patch.detailTab === "stats" ? "trends" : patch.detailTab; } else { state.detailTab = "skills"; } } else { state.selectedKey = null; state.detailTab = "skills"; } // Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less). state.inspect = null; // Trends board bracket + sort (top-level 走势 page). if ( patch.trendsBracket && Object.prototype.hasOwnProperty.call(BRACKET_LABELS, patch.trendsBracket) ) { state.trendsBracket = patch.trendsBracket; } else if (state.page === "trends" && !state.trendsBracket) { state.trendsBracket = "legend"; } if (patch.trendsSort === "wr_end" || patch.trendsSort === "pr_end") { state.trendsSort = patch.trendsSort; } else if (state.page === "trends" && patch.page) { state.trendsSort = "wr_end"; } // Mechanics page effect key. { const mechOrder = (state.data.mechanic_query && state.data.mechanic_query.order) || []; if (patch.mechanicEffect && mechOrder.includes(patch.mechanicEffect)) { state.mechanicEffect = patch.mechanicEffect; } else if (state.page === "mechanics") { state.mechanicEffect = mechOrder[0] || "basic_dispel"; } } // Immortal region (rankings page). { const lb = leaderboardsData(); const regions = lb.regions || {}; const order = lb.region_order || []; const valid = new Set([...order, ...Object.keys(regions)]); if (patch.rankingRegion && valid.has(patch.rankingRegion)) { state.rankingRegion = patch.rankingRegion; } else if (state.page === "rankings") { state.rankingRegion = lb.default_region || order[0] || "china"; } } // Star-player filter + origin + page (matches page). if (state.page === "matches") { if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) { state.matchesPlayerId = String(patch.matchesPlayerId); } else { state.matchesPlayerId = null; } if ( patch.matchesOrigin === "pro" || patch.matchesOrigin === "china" || patch.matchesOrigin === "all" ) { state.matchesOrigin = patch.matchesOrigin; } else { state.matchesOrigin = "all"; } const mp = Number(patch.matchesPage); state.matchesPage = Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1; } // Item (items page) — must exist in the shop catalog. if (patch.itemKey && shopItem(patch.itemKey)) { state.selectedItemKey = patch.itemKey; } else { state.selectedItemKey = null; } // Patch version (patches page; null means latest). if ( patch.patchVersion && (state.data.patches || []).some((p) => p.version === patch.patchVersion) ) { state.selectedPatch = patch.patchVersion; } else { state.selectedPatch = null; } // Tag filters (heroes page only) — drop unknown tag names. if (patch.tags instanceof Set) { const valid = new Set(state.data.tag_order || []); state.tagFilters = new Set([...patch.tags].filter((t) => valid.has(t))); } if (typeof patch.query === "string") { state.query = patch.query; const inp = $("#q"); if (inp && inp.value !== state.query) inp.value = state.query; } if (typeof patch.itemQuery === "string") { state.itemQuery = patch.itemQuery; const inp = $("#q-item"); if (inp && inp.value !== state.itemQuery) inp.value = state.itemQuery; } render(); } function bindSearch() { const input = $("#q"); if (input) { input.value = state.query; input.addEventListener("input", () => { state.query = input.value; if (state.page === "heroes") renderBoard(); if (heroSearchSyncTimer) clearTimeout(heroSearchSyncTimer); heroSearchSyncTimer = setTimeout(() => { heroSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); } const itemInput = $("#q-item"); if (itemInput) { itemInput.value = state.itemQuery; itemInput.addEventListener("input", () => { state.itemQuery = itemInput.value; if (state.page === "items") renderItemShop(); if (itemSearchSyncTimer) clearTimeout(itemSearchSyncTimer); itemSearchSyncTimer = setTimeout(() => { itemSearchSyncTimer = 0; syncStateToUrl({ replace: true }); }, SEARCH_SYNC_DEBOUNCE_MS); }); } } function bindPageChrome() { document.querySelectorAll(".main-tab").forEach((btn) => { btn.addEventListener("click", () => setPage(btn.dataset.page)); }); } /** Click empty area above role tags (board remainder / toolbar chrome) to dismiss detail. */ function bindHeroesDismiss() { const view = $("#heroes-view"); if (view) { view.addEventListener("click", (e) => { if (state.page !== "heroes" || !state.selectedKey) return; if (e.target.closest(".hero")) return; clearHeroDetail(); }); } const toolbar = $("#heroes-toolbar"); if (toolbar) { toolbar.addEventListener("click", (e) => { if (state.page !== "heroes" || !state.selectedKey) return; if (e.target.closest("button, a, input, select, textarea, label")) return; clearHeroDetail(); }); } bindDetailDrawerGestures(); } async function main() { // mobile-gate.js sets this before paint; skip desktop data boot on phones/tablets. if (typeof window !== "undefined" && window.__CLIMPEROR_MOBILE__) return; document.addEventListener("keydown", (e) => { if (e.key !== "Escape") return; if (talentPopoverEl) { closeTalentPopover(); return; } if (state.page === "heroes") { if (state.query && document.activeElement === $("#q")) { state.query = ""; $("#q").value = ""; syncStateToUrl({ replace: true }); renderBoard(); return; } clearHeroDetail(); return; } if (state.itemQuery && document.activeElement === $("#q-item")) { state.itemQuery = ""; $("#q-item").value = ""; syncStateToUrl({ replace: true }); renderItemShop(); return; } if (state.selectedItemKey) { state.selectedItemKey = null; syncStateToUrl(); render(); } }); document.addEventListener("mousedown", (e) => { if (!talentPopoverEl) return; const t = e.target; if (talentPopoverEl.contains(t)) return; if (t.closest && t.closest(".skill-icon.talent-trigger")) return; closeTalentPopover(); }); window.addEventListener("resize", () => { closeTalentPopover(); if ( state.page === "heroes" && state.selectedKey && !detailDrawerClosing && !detailDrawerDrag ) { applyDetailDrawerHeight(); } }); document.addEventListener( "scroll", (e) => { if (!talentPopoverEl) return; if (e.target === document || e.target === document.documentElement) { closeTalentPopover(); return; } if (e.target && e.target.id === "detail") closeTalentPopover(); }, true ); bindSearch(); bindPageChrome(); bindHeroesDismiss(); const brandLogo = document.querySelector(".brand-logo"); if (brandLogo) brandLogo.src = assetUrl("/ui-icon/dota2_logo_wordmark.png"); const favicon = document.querySelector('link[rel="icon"]'); if (favicon) favicon.href = assetUrl("/ui-icon/dota2_logo.png"); // Absolute path: History routes like /heroes/axe must not resolve data.json relatively. const res = await fetch("/data.json"); if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`); state.data = await res.json(); state.data.relations = state.data.relations || { counters: [], synergies: [] }; state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} }; state.data.hero_stats = state.data.hero_stats || { brackets: [], by_hero: {}, totals: {}, fetched_at: null, window_days: 7, window_label_zh: "近约 7 天公开对局", }; state.data.hero_matches = state.data.hero_matches || { meta: {}, items: {}, by_hero: {}, }; state.data.hero_item_fears = state.data.hero_item_fears || { items: {}, by_hero: {} }; state.data.hero_abilities = state.data.hero_abilities || { by_hero: {} }; state.data.item_shop = state.data.item_shop || { basic: { sections: [] }, upgraded: { sections: [] }, items: {}, }; state.data.items_meta = state.data.items_meta || {}; state.data.patches = state.data.patches || []; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_details = state.data.patch_details || {}; state.data.leaderboards = state.data.leaderboards || { fetched_at: null, source: "valve", default_region: "china", region_order: ["china", "europe", "americas", "se_asia"], regions: {}, }; state.data.pro_matches = state.data.pro_matches || { meta: {}, items: {}, pros: {}, by_pro: {}, by_hero: {}, }; state.data.streamers = state.data.streamers || { fetched_at: null, source: "manual+douyin", platform_meta: { douyin: { label_zh: "抖音", icon: "ui-icon/platform_douyin.png" }, }, streamers: [], }; _skillEntriesCache.clear(); _itemIdIndex = null; _resourceBarScaleCache = null; _innateAbilityKeys = null; installRouter({ getState: () => state, applyPatch }); applyUrlToState(); clearSeoPrerender(); refreshStreamerLiveStatus(); } main().catch((err) => { const el = $("#columns") || $("#item-shop"); if (el) el.textContent = "加载失败: " + err; });