Add 冰眼/蛇矛 aliases, correct Tiny fears to Hydra's Breath, and fix production match detail POST 405. Co-authored-by: Cursor <cursoragent@cursor.com>
7632 lines
251 KiB
JavaScript
7632 lines
251 KiB
JavaScript
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, PLAYERS_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) {
|
||
// Cache-bust if OSS was briefly polluted with 96×96 match crops.
|
||
return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide2`);
|
||
}
|
||
|
||
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/<file>. */
|
||
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 = {
|
||
home: "我",
|
||
heroes: "英雄克制与搭档",
|
||
rankings: "Immortal 排行",
|
||
streamers: "主播",
|
||
matches: "明星比赛",
|
||
players: "玩家战绩",
|
||
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 === "home") {
|
||
title = `我 — ${brand}`;
|
||
description = "Steam 登录后查看本人近期比赛与战绩。";
|
||
} else if (page === "players") {
|
||
const aid = st.playerAccountId;
|
||
if (aid && st.playerMatchId) {
|
||
title = `比赛 ${st.playerMatchId} — ${brand}`;
|
||
description = `玩家 ${aid} 的比赛 ${st.playerMatchId} 战绩详情。`;
|
||
} else if (aid) {
|
||
title = `玩家 ${aid} — ${brand}`;
|
||
description = `玩家 ${aid} 的近期比赛与战绩(上分帝 PC 赛后生成)。`;
|
||
} else {
|
||
title = `玩家战绩 — ${brand}`;
|
||
description = "PC 上分帝赛后生成的玩家主页与比赛详情。";
|
||
}
|
||
} 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,
|
||
/** Steam session from /api/auth/me; null until first probe. */
|
||
auth: null,
|
||
page: "heroes", // home | heroes | rankings | matches | streamers | trends | mechanics | items | patches | players
|
||
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,
|
||
/** PC post-match player pages: account_id / match_id */
|
||
playerAccountId: null,
|
||
playerMatchId: null,
|
||
/** In-memory cache for /players fetches */
|
||
_playerProfile: null,
|
||
_playerMatch: null,
|
||
_playerLoadKey: null,
|
||
_playerEnrichKey: null,
|
||
/** 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 = `<div class="col-head"><img class="attr-icon" src="${attrIconSrc(attr)}" alt="${attr_labels[attr]}" /><span>${attr_labels[attr]}</span></div>`;
|
||
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 = `<span class="m-sel" title="选中" aria-label="选中"></span>`;
|
||
} 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(`<span class="m-counter" title="克制" aria-label="克制">克</span>`);
|
||
}
|
||
if (rel.countered) {
|
||
btn.classList.add("is-countered");
|
||
bits.push(`<span class="m-countered" title="被克制" aria-label="被克制">怕</span>`);
|
||
}
|
||
if (rel.synergy) {
|
||
btn.classList.add("is-synergy");
|
||
bits.push(`<span class="m-synergy" title="搭档" aria-label="搭档">搭</span>`);
|
||
}
|
||
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);
|
||
}
|
||
// Filters change grid height; keep the open drawer from covering the last row.
|
||
if (
|
||
state.selectedKey &&
|
||
!detailDrawerClosing &&
|
||
!detailDrawerDrag &&
|
||
!$("#detail-drawer")?.classList.contains("is-collapsed")
|
||
) {
|
||
applyDetailDrawerHeight();
|
||
}
|
||
}
|
||
|
||
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 = 280;
|
||
|
||
/** Natural height of the attr hero grid (content-sized; not the clipped viewport). */
|
||
function heroesGridContentHeight() {
|
||
const cols = $("#columns");
|
||
if (!cols) return 0;
|
||
const view = $("#heroes-view");
|
||
let pad = 0;
|
||
if (view) {
|
||
const cs = getComputedStyle(view);
|
||
pad = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
|
||
}
|
||
// offsetHeight is the laid-out grid even when #heroes-view is overflow-clipped.
|
||
return Math.ceil(cols.offsetHeight + pad);
|
||
}
|
||
|
||
/**
|
||
* Drawer height = leftover after topbar + full hero grid + role tags.
|
||
* Skills / other tabs flex+scroll inside; do not size the drawer for skill content.
|
||
*/
|
||
function detailDrawerTargetHeight() {
|
||
const vh = window.innerHeight || 800;
|
||
const topbar = document.querySelector(".topbar")?.offsetHeight || 60;
|
||
const toolbar = document.querySelector(".hero-role-toolbar")?.offsetHeight || 52;
|
||
const heroNeed = heroesGridContentHeight() || Math.round(vh * 0.36);
|
||
const gap = 8;
|
||
const available = vh - topbar - toolbar - heroNeed - gap;
|
||
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 =
|
||
'<svg viewBox="0 0 16 16" focusable="false"><path d="M1.5 1.5h5v5h-5zM9.5 1.5h5v5h-5zM1.5 9.5h5v5h-5zM9.5 9.5h5v5h-5z"/></svg>';
|
||
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 heroById(id) {
|
||
if (id == null) return null;
|
||
const n = Number(id);
|
||
return (state.data.heroes || []).find((h) => Number(h.id) === n) || null;
|
||
}
|
||
|
||
/** OSS/local base for players/*.json; empty PLAYERS_ASSET_BASE → STATIC_ASSET_BASE. */
|
||
function playersAssetBase() {
|
||
const dedicated =
|
||
typeof PLAYERS_ASSET_BASE !== "undefined" ? trimBase(PLAYERS_ASSET_BASE) : "";
|
||
return dedicated || staticAssetBase();
|
||
}
|
||
|
||
/**
|
||
* Load player profile or match detail.
|
||
* kind: "profile" | "match"
|
||
* Local: /api/players/{account}[/match]
|
||
* OSS: {base}/players/{account}/profile.json
|
||
* {base}/players/{account}/matches/{match}.json
|
||
*/
|
||
async function fetchPlayerJson(accountId, matchId) {
|
||
const aid = String(accountId || "");
|
||
if (!/^\d+$/.test(aid)) return null;
|
||
const mid = matchId != null ? String(matchId) : "";
|
||
const localPath = mid ? `/api/players/${aid}/${mid}` : `/api/players/${aid}`;
|
||
try {
|
||
const local = await fetch(localPath);
|
||
if (local.ok) return await local.json();
|
||
} catch (_) {
|
||
/* fall through to OSS */
|
||
}
|
||
const base = playersAssetBase();
|
||
if (!base) return null;
|
||
const ossPath = mid
|
||
? `${base}/players/${aid}/matches/${mid}.json`
|
||
: `${base}/players/${aid}/profile.json`;
|
||
try {
|
||
const res = await fetch(ossPath);
|
||
if (res.ok) return await res.json();
|
||
} catch (_) {
|
||
/* missing / private */
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** True when the profile has career or recent rows worth painting. */
|
||
function playerProfileHasStats(profile) {
|
||
if (!profile || profile.error) return false;
|
||
const hasCareer = profile.career && Number(profile.career.games) > 0;
|
||
const hasRecent =
|
||
(Array.isArray(profile.recent) && profile.recent.length > 0) ||
|
||
(profile.recent_20 && Number(profile.recent_20.sample) > 0);
|
||
return !!(hasCareer || hasRecent);
|
||
}
|
||
|
||
/** Empty shell → hard sync. TTL stale alone is not empty. */
|
||
function playerProfileNeedsRefresh(profile) {
|
||
return !playerProfileHasStats(profile);
|
||
}
|
||
|
||
function playerProfileIsSoftStale(profile) {
|
||
if (!profile) return false;
|
||
if (profile.stale) return true;
|
||
if (profile.availability && profile.availability.stale) return true;
|
||
return false;
|
||
}
|
||
|
||
/** Local/Pages: backfill profile. force=false uses server TTL cache. */
|
||
async function enrichPlayerProfile(
|
||
accountId,
|
||
{ includeGsi = true, force = false } = {}
|
||
) {
|
||
const aid = String(accountId || "");
|
||
if (!/^\d+$/.test(aid)) return null;
|
||
try {
|
||
const res = await fetch("/api/players/enrich", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({
|
||
account_id: Number(aid),
|
||
include_gsi: !!includeGsi,
|
||
force: !!force,
|
||
}),
|
||
});
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
return data && data.profile ? data.profile : null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Production: GET /api/players/me (D1). Local serve uses disk TTL cache. */
|
||
async function fetchMyPlayerProfile() {
|
||
try {
|
||
const res = await fetch("/api/players/me", {
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!res.ok) return null;
|
||
const data = await res.json();
|
||
if (!data || data.error) return null;
|
||
return data;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** Poll /me while Worker/local background enrich fills an empty/stale shell. */
|
||
async function pollMyPlayerProfile({
|
||
attempts = 6,
|
||
delayMs = 2500,
|
||
accountId = null,
|
||
} = {}) {
|
||
let last = null;
|
||
for (let i = 0; i < attempts; i++) {
|
||
if (
|
||
accountId != null &&
|
||
String(state.playerAccountId || "") !== String(accountId)
|
||
) {
|
||
return last;
|
||
}
|
||
last = await fetchMyPlayerProfile();
|
||
if (last && !playerProfileNeedsRefresh(last)) return last;
|
||
if (i + 1 < attempts) {
|
||
await new Promise((r) => setTimeout(r, delayMs));
|
||
}
|
||
}
|
||
return last;
|
||
}
|
||
|
||
const LOBBY_LABELS = {
|
||
0: "普通",
|
||
1: "练习",
|
||
2: "联赛",
|
||
7: "天梯",
|
||
9: "勇士联赛",
|
||
};
|
||
|
||
function lobbyLabel(row) {
|
||
if (!row) return "";
|
||
const lt = row.lobby_type;
|
||
if (lt != null && LOBBY_LABELS[lt]) return LOBBY_LABELS[lt];
|
||
return "";
|
||
}
|
||
|
||
function appendStatCards(host, items) {
|
||
if (!items.length) return;
|
||
const row = document.createElement("div");
|
||
row.className = "players-stat-row";
|
||
for (const it of items) {
|
||
if (it.value == null || it.value === "") continue;
|
||
const card = document.createElement("div");
|
||
card.className = "players-stat-card";
|
||
card.innerHTML = `<span class="players-stat-label">${escapeHtml(
|
||
it.label
|
||
)}</span><span class="players-stat-value">${escapeHtml(
|
||
String(it.value)
|
||
)}</span>`;
|
||
row.appendChild(card);
|
||
}
|
||
if (row.childElementCount) host.appendChild(row);
|
||
}
|
||
|
||
/** Local-only: ensure match detail JSON exists, then return it. */
|
||
async function ensurePlayerMatch(accountId, matchId) {
|
||
const aid = String(accountId || "");
|
||
const mid = String(matchId || "");
|
||
if (!/^\d+$/.test(aid) || !/^\d+$/.test(mid)) {
|
||
return { match: null, error: "bad id" };
|
||
}
|
||
const existing = await fetchPlayerJson(aid, mid);
|
||
if (existing) return { match: existing, error: "" };
|
||
try {
|
||
const res = await fetch("/api/players/ensure-match", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||
body: JSON.stringify({ account_id: Number(aid), match_id: Number(mid) }),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
return { match: null, error: (data && data.error) || `HTTP ${res.status}` };
|
||
}
|
||
return { match: data.match || null, error: data.match ? "" : "empty" };
|
||
} catch (e) {
|
||
return { match: null, error: String((e && e.message) || e) };
|
||
}
|
||
}
|
||
|
||
function openPlayerPage(accountId, matchId) {
|
||
const aid = String(accountId || "");
|
||
if (!/^\d+$/.test(aid)) return;
|
||
state.page = "players";
|
||
state.playerAccountId = aid;
|
||
state.playerMatchId =
|
||
matchId != null && /^\d+$/.test(String(matchId)) ? String(matchId) : null;
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
syncStateToUrl();
|
||
render();
|
||
}
|
||
|
||
function authAccountId() {
|
||
const a = state.auth;
|
||
if (!a || !a.authenticated) return null;
|
||
const id = a.account_id;
|
||
return id != null && /^\d+$/.test(String(id)) ? String(id) : null;
|
||
}
|
||
|
||
async function fetchAuthMe() {
|
||
try {
|
||
const res = await fetch("/api/auth/me", {
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
if (!res.ok) {
|
||
state.auth = { authenticated: false };
|
||
return state.auth;
|
||
}
|
||
const data = await res.json();
|
||
state.auth =
|
||
data && data.authenticated
|
||
? {
|
||
authenticated: true,
|
||
steamid: data.steamid || null,
|
||
account_id: data.account_id,
|
||
personaname: data.personaname || null,
|
||
avatar: data.avatar || null,
|
||
}
|
||
: { authenticated: false };
|
||
return state.auth;
|
||
} catch (_) {
|
||
state.auth = { authenticated: false };
|
||
return state.auth;
|
||
}
|
||
}
|
||
|
||
function syncAuthChrome() {
|
||
const loginBtn = $("#steam-login-btn");
|
||
const userEl = $("#auth-user");
|
||
const avatar = $("#auth-avatar");
|
||
const nameEl = $("#auth-name");
|
||
const homeTab = $("#tab-home");
|
||
const authed = !!(state.auth && state.auth.authenticated);
|
||
if (loginBtn) loginBtn.classList.toggle("hidden", authed);
|
||
if (userEl) userEl.classList.toggle("hidden", !authed);
|
||
if (homeTab) homeTab.classList.toggle("hidden", !authed);
|
||
if (authed && userEl) {
|
||
const name = state.auth.personaname || `玩家 ${state.auth.account_id}`;
|
||
if (nameEl) nameEl.textContent = name;
|
||
if (avatar) {
|
||
if (state.auth.avatar) {
|
||
avatar.src = state.auth.avatar;
|
||
avatar.alt = name;
|
||
avatar.classList.remove("hidden");
|
||
} else {
|
||
avatar.removeAttribute("src");
|
||
avatar.alt = "";
|
||
avatar.classList.add("hidden");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
async function logoutAuth() {
|
||
try {
|
||
await fetch("/api/auth/logout", {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { Accept: "application/json" },
|
||
});
|
||
} catch (_) {
|
||
/* ignore */
|
||
}
|
||
state.auth = { authenticated: false };
|
||
if (state.page === "home") {
|
||
state.page = "heroes";
|
||
state.playerAccountId = null;
|
||
state.playerMatchId = null;
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
state._playerEnrichKey = null;
|
||
}
|
||
syncAuthChrome();
|
||
syncStateToUrl();
|
||
render();
|
||
}
|
||
|
||
function bindAuthChrome() {
|
||
const logoutBtn = $("#auth-logout");
|
||
if (logoutBtn) {
|
||
logoutBtn.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
logoutAuth();
|
||
});
|
||
}
|
||
}
|
||
|
||
function renderHomeGate(root) {
|
||
root.replaceChildren();
|
||
const box = document.createElement("div");
|
||
box.className = "rankings-empty auth-home-gate";
|
||
box.innerHTML =
|
||
'<p>登录 Steam 后可查看本人近期比赛与战绩。</p>' +
|
||
'<p><a class="steam-login-btn" href="/api/auth/steam">Steam 登录</a></p>';
|
||
root.appendChild(box);
|
||
}
|
||
|
||
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 markAbilityIconMissing(img) {
|
||
img.onerror = null;
|
||
img.removeAttribute("src");
|
||
img.alt = "";
|
||
img.classList.add("missing");
|
||
}
|
||
|
||
function setAbilityIcon(img, abilityKey, isInnate) {
|
||
if (isInnate) {
|
||
img.src = innateIconSrc();
|
||
img.onerror = () => markAbilityIconMissing(img);
|
||
return;
|
||
}
|
||
img.src = abilityIconSrc(abilityKey);
|
||
img.onerror = () => {
|
||
// Many innates / renamed keys 404 on CDN — shared badge, then quiet miss.
|
||
img.onerror = () => markAbilityIconMissing(img);
|
||
img.src = innateIconSrc();
|
||
};
|
||
}
|
||
|
||
/** 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);
|
||
const metaExtra = (state.data.items_meta || {})[key] || null;
|
||
const aliases =
|
||
(shop?.aliases?.length && shop.aliases) ||
|
||
(metaExtra?.aliases?.length && metaExtra.aliases) ||
|
||
undefined;
|
||
if (shop) {
|
||
return aliases && !shop.aliases?.length ? { ...shop, aliases } : shop;
|
||
}
|
||
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 || "",
|
||
...(aliases ? { aliases } : {}),
|
||
};
|
||
}
|
||
}
|
||
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 || "",
|
||
...(aliases ? { aliases } : {}),
|
||
};
|
||
}
|
||
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, starFile }. */
|
||
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, Math.min(5, 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,
|
||
// OpenDota star overlay (rank_star_1..5); immortal has no stars.
|
||
starFile:
|
||
medalKey !== "immortal" && stars >= 1 && stars <= 5
|
||
? `rank_star_${stars}.png`
|
||
: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Medal icon (+ optional star overlay) for a concrete rank_tier.
|
||
* Text label goes in title/alt only — stars convey 传奇四 etc. visually.
|
||
*/
|
||
function createRankMedalEl(rank, className = "rank-medal") {
|
||
if (!rank || !rank.iconFile) return null;
|
||
const wrap = document.createElement("span");
|
||
wrap.className = className;
|
||
wrap.title = rank.label;
|
||
const base = document.createElement("img");
|
||
base.className = "rank-medal-base";
|
||
base.src = rankIconSrc(rank.iconFile);
|
||
base.alt = rank.label;
|
||
base.loading = "lazy";
|
||
base.decoding = "async";
|
||
wrap.appendChild(base);
|
||
if (rank.starFile) {
|
||
const stars = document.createElement("img");
|
||
stars.className = "rank-medal-stars";
|
||
stars.src = rankIconSrc(rank.starFile);
|
||
stars.alt = "";
|
||
stars.setAttribute("aria-hidden", "true");
|
||
stars.loading = "lazy";
|
||
stars.decoding = "async";
|
||
wrap.appendChild(stars);
|
||
}
|
||
return wrap;
|
||
}
|
||
|
||
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 =
|
||
'<svg viewBox="0 0 300 300" width="20" height="36" focusable="false" aria-hidden="true">' +
|
||
'<path fill="currentColor" d="M224.9,192.1c0.1-8.7,0.1-27.8-1.5-46.8c0.1,8,0.3,15.3,0.6,19.1C224.2,166.6,224.5,178,224.9,192.1z"/>' +
|
||
'<path fill="currentColor" d="M152,28.1c0.3-6.1-0.1-12.7,5.1-12.3c2.5,0.2,6.8,0.8,12.1,1.9c5.9,1.2,5.1,33.1,5.1,33.1l21.3,8.3c0.7-23.1-5.1-49.9-10.8-51.6C175.1,4.7,161-1,146.9,2.1C137.5,4.1,135,38.7,135,38.7l16.5,4.3C151.4,43,151.6,37.9,152,28.1z"/>' +
|
||
'<path fill="currentColor" d="M52.7,144.8c3.5,1.3,9.2,3.2,15.9,5.3c5.1-14,13.6-24.7,13.6-24.7s2-5.2,11.9-3.1c9.9,2.1,10.4,6.6,10.4,6.6s-6.4,10.4-12.7,24.5c-0.4,0.9-0.9,1.9-1.3,3.3c15.8,4.6,31,8.7,35.4,9.1c8.6,0.8,30.3,1.7,34.4-19.1c4.1-20.6,8.1-33.1,11.5-40.9c0.7-1.7,1.3-3.1,1.8-4.2c0.9-2.1,3.8-6.3,7.9-10.5c0,0,0,0,0,0c0,0,0,0,0.1-0.1c5.5-5.5,13.3-10.8,22-10.8c0.5,0,1,0.1,1.6,0.2c-5.1-5.7-11-10.5-17.2-12.9c-1.4-0.5-52.7-20.8-68.1-18.7c-0.4,0.1-0.9,0.1-1.2,0.2c0.7-0.1,1.2-0.2,1.2-0.2s-35.2,0.9-58.4,24.1c-12.1,12.1-22.8,33.9-29.5,54.8C32,133,38.8,139.7,52.7,144.8z"/>' +
|
||
'<path fill="currentColor" d="M121.3,181.4c-6.9-1.3-19.7-4.8-33.4-8.8c-2.1,8.2-0.3,22.4-0.3,22.4s-2.7,6.4-13.3,4.1c-9-2-8.8-8.5-8.8-8.5s-2.3-13.2-0.3-24.9c-13-4.1-24.1-7.8-28.2-9.3c-4.9-1.9-8.4-4.5-11-7.4c-0.2,1.2-0.4,5.8-0.5,6.9c-9.2,65.8,9.9,94.1,11.3,100c1.8,8,36.8,24.9,70.7,37.2c19.6,7.2,41.5,5.8,50.2,5.1l0.6,0c1.4-25.4,5.2-91.6,7-132.1C162.4,181,137.9,184.4,121.3,181.4z"/>' +
|
||
'<path fill="currentColor" d="M224.9,192.1c-0.4-14-0.7-25.5-0.9-27.7c-0.3-3.8-0.5-11.1-0.6-19.1c-1.4-16.8-5.7-34.9-9.4-44.3c-6.2-15.8-24.4,4-29.5,16.5c-5.3,12.9-10.3,162-10.8,176.7c12.6-3.3,30.8-8,33-11.5c4.7-7.4,7.3-14.5,8.4-20.6c0.8-4,11.2-7.8,11.2-7.8s-0.7-32.1-1.4-58.1l-0.2,0.7C224.8,196.9,224.9,195.1,224.9,192.1z"/>' +
|
||
'<path fill="currentColor" d="M271.3,127.2c0,0-2.7-31.9-3.3-38.1c-1.5-15-15-63.2-61.1-26.3l12.2,13.3c0,0,31.2-28.1,32.4,38.8c0.1,5.7,0.5,13.6,3.9,61.2c3.8,52.6-16.8,52.9-16.8,52.9l0.9,22.4c0,0,7.6-2,15.5-5.3c11.2-4.7,21.9-24.4,21.9-53.3C276.9,181.4,271.3,127.2,271.3,127.2z"/>' +
|
||
'<path fill="currentColor" d="M109.9,37.9l10.2-1.6v-8.6l2-7.1c0,0-9.4,4.3-12.2,8.6c-2.7,4.3-3.9,6.7-3.9,8.6C106,39.9,109.9,37.9,109.9,37.9z"/>' +
|
||
"</svg>";
|
||
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) {
|
||
let tip = rank.label;
|
||
if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`;
|
||
if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位";
|
||
const medal = createRankMedalEl({ ...rank, label: tip }, "rank-medal match-rank-medal");
|
||
if (medal) 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 aliasBit = itemAliasHint(key, (state.data.items_meta || {})[key]);
|
||
const tipParts = [`${name}${aliasBit}`, 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 <font> 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";
|
||
const aliasBit = itemAliasHint(meta.key, meta).replace(/^ \/ /, "");
|
||
title.textContent = aliasBit
|
||
? `${meta.name_loc || meta.key}(${aliasBit})`
|
||
: 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 =
|
||
`<div class="hero-stat-bar-track" aria-hidden="true">` +
|
||
`<div class="hero-stat-bar-fill" style="width:${pct.toFixed(1)}%"></div>` +
|
||
`</div>` +
|
||
`<div class="hero-stat-bar-text">` +
|
||
`<span class="hero-stat-bar-label">${label}</span>` +
|
||
`<span class="hero-stat-bar-val">${value}` +
|
||
(regen ? `<small>${regen}</small>` : "") +
|
||
`</span>` +
|
||
`</div>`;
|
||
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 =
|
||
`<img class="attr-icon" src="${attrIconSrc(a.key)}" alt="${a.label}" />` +
|
||
`<span class="hero-stat-attr-base">${a.base}</span>` +
|
||
`<span class="hero-stat-attr-gain">${fmtGain(a.gain)}</span>`;
|
||
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;
|
||
for (const a of meta?.aliases || []) {
|
||
if (String(a).includes(q)) return true;
|
||
}
|
||
// Fall back to items_meta when shop row has no aliases yet.
|
||
const extra = (state.data.items_meta || {})[key];
|
||
for (const a of extra?.aliases || []) {
|
||
if (String(a).includes(q)) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function itemAliasHint(key, meta) {
|
||
const fromMeta = meta?.aliases;
|
||
const fromIndex = (state.data.items_meta || {})[key]?.aliases;
|
||
const aliases = (fromMeta?.length ? fromMeta : fromIndex) || [];
|
||
return aliases.length ? " / " + aliases.join("、") : "";
|
||
}
|
||
|
||
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}${itemAliasHint(key, meta)}${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(/<br\s*\/?>/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();
|
||
},
|
||
});
|
||
|
||
const patchPanel = buildItemPatchChangesPanel(meta.key);
|
||
if (patchPanel) root.appendChild(patchPanel);
|
||
}
|
||
|
||
// 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 `<img class="stat-icon" src="${attrIconSrc(a)}" alt="" onerror="this.style.visibility='hidden'">`;
|
||
const label = STAT_LABELS[icon] || icon;
|
||
return `<span class="stat-chip">${escapeHtml(label)}</span>`;
|
||
}
|
||
|
||
function patchNoteHtml(note) {
|
||
// Valve notes carry <br>, <font color=…>, <span class="New|Subtitle|…">.
|
||
// Keep line breaks; strip the rest so raw tags never leak into the UI.
|
||
const plain = stripItemDesc(note);
|
||
return escapeHtml(plain).replace(/\n/g, "<br/>");
|
||
}
|
||
|
||
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*<br\s*\/?\s*>$/i.test(raw)) {
|
||
return `<li class="patch-spacer" style="margin-left:${indent}em"></li>`;
|
||
}
|
||
return `<li class="hide-dot" style="margin-left:${indent}em">${patchNoteHtml(n.note)}</li>`;
|
||
}
|
||
return `<li style="margin-left:${indent}em">${patchNoteHtml(n.note)}</li>`;
|
||
}).join("");
|
||
return `<ul class="patch-notes">${items}</ul>`;
|
||
}
|
||
|
||
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 `<li class="hide-dot" style="margin-left:${indent}em">${chip}${patchNoteHtml(n.note)}</li>`;
|
||
}
|
||
return `<li style="margin-left:${indent}em">${chip}${patchNoteHtml(n.note)}</li>`;
|
||
}).join("");
|
||
return `<ul class="patch-notes patch-hero-notes">${items}</ul>`;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
/** Inline onerror for patch HTML icons: optional innate fallback, then remove (no empty box). */
|
||
function patchIconOnErrorAttr(tryInnate) {
|
||
if (tryInnate) {
|
||
const src = innateIconSrc().replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||
return (
|
||
`this.onerror=null;this.src='${src}';` +
|
||
"this.onerror=function(){this.onerror=null;this.remove()}"
|
||
);
|
||
}
|
||
return "this.onerror=null;this.remove()";
|
||
}
|
||
|
||
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
|
||
? `<img class="patch-ability-icon" src="${innate ? innateIconSrc() : abilityIconSrc(akey)}" alt="" onerror="${patchIconOnErrorAttr(!innate)}">`
|
||
: "";
|
||
return `<div class="patch-ability">${aicon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(aname)}</span>${renderNotesList(ab.ability_notes)}</div></div>`;
|
||
}
|
||
|
||
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
|
||
? `<img class="patch-item-icon" src="${itemIconSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
|
||
: "";
|
||
return `<div class="patch-entry-row">${icon}<div class="patch-entry-body"><span class="patch-name">${escapeHtml(name)}</span>${renderNotesList(entry.ability_notes)}</div></div>`;
|
||
}
|
||
|
||
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
|
||
? `<img class="patch-hero-icon" src="${portraitSrc(key)}" alt="" onerror="${patchIconOnErrorAttr(false)}">`
|
||
: "";
|
||
let html = `<div class="patch-hero"><div class="patch-hero-head">${portrait}<span class="patch-name">${escapeHtml(name)}</span></div>`;
|
||
html += renderHeroNotes(hero.hero_notes);
|
||
for (const ab of (hero.abilities || [])) {
|
||
html += patchAbilityBlockHtml(ab, lookup);
|
||
}
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
function renderPatchSection(root, title, html) {
|
||
if (!html) return;
|
||
const sec = document.createElement("section");
|
||
sec.className = "patch-section";
|
||
sec.innerHTML = `<h3 class="patch-section-title">${escapeHtml(stripItemDesc(title))}</h3>${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 = `<div class="patch-change-ver">${escapeHtml(p.version)}<span class="patch-change-date">${escapeHtml(patchDate)}</span></div>`;
|
||
html += renderHeroNotes(hentry.hero_notes);
|
||
for (const ab of (hentry.abilities || [])) {
|
||
html += patchAbilityBlockHtml(ab, lookup);
|
||
}
|
||
blocks.push(`<div class="patch-change-version">${html}</div>`);
|
||
}
|
||
if (!blocks.length) {
|
||
wrap.textContent = "近一年无版本改动";
|
||
return wrap;
|
||
}
|
||
wrap.innerHTML = blocks.join("");
|
||
return wrap;
|
||
}
|
||
|
||
/** Match shop/item key against a patch items or neutral_items entry via lookup. */
|
||
function patchItemEntryForKey(entries, itemKey, lookup) {
|
||
if (!itemKey || !entries?.length) return null;
|
||
const itemsLookup = (lookup && lookup.items) || {};
|
||
for (const entry of entries) {
|
||
if (!entry || entry.is_general_note || entry.ability_id === -1) continue;
|
||
const meta = itemsLookup[String(entry.ability_id)];
|
||
if (meta && meta.key === itemKey) return entry;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** Recent patch notes for one item (shop detail). Null when none in window. */
|
||
function buildItemPatchChangesPanel(itemKey) {
|
||
const patches = state.data.patches || [];
|
||
const details = state.data.patch_details || {};
|
||
const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
||
if (!itemKey || !patches.length) return null;
|
||
|
||
const blocks = [];
|
||
for (const p of patches) {
|
||
const det = details[p.version];
|
||
if (!det) continue;
|
||
const entry =
|
||
patchItemEntryForKey(det.items, itemKey, lookup) ||
|
||
patchItemEntryForKey(det.neutral_items, itemKey, lookup);
|
||
if (!entry) continue;
|
||
const notesHtml = renderNotesList(entry.ability_notes);
|
||
if (!notesHtml) continue;
|
||
const patchDate =
|
||
formatAbsoluteTime(p.date || p.timestamp, { dateOnly: true }) ||
|
||
p.date ||
|
||
"";
|
||
blocks.push(
|
||
`<div class="patch-change-version">` +
|
||
`<div class="patch-change-ver">${escapeHtml(p.version)}` +
|
||
`<span class="patch-change-date">${escapeHtml(patchDate)}</span></div>` +
|
||
notesHtml +
|
||
`</div>`
|
||
);
|
||
}
|
||
if (!blocks.length) return null;
|
||
|
||
const section = document.createElement("div");
|
||
section.className = "item-detail-patches";
|
||
const label = document.createElement("div");
|
||
label.className = "craft-label";
|
||
label.textContent = "改动";
|
||
section.appendChild(label);
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "patch-changes item-patch-changes";
|
||
wrap.innerHTML = blocks.join("");
|
||
section.appendChild(wrap);
|
||
return section;
|
||
}
|
||
|
||
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 patchSummaryFor(version) {
|
||
const pack = state.data?.patch_summaries || {};
|
||
const by = pack.by_version || {};
|
||
return version && by[version] ? by[version] : null;
|
||
}
|
||
|
||
function patchSummaryEntityLabel(kind, key) {
|
||
if (kind === "hero") {
|
||
const hero = heroByKey(key);
|
||
return (hero && (hero.name_loc || hero.key)) || key;
|
||
}
|
||
const meta = (state.data.items_meta || {})[key];
|
||
if (meta && meta.name_loc) return meta.name_loc;
|
||
const shop = ((state.data.item_shop || {}).items || {})[key];
|
||
if (shop && shop.name_loc) return shop.name_loc;
|
||
return key;
|
||
}
|
||
|
||
function renderPatchSummaryChip(kind, row) {
|
||
const key = row && row.key ? String(row.key) : "";
|
||
if (!key) return "";
|
||
const note = row.note ? String(row.note) : "";
|
||
const label = patchSummaryEntityLabel(kind, key);
|
||
const img =
|
||
kind === "hero"
|
||
? `<img class="patch-summary-icon patch-summary-icon--hero" src="${portraitSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`
|
||
: `<img class="patch-summary-icon patch-summary-icon--item" src="${itemIconSrc(key)}" alt="" onerror="this.style.visibility='hidden'">`;
|
||
const clickable = kind === "hero" && heroByKey(key);
|
||
const tag = clickable ? "button" : "div";
|
||
const attrs = clickable
|
||
? ` type="button" class="patch-summary-chip is-clickable" data-hero="${escapeHtml(key)}"`
|
||
: ` class="patch-summary-chip"`;
|
||
return `<${tag}${attrs}>${img}<span class="patch-summary-chip-body"><span class="patch-summary-chip-name">${escapeHtml(label)}</span>${note ? `<span class="patch-summary-chip-note">${escapeHtml(note)}</span>` : ""}</span></${tag}>`;
|
||
}
|
||
|
||
function renderPatchSummarySection(title, html) {
|
||
if (!html) return "";
|
||
return `<section class="patch-summary-section"><h4 class="patch-summary-section-title">${escapeHtml(title)}</h4>${html}</section>`;
|
||
}
|
||
|
||
function renderPatchAside(version) {
|
||
const aside = $("#patches-aside");
|
||
if (!aside) return;
|
||
const summary = patchSummaryFor(version);
|
||
if (!summary) {
|
||
aside.hidden = true;
|
||
aside.innerHTML = "";
|
||
return;
|
||
}
|
||
|
||
const themes = (summary.themes || [])
|
||
.filter((t) => typeof t === "string" && t.trim())
|
||
.map((t) => `<li>${escapeHtml(t)}</li>`)
|
||
.join("");
|
||
const buffs = (summary.buffs || []).map((r) => renderPatchSummaryChip("hero", r)).join("");
|
||
const nerfs = (summary.nerfs || []).map((r) => renderPatchSummaryChip("hero", r)).join("");
|
||
const items = (summary.items || []).map((r) => renderPatchSummaryChip("item", r)).join("");
|
||
const takeaways = (summary.takeaways || [])
|
||
.filter((t) => typeof t === "string" && t.trim())
|
||
.map((t) => `<li>${escapeHtml(t)}</li>`)
|
||
.join("");
|
||
|
||
let body = "";
|
||
if (summary.headline) {
|
||
body += `<p class="patch-summary-headline">${escapeHtml(String(summary.headline))}</p>`;
|
||
}
|
||
if (themes) {
|
||
body += renderPatchSummarySection("本版方向", `<ul class="patch-summary-list">${themes}</ul>`);
|
||
}
|
||
if (buffs) {
|
||
body += renderPatchSummarySection("明显加强", `<div class="patch-summary-chips">${buffs}</div>`);
|
||
}
|
||
if (nerfs) {
|
||
body += renderPatchSummarySection("明显削弱", `<div class="patch-summary-chips">${nerfs}</div>`);
|
||
}
|
||
if (items) {
|
||
body += renderPatchSummarySection("装备要点", `<div class="patch-summary-chips">${items}</div>`);
|
||
}
|
||
if (takeaways) {
|
||
body += renderPatchSummarySection("选将提示", `<ul class="patch-summary-list">${takeaways}</ul>`);
|
||
}
|
||
|
||
aside.hidden = false;
|
||
aside.innerHTML = `
|
||
<div class="patch-summary ui-panel">
|
||
<div class="patch-summary-head">
|
||
<h3 class="patch-summary-title">AI 解读</h3>
|
||
</div>
|
||
${body}
|
||
</div>`;
|
||
|
||
aside.querySelectorAll("button.patch-summary-chip[data-hero]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const key = btn.dataset.hero;
|
||
if (!key || !heroByKey(key)) return;
|
||
state.page = "heroes";
|
||
state.selectedKey = key;
|
||
state.detailTab = "patches";
|
||
state.inspect = null;
|
||
syncStateToUrl();
|
||
render();
|
||
});
|
||
});
|
||
}
|
||
|
||
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 = '<div class="patches-empty">暂无版本数据</div>';
|
||
renderPatchAside(null);
|
||
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 `<option value="${escapeHtml(p.version)}"${sel}>${escapeHtml(p.version)}${major} — ${escapeHtml(patchDate)}</option>`;
|
||
})
|
||
.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) || "";
|
||
|
||
renderPatchAside(state.selectedPatch);
|
||
|
||
const lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
|
||
const details = state.data.patch_details || {};
|
||
const det = details[state.selectedPatch];
|
||
if (!det) {
|
||
root.innerHTML = '<div class="patches-empty">该版本详情暂未收录</div>';
|
||
return;
|
||
}
|
||
|
||
root.innerHTML = "";
|
||
|
||
const generalHtml = (det.general_notes || [])
|
||
.map((g) => {
|
||
const t = g.title ? `<h4 class="patch-sub-title">${escapeHtml(g.title)}</h4>` : "";
|
||
return `<div class="patch-general">${t}${renderNotesList(g.generic)}</div>`;
|
||
})
|
||
.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 ? `<h4 class="patch-sub-title">${escapeHtml(e.title)}</h4>` : "";
|
||
}
|
||
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, "<").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 =
|
||
'<div class="rankings-empty">暂无排行数据</div>';
|
||
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 `<button type="button" class="rankings-region-btn${active}" data-region="${escapeHtml(key)}" role="tab" aria-selected="${key === state.rankingRegion}">${escapeHtml(label)}</button>`;
|
||
})
|
||
.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 = '<div class="rankings-empty">该地区暂无榜单数据</div>';
|
||
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 `<tr><td class="rankings-rank">${escapeHtml(rank)}</td><td class="rankings-name">${name}</td><td class="rankings-team">${team}</td><td class="rankings-country">${country}</td></tr>`;
|
||
})
|
||
.join("");
|
||
|
||
body.innerHTML = `
|
||
<table class="rankings-table">
|
||
<thead><tr><th class="rankings-rank">排名</th><th>选手</th><th class="rankings-team">战队</th><th class="rankings-country">国籍</th></tr></thead>
|
||
<tbody>${trs}</tbody>
|
||
</table>`;
|
||
}
|
||
|
||
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 `<button type="button" class="rankings-region-btn${active}" data-origin="${escapeHtml(
|
||
o.id
|
||
)}" role="tab" aria-selected="${origin === o.id}">${escapeHtml(
|
||
o.label
|
||
)}</button>`;
|
||
}).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 = [
|
||
`<button type="button" class="rankings-region-btn${
|
||
!state.matchesPlayerId ? " active" : ""
|
||
}" data-player="" role="tab" aria-selected="${!state.matchesPlayerId}">全部</button>`,
|
||
];
|
||
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(
|
||
`<button type="button" class="rankings-region-btn${active}" data-player="${escapeHtml(
|
||
p.account_id
|
||
)}" role="tab" aria-selected="${
|
||
state.matchesPlayerId === p.account_id
|
||
}">${label}</button>`
|
||
);
|
||
}
|
||
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 =
|
||
'<div class="rankings-empty">暂无比赛数据(请运行 python web/fetch_pro_matches.py)</div>';
|
||
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
|
||
? '<div class="rankings-empty">该选手暂无符合筛选的近期比赛</div>'
|
||
: '<div class="rankings-empty">暂无符合筛选的近期比赛</div>';
|
||
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<HTMLVideoElement, number>} mid-band visibility ratio */
|
||
const streamerVideoVisibility = new Map();
|
||
/** @type {Set<HTMLVideoElement>} 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 '<span class="trends-spark-empty">—</span>';
|
||
}
|
||
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 '<span class="trends-spark-empty">—</span>';
|
||
}
|
||
const line = pts.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" ");
|
||
const dots = pts
|
||
.map(
|
||
(p) =>
|
||
`<rect class="trends-spark-dot" x="${(p.x - 1.5).toFixed(1)}" y="${(p.y - 1.5).toFixed(1)}" width="3" height="3" rx="0.4" />`
|
||
)
|
||
.join("");
|
||
return `<svg class="trends-spark ${escapeHtml(colorClass || "")}" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline class="trends-spark-line" fill="none" points="${line}" />${dots}</svg>`;
|
||
}
|
||
|
||
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 =
|
||
'<div class="rankings-empty">暂无走势数据</div>';
|
||
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 `<button type="button" class="trends-sort-btn${active}" data-sort="${m.id}" role="tab" aria-selected="${state.trendsSort === m.id}">${m.label}</button>`;
|
||
})
|
||
.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 = '<div class="rankings-empty">该段位暂无走势数据</div>';
|
||
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 `<tr class="trends-row" data-hero="${escapeHtml(row.key)}">
|
||
<td class="rankings-rank">${i + 1}</td>
|
||
<td class="meta-hero"><img class="meta-portrait" src="${portrait}" alt="" loading="lazy" decoding="async" /><span>${name}</span></td>
|
||
<td class="trends-num">${formatFracAsPct(row.wrStart)}</td>
|
||
<td class="trends-spark-cell">${buildMiniSparkline(row.wrSeries, "is-wr")}</td>
|
||
<td class="trends-num trends-end">${formatFracAsPct(row.wrEnd)}</td>
|
||
<td class="trends-delta ${wrDelta.cls}">${wrDelta.text}</td>
|
||
<td class="trends-num">${formatFracAsPct(row.prStart)}</td>
|
||
<td class="trends-spark-cell">${buildMiniSparkline(row.prSeries, "is-pr")}</td>
|
||
<td class="trends-num trends-end">${formatFracAsPct(row.prEnd)}</td>
|
||
<td class="trends-delta ${prDelta.cls}">${prDelta.text}</td>
|
||
</tr>`;
|
||
})
|
||
.join("");
|
||
|
||
body.innerHTML = `
|
||
<table class="rankings-table trends-table">
|
||
<thead>
|
||
<tr>
|
||
<th rowspan="2" class="rankings-rank">#</th>
|
||
<th rowspan="2">英雄</th>
|
||
<th colspan="4" class="trends-group trends-group-wr ${sortCls("wr_end")}" data-sort="wr_end" title="按末期胜率排序">胜率${sortArrow("wr_end")}</th>
|
||
<th colspan="4" class="trends-group trends-group-pr ${sortCls("pr_end")}" data-sort="pr_end" title="按末期上场率排序">上场率${sortArrow("pr_end")}</th>
|
||
</tr>
|
||
<tr>
|
||
<th>初期</th>
|
||
<th>走势</th>
|
||
<th class="${sortCls("wr_end")}" data-sort="wr_end" title="按末期胜率排序">末期${sortArrow("wr_end")}</th>
|
||
<th>变化</th>
|
||
<th>初期</th>
|
||
<th>走势</th>
|
||
<th class="${sortCls("pr_end")}" data-sort="pr_end" title="按末期上场率排序">末期${sortArrow("pr_end")}</th>
|
||
<th>变化</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>${trs}</tbody>
|
||
</table>`;
|
||
|
||
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 =
|
||
"技能 <span class=\"mechanics-count\">" + abilities.length + "</span>";
|
||
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 = function () {
|
||
this.onerror = null;
|
||
this.remove();
|
||
};
|
||
this.src = innateIconSrc();
|
||
};
|
||
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 =
|
||
"物品 <span class=\"mechanics-count\">" + items.length + "</span>";
|
||
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 fmtDuration(sec) {
|
||
const s = Math.max(0, Number(sec) || 0);
|
||
const m = Math.floor(s / 60);
|
||
const r = s % 60;
|
||
return `${m}:${String(r).padStart(2, "0")}`;
|
||
}
|
||
|
||
function pctLabel(v) {
|
||
if (v == null || !Number.isFinite(Number(v))) return "—";
|
||
return `${Math.round(Number(v) * 100)}%`;
|
||
}
|
||
|
||
function fmtNetWorth(n) {
|
||
const v = Number(n);
|
||
if (!Number.isFinite(v) || v < 0) return "—";
|
||
return Math.round(v).toLocaleString("zh-CN");
|
||
}
|
||
|
||
function appendPlayerItemIcons(row, itemIds) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "player-match-items";
|
||
const ids = Array.isArray(itemIds) ? itemIds.slice(0, 6) : [];
|
||
// Always 6 slots so the items column width (and metrics alignment) stays stable.
|
||
while (ids.length < 6) ids.push(0);
|
||
ids.forEach((id) => {
|
||
const meta = id ? itemMetaFromId(id) : null;
|
||
const cell = document.createElement("span");
|
||
cell.className = "player-match-item";
|
||
if (meta && meta.key) {
|
||
const img = document.createElement("img");
|
||
img.alt = meta.name_loc || meta.key;
|
||
img.title = meta.name_loc || meta.key;
|
||
setItemIcon(img, meta.key);
|
||
cell.appendChild(img);
|
||
} else {
|
||
cell.classList.add("empty");
|
||
cell.title = id ? String(id) : "";
|
||
}
|
||
wrap.appendChild(cell);
|
||
});
|
||
row.appendChild(wrap);
|
||
}
|
||
|
||
function buildPlayerScoreboardTeam(detail, isRadiant) {
|
||
const side = document.createElement("section");
|
||
const team = isRadiant ? detail.radiant || {} : detail.dire || {};
|
||
const won = Boolean(detail.radiant_win) === isRadiant;
|
||
side.className = `player-team ${isRadiant ? "radiant" : "dire"} ${
|
||
won ? "won" : "lost"
|
||
}`;
|
||
const head = document.createElement("header");
|
||
head.className = "player-team-head";
|
||
head.innerHTML = `
|
||
<span class="player-team-name">${isRadiant ? "天辉" : "夜魇"}${won ? " · 胜利" : " · 失败"}</span>
|
||
<span class="player-team-stats">击杀 ${team.kills ?? "—"} · 经济 ${Number(team.net_worth || 0).toLocaleString("zh-CN")}</span>
|
||
`;
|
||
side.appendChild(head);
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "player-team-rows";
|
||
const players = (detail.players || []).filter((p) => !!p.is_radiant === isRadiant);
|
||
for (const p of players) {
|
||
const row = document.createElement("div");
|
||
row.className = "player-match-row";
|
||
if (p.is_mvp) row.classList.add("is-mvp");
|
||
if (
|
||
state.playerAccountId &&
|
||
String(p.account_id) === String(state.playerAccountId)
|
||
) {
|
||
row.classList.add("is-focus");
|
||
}
|
||
|
||
const heroKey = p.hero_key || (heroById(p.hero_id) || {}).key;
|
||
const heroName =
|
||
p.hero_name_loc ||
|
||
(heroById(p.hero_id) || {}).name_loc ||
|
||
heroKey ||
|
||
"—";
|
||
const left = document.createElement("div");
|
||
left.className = "player-match-hero";
|
||
if (heroKey) {
|
||
const img = document.createElement("img");
|
||
img.className = "player-match-portrait";
|
||
img.src = portraitSrc(heroKey);
|
||
img.alt = heroName;
|
||
left.appendChild(img);
|
||
}
|
||
const meta = document.createElement("div");
|
||
meta.className = "player-match-meta";
|
||
const nameLine = document.createElement("div");
|
||
nameLine.className = "player-match-name";
|
||
// Prefer Steam persona; never fall back to hero name (hero is in the sub line).
|
||
const displayName = p.personaname
|
||
? String(p.personaname)
|
||
: p.account_id
|
||
? `玩家 ${p.account_id}`
|
||
: "匿名";
|
||
if (p.account_id) {
|
||
const link = document.createElement("a");
|
||
link.className = "player-match-name-link";
|
||
link.href = `/players/${p.account_id}`;
|
||
link.textContent = displayName;
|
||
link.title = p.personaname
|
||
? `${p.personaname} · ${p.account_id}`
|
||
: `查看玩家 ${p.account_id}`;
|
||
link.addEventListener("click", (ev) => {
|
||
ev.preventDefault();
|
||
openPlayerPage(p.account_id);
|
||
});
|
||
nameLine.appendChild(link);
|
||
} else {
|
||
const span = document.createElement("span");
|
||
span.className = "player-match-name-anon";
|
||
span.textContent = displayName;
|
||
span.title = "未公开 Steam 昵称";
|
||
nameLine.appendChild(span);
|
||
}
|
||
meta.appendChild(nameLine);
|
||
const sub = document.createElement("div");
|
||
sub.className = "player-match-sub";
|
||
const subText = document.createElement("span");
|
||
subText.textContent = `Lv.${p.level ?? "—"} · ${heroName}`;
|
||
sub.appendChild(subText);
|
||
const badges = document.createElement("span");
|
||
badges.className = "player-match-badges";
|
||
if (p.party_label) {
|
||
const party = document.createElement("span");
|
||
party.className = `player-party-badge party-${String(p.party_label).toLowerCase()}`;
|
||
party.textContent = `组${p.party_label}`;
|
||
party.title = "开黑组队(同 party_id)";
|
||
badges.appendChild(party);
|
||
}
|
||
if (p.is_mvp) {
|
||
const badge = document.createElement("span");
|
||
badge.className = "player-mvp-badge";
|
||
badge.textContent = "MVP";
|
||
badges.appendChild(badge);
|
||
}
|
||
if (badges.childElementCount) sub.appendChild(badges);
|
||
meta.appendChild(sub);
|
||
left.appendChild(meta);
|
||
row.appendChild(left);
|
||
|
||
const metrics = document.createElement("div");
|
||
metrics.className = "player-match-metrics";
|
||
const kdaText = `${p.kills ?? 0}/${p.deaths ?? 0}/${p.assists ?? 0}`;
|
||
const ratio = p.kda != null && p.kda !== "" ? `(${p.kda})` : "";
|
||
const metricBits = [
|
||
["参战率", "参战", pctLabel(p.participation)],
|
||
["伤害占比", "伤害", pctLabel(p.damage_share)],
|
||
["个人经济(净身价)", "经济", fmtNetWorth(p.net_worth)],
|
||
["击杀/死亡/助攻;(K+A)/D", "KDA", `${kdaText}${ratio}`],
|
||
];
|
||
for (const [title, label, value] of metricBits) {
|
||
const span = document.createElement("span");
|
||
span.title = title;
|
||
const em = document.createElement("em");
|
||
em.textContent = label;
|
||
span.appendChild(em);
|
||
span.appendChild(document.createTextNode(String(value)));
|
||
metrics.appendChild(span);
|
||
}
|
||
row.appendChild(metrics);
|
||
appendPlayerItemIcons(row, p.items);
|
||
list.appendChild(row);
|
||
}
|
||
side.appendChild(list);
|
||
return side;
|
||
}
|
||
|
||
function renderPlayerMatchDetail(root, detail) {
|
||
root.replaceChildren();
|
||
const shell = document.createElement("div");
|
||
shell.className = "players-match-shell";
|
||
|
||
const back = document.createElement("button");
|
||
back.type = "button";
|
||
back.className = "players-back";
|
||
back.setAttribute("aria-label", "返回玩家主页");
|
||
back.innerHTML = `
|
||
<svg class="players-back-icon" viewBox="0 0 20 20" width="18" height="18" aria-hidden="true" focusable="false">
|
||
<path fill="currentColor" d="M12.7 4.3a1 1 0 0 1 0 1.4L8.4 10l4.3 4.3a1 1 0 1 1-1.4 1.4l-5-5a1 1 0 0 1 0-1.4l5-5a1 1 0 0 1 1.4 0z"/>
|
||
</svg>
|
||
<span>返回玩家主页</span>
|
||
`;
|
||
back.addEventListener("click", () => {
|
||
state.playerMatchId = null;
|
||
state._playerMatch = null;
|
||
state._playerLoadKey = null;
|
||
syncStateToUrl();
|
||
render();
|
||
});
|
||
shell.appendChild(back);
|
||
|
||
const head = document.createElement("header");
|
||
head.className = "players-match-head";
|
||
const when = formatFriendlyTime(detail.start_time);
|
||
const winner = detail.radiant_win ? "天辉胜" : "夜魇胜";
|
||
const summary = document.createElement("div");
|
||
summary.className = "players-match-summary";
|
||
const h2 = document.createElement("h2");
|
||
h2.className = "page-title";
|
||
h2.textContent = `比赛 ${detail.match_id}`;
|
||
summary.appendChild(h2);
|
||
const sub = document.createElement("p");
|
||
sub.className = "page-sub";
|
||
if (when.title) sub.title = when.title;
|
||
sub.innerHTML = `
|
||
<span>${when.text ? escapeHtml(when.text) : "—"}</span>
|
||
<span>时长 ${escapeHtml(fmtDuration(detail.duration))}</span>
|
||
<span class="players-match-winner">${winner}</span>
|
||
`;
|
||
summary.appendChild(sub);
|
||
head.appendChild(summary);
|
||
shell.appendChild(head);
|
||
|
||
const board = document.createElement("div");
|
||
board.className = "player-scoreboard";
|
||
const columns = document.createElement("div");
|
||
columns.className = "player-scoreboard-columns";
|
||
columns.innerHTML = `
|
||
<span>玩家 / 英雄</span>
|
||
<span class="player-scoreboard-metric-labels">
|
||
<span>参战</span><span>伤害</span><span>经济</span><span>KDA</span>
|
||
</span>
|
||
<span class="player-scoreboard-items-label">装备</span>
|
||
`;
|
||
board.appendChild(columns);
|
||
board.appendChild(buildPlayerScoreboardTeam(detail, true));
|
||
board.appendChild(buildPlayerScoreboardTeam(detail, false));
|
||
shell.appendChild(board);
|
||
root.appendChild(shell);
|
||
}
|
||
|
||
function renderPlayerProfile(root, profile) {
|
||
root.replaceChildren();
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "players-home";
|
||
|
||
// --- Identity strip ---
|
||
const head = document.createElement("header");
|
||
head.className = "players-identity";
|
||
const authName =
|
||
state.page === "home" && state.auth && state.auth.personaname
|
||
? String(state.auth.personaname).trim()
|
||
: "";
|
||
const authAvatar =
|
||
state.page === "home" && state.auth && state.auth.avatar
|
||
? String(state.auth.avatar)
|
||
: "";
|
||
const name =
|
||
(profile.personaname && String(profile.personaname).trim()) ||
|
||
authName ||
|
||
`玩家 ${profile.account_id}`;
|
||
const avatarUrl = profile.avatar || authAvatar;
|
||
if (avatarUrl) {
|
||
const av = document.createElement("img");
|
||
av.className = "players-identity-avatar";
|
||
av.src = avatarUrl;
|
||
av.alt = name;
|
||
av.width = 56;
|
||
av.height = 56;
|
||
head.appendChild(av);
|
||
}
|
||
const idBody = document.createElement("div");
|
||
idBody.className = "players-identity-body";
|
||
const titleRow = document.createElement("div");
|
||
titleRow.className = "players-profile-title";
|
||
const h2 = document.createElement("h2");
|
||
h2.className = "page-title";
|
||
h2.textContent = name;
|
||
titleRow.appendChild(h2);
|
||
const rank = parseRankTier(profile.rank_tier);
|
||
if (rank && rank.iconFile) {
|
||
let tip = rank.label;
|
||
if (profile.leaderboard_rank) tip += ` · 榜 #${profile.leaderboard_rank}`;
|
||
const medal = createRankMedalEl(
|
||
{ ...rank, label: tip },
|
||
"rank-medal players-rank-medal"
|
||
);
|
||
if (medal) titleRow.appendChild(medal);
|
||
if (rank.medalKey === "immortal" && profile.leaderboard_rank) {
|
||
const board = document.createElement("span");
|
||
board.className = "players-rank-board";
|
||
board.textContent = `#${profile.leaderboard_rank}`;
|
||
board.title = tip;
|
||
titleRow.appendChild(board);
|
||
}
|
||
}
|
||
idBody.appendChild(titleRow);
|
||
const avail = profile.availability || {};
|
||
const sub = document.createElement("p");
|
||
sub.className = "page-sub";
|
||
const bits = [`ID ${profile.account_id}`];
|
||
if (profile.public_share) bits.push("已公开主页");
|
||
if (avail.note) bits.push(avail.note);
|
||
else if (avail.status === "syncing") bits.push("Steam 已公开,OpenDota 同步中");
|
||
if (profile.stale || (avail && avail.stale)) bits.push("刷新中");
|
||
sub.textContent = bits.join(" · ");
|
||
idBody.appendChild(sub);
|
||
head.appendChild(idBody);
|
||
wrap.appendChild(head);
|
||
|
||
// --- Snapshot: career + recent_20 side by side ---
|
||
const career = profile.career;
|
||
const r20 = profile.recent_20;
|
||
const hasCareer = career && career.games > 0;
|
||
const hasR20 = r20 && r20.sample > 0;
|
||
if (hasCareer || hasR20) {
|
||
const snap = document.createElement("section");
|
||
snap.className = "players-snapshot";
|
||
if (hasCareer) {
|
||
const col = document.createElement("div");
|
||
col.className = "players-snapshot-col";
|
||
col.innerHTML = `<h3 class="players-section-title">生涯</h3>`;
|
||
appendStatCards(col, [
|
||
{ label: "场次", value: career.games },
|
||
{
|
||
label: "胜率",
|
||
value: career.winrate != null ? `${career.winrate}%` : null,
|
||
},
|
||
{ label: "KDA", value: career.kda },
|
||
{ label: "场均GPM", value: career.avg_gpm },
|
||
{ label: "场均XPM", value: career.avg_xpm },
|
||
]);
|
||
snap.appendChild(col);
|
||
}
|
||
if (hasR20) {
|
||
const col = document.createElement("div");
|
||
col.className = "players-snapshot-col";
|
||
col.innerHTML = `<h3 class="players-section-title">近 ${r20.sample} 场</h3>`;
|
||
appendStatCards(col, [
|
||
{ label: "胜负", value: `${r20.wins}-${r20.losses}` },
|
||
{
|
||
label: "胜率",
|
||
value: r20.winrate != null ? `${r20.winrate}%` : null,
|
||
},
|
||
{ label: "KDA", value: r20.kda },
|
||
{ label: "场均GPM", value: r20.avg_gpm },
|
||
{ label: "场均XPM", value: r20.avg_xpm },
|
||
]);
|
||
snap.appendChild(col);
|
||
}
|
||
wrap.appendChild(snap);
|
||
}
|
||
|
||
// --- Analysis: top heroes | activity / peers ---
|
||
const topHeroes = Array.isArray(profile.top_heroes) ? profile.top_heroes : [];
|
||
const activity = profile.activity_180;
|
||
const peers = Array.isArray(profile.peers) ? profile.peers : [];
|
||
const hasActivity = activity && activity.sample > 0;
|
||
if (topHeroes.length || hasActivity || peers.length) {
|
||
const analysis = document.createElement("section");
|
||
analysis.className = "players-analysis";
|
||
|
||
if (topHeroes.length) {
|
||
const sec = document.createElement("div");
|
||
sec.className = "players-analysis-block";
|
||
sec.innerHTML = `<h3 class="players-section-title">常用英雄</h3>`;
|
||
const list = document.createElement("div");
|
||
list.className = "players-top-heroes";
|
||
for (const h of topHeroes.slice(0, 5)) {
|
||
const key = h.hero_key || (heroById(h.hero_id) || {}).key;
|
||
const row = document.createElement("div");
|
||
row.className = "players-top-hero";
|
||
if (key) {
|
||
const img = document.createElement("img");
|
||
img.src = portraitSrc(key);
|
||
img.alt = h.hero_name_loc || key;
|
||
row.appendChild(img);
|
||
}
|
||
const meta = document.createElement("div");
|
||
meta.innerHTML = `<strong>${escapeHtml(
|
||
h.hero_name_loc || key || "—"
|
||
)}</strong><span>${h.games} 场 · 胜率 ${
|
||
h.winrate != null ? h.winrate + "%" : "—"
|
||
}</span>`;
|
||
row.appendChild(meta);
|
||
list.appendChild(row);
|
||
}
|
||
sec.appendChild(list);
|
||
analysis.appendChild(sec);
|
||
}
|
||
|
||
const side = document.createElement("div");
|
||
side.className = "players-analysis-side";
|
||
if (hasActivity) {
|
||
const sec = document.createElement("div");
|
||
sec.className = "players-analysis-block";
|
||
sec.innerHTML = `<h3 class="players-section-title">${escapeHtml(
|
||
activity.label || "最近 180 天"
|
||
)}</h3>`;
|
||
appendStatCards(sec, [
|
||
{ label: "样本场次", value: activity.sample },
|
||
{
|
||
label: "胜率",
|
||
value: activity.winrate != null ? `${activity.winrate}%` : null,
|
||
},
|
||
]);
|
||
if (activity.highs) {
|
||
const highs = document.createElement("div");
|
||
highs.className = "players-highs";
|
||
const hb = [];
|
||
if (activity.highs.kills) hb.push(`最高击杀 ${activity.highs.kills.value}`);
|
||
if (activity.highs.assists) {
|
||
hb.push(`最高助攻 ${activity.highs.assists.value}`);
|
||
}
|
||
if (activity.highs.gpm) hb.push(`最高GPM ${activity.highs.gpm.value}`);
|
||
highs.textContent = hb.join(" · ");
|
||
if (hb.length) sec.appendChild(highs);
|
||
}
|
||
if (Array.isArray(activity.heatmap) && activity.heatmap.length) {
|
||
const heatWrap = document.createElement("div");
|
||
heatWrap.className = "players-heatmap-wrap";
|
||
const heat = document.createElement("div");
|
||
heat.className = "players-heatmap";
|
||
heat.title = "每天场次(最近 180 天)";
|
||
heat.setAttribute("aria-label", "最近 180 天每天比赛场次");
|
||
const maxG = Math.max(
|
||
1,
|
||
...activity.heatmap.map((d) => Number(d.games) || 0)
|
||
);
|
||
for (const d of activity.heatmap) {
|
||
const cell = document.createElement("span");
|
||
const g = Number(d.games) || 0;
|
||
const level = g <= 0 ? 0 : Math.min(4, Math.ceil((g / maxG) * 4));
|
||
cell.className = `players-heat-cell lv${level}`;
|
||
cell.title = `${d.date}: ${g} 场`;
|
||
heat.appendChild(cell);
|
||
}
|
||
heatWrap.appendChild(heat);
|
||
const legend = document.createElement("div");
|
||
legend.className = "players-heatmap-legend";
|
||
legend.innerHTML = `
|
||
<span>少</span>
|
||
<span class="players-heat-cell lv0"></span>
|
||
<span class="players-heat-cell lv1"></span>
|
||
<span class="players-heat-cell lv2"></span>
|
||
<span class="players-heat-cell lv3"></span>
|
||
<span class="players-heat-cell lv4"></span>
|
||
<span>多</span>
|
||
`;
|
||
heatWrap.appendChild(legend);
|
||
sec.appendChild(heatWrap);
|
||
}
|
||
side.appendChild(sec);
|
||
}
|
||
if (peers.length) {
|
||
const sec = document.createElement("div");
|
||
sec.className = "players-analysis-block";
|
||
sec.innerHTML = `<h3 class="players-section-title">队友</h3>`;
|
||
const list = document.createElement("div");
|
||
list.className = "players-peers";
|
||
for (const p of peers.slice(0, 6)) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "players-peer";
|
||
if (p.avatar) {
|
||
const img = document.createElement("img");
|
||
img.src = p.avatar;
|
||
img.alt = "";
|
||
row.appendChild(img);
|
||
}
|
||
const meta = document.createElement("div");
|
||
meta.innerHTML = `<strong title="${escapeHtml(
|
||
p.personaname || "玩家"
|
||
)}">${escapeHtml(
|
||
p.personaname || "玩家"
|
||
)}</strong><span>${p.games} 场 · 胜率 ${
|
||
p.winrate != null ? p.winrate + "%" : "—"
|
||
}</span>`;
|
||
row.appendChild(meta);
|
||
if (p.account_id) {
|
||
row.addEventListener("click", () => openPlayerPage(p.account_id));
|
||
}
|
||
list.appendChild(row);
|
||
}
|
||
sec.appendChild(list);
|
||
side.appendChild(sec);
|
||
}
|
||
if (side.childElementCount) analysis.appendChild(side);
|
||
wrap.appendChild(analysis);
|
||
}
|
||
|
||
const status = document.createElement("div");
|
||
status.className = "players-enrich-status rankings-empty";
|
||
status.hidden = true;
|
||
wrap.appendChild(status);
|
||
|
||
// --- Recent matches (primary list) ---
|
||
const listSec = document.createElement("section");
|
||
listSec.className = "players-section players-recent-section";
|
||
listSec.innerHTML = `<h3 class="players-section-title">近期比赛</h3>`;
|
||
const listHost = document.createElement("div");
|
||
listHost.className = "players-recent-host";
|
||
listSec.appendChild(listHost);
|
||
wrap.appendChild(listSec);
|
||
root.appendChild(wrap);
|
||
|
||
const paintRecent = (recentRows) => {
|
||
listHost.replaceChildren();
|
||
const recent = Array.isArray(recentRows) ? recentRows : [];
|
||
if (!recent.length) {
|
||
const empty = document.createElement("div");
|
||
empty.className = "rankings-empty";
|
||
empty.textContent =
|
||
(profile.availability && profile.availability.note) ||
|
||
"暂无近期比赛。请在 Dota 2 设置中开启「公开比赛数据」;隐私局仍需本机 GSI 录像。";
|
||
listHost.appendChild(empty);
|
||
return;
|
||
}
|
||
const list = document.createElement("div");
|
||
list.className = "players-recent";
|
||
for (const row of recent) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = `players-recent-row ${row.won ? "won" : "lost"}`;
|
||
const heroKey = row.hero_key || (heroById(row.hero_id) || {}).key;
|
||
const heroName =
|
||
row.hero_name_loc ||
|
||
(heroById(row.hero_id) || {}).name_loc ||
|
||
heroKey ||
|
||
"—";
|
||
if (heroKey) {
|
||
const img = document.createElement("img");
|
||
img.className = "players-recent-portrait";
|
||
img.src = portraitSrc(heroKey);
|
||
img.alt = heroName;
|
||
btn.appendChild(img);
|
||
}
|
||
const body = document.createElement("div");
|
||
body.className = "players-recent-body";
|
||
const when = formatFriendlyTime(row.start_time);
|
||
const kdaText = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}${
|
||
row.kda != null ? `(${row.kda})` : ""
|
||
}`;
|
||
const tipBits = [kdaText];
|
||
const mode = lobbyLabel(row);
|
||
if (mode) tipBits.push(mode);
|
||
if (row.gpm != null) tipBits.push(`GPM ${row.gpm}`);
|
||
if (row.xpm != null) tipBits.push(`XPM ${row.xpm}`);
|
||
const kdaTip = tipBits.join(" · ");
|
||
// hero/KDA (grows left) | duration/when | result/id (tight right cluster)
|
||
body.innerHTML = `
|
||
<div class="players-recent-hero-col">
|
||
<span class="players-recent-hero" title="${escapeHtml(heroName).replace(
|
||
/"/g,
|
||
"""
|
||
)}">${escapeHtml(heroName)}</span>
|
||
<span class="players-recent-kda" title="${escapeHtml(kdaTip).replace(
|
||
/"/g,
|
||
"""
|
||
)}">${escapeHtml(kdaText)}</span>
|
||
</div>
|
||
<div class="players-recent-meta">
|
||
<span class="players-recent-dur">${fmtDuration(row.duration)}</span>
|
||
<span class="players-recent-when"${
|
||
when.title
|
||
? ` title="${escapeHtml(when.title).replace(/"/g, """)}"`
|
||
: ""
|
||
}>${when.text ? escapeHtml(when.text) : "—"}</span>
|
||
</div>
|
||
<div class="players-recent-end">
|
||
<span class="players-recent-wl ${row.won ? "won" : "lost"}">${
|
||
row.won ? "胜利" : "失败"
|
||
}</span>
|
||
<span class="players-recent-id">#${row.match_id}</span>
|
||
</div>
|
||
`;
|
||
btn.appendChild(body);
|
||
btn.addEventListener("click", async () => {
|
||
status.hidden = false;
|
||
status.textContent = "加载比赛详情…";
|
||
const aid = String(profile.account_id);
|
||
const { match, error } = await ensurePlayerMatch(aid, row.match_id);
|
||
if (
|
||
String(state.playerAccountId) !== aid ||
|
||
(state.page !== "players" && state.page !== "home")
|
||
) {
|
||
return;
|
||
}
|
||
if (!match) {
|
||
status.textContent = error
|
||
? `无法加载:${error}`
|
||
: "无法加载该场(OpenDota 暂无或隐私)";
|
||
return;
|
||
}
|
||
status.hidden = true;
|
||
state.playerMatchId = String(row.match_id);
|
||
state._playerMatch = match;
|
||
state._playerLoadKey = `${aid}:${row.match_id}`;
|
||
syncStateToUrl();
|
||
render();
|
||
});
|
||
list.appendChild(btn);
|
||
}
|
||
listHost.appendChild(list);
|
||
};
|
||
|
||
paintRecent(profile.recent);
|
||
|
||
const enrichKey = `enrich:${profile.account_id}`;
|
||
if (state._playerEnrichKey !== enrichKey) {
|
||
state._playerEnrichKey = enrichKey;
|
||
const aid = profile.account_id;
|
||
// Has stats: keep first paint. Soft TTL refresh is silent (Queue / local bg).
|
||
if (!playerProfileNeedsRefresh(profile)) {
|
||
status.hidden = true;
|
||
if (playerProfileIsSoftStale(profile) && state.page === "home") {
|
||
void (async () => {
|
||
const updated = await fetchMyPlayerProfile();
|
||
if (!updated || !playerProfileHasStats(updated)) return;
|
||
if (String(state.playerAccountId) !== String(aid)) return;
|
||
if (state.playerMatchId) return;
|
||
if (
|
||
updated.enriched_at === profile.enriched_at &&
|
||
(updated.recent || []).length === (profile.recent || []).length
|
||
) {
|
||
return;
|
||
}
|
||
state._playerProfile = updated;
|
||
renderPlayerProfile(root, updated);
|
||
})();
|
||
}
|
||
return;
|
||
}
|
||
// Empty shell only: poll Worker/local fill, then one local enrich.
|
||
status.hidden = false;
|
||
status.textContent = "正在同步战绩…";
|
||
(async () => {
|
||
let updated = null;
|
||
if (state.page === "home") {
|
||
updated = await pollMyPlayerProfile({ accountId: aid });
|
||
}
|
||
if (!updated || playerProfileNeedsRefresh(updated)) {
|
||
updated = await enrichPlayerProfile(aid, {
|
||
includeGsi: true,
|
||
force: false,
|
||
});
|
||
}
|
||
if (String(state.playerAccountId) !== String(aid)) return;
|
||
if (state.playerMatchId) return;
|
||
if (updated && playerProfileHasStats(updated)) {
|
||
state._playerProfile = updated;
|
||
// Keep loadKey — never flash「加载中…」for a background sync.
|
||
renderPlayerProfile(root, updated);
|
||
return;
|
||
}
|
||
status.hidden = true;
|
||
})();
|
||
}
|
||
}
|
||
|
||
function renderPlayersPage() {
|
||
const root = $("#players-body");
|
||
if (!root) return;
|
||
|
||
if (state.page === "home") {
|
||
const aid = authAccountId();
|
||
if (!aid) {
|
||
renderHomeGate(root);
|
||
return;
|
||
}
|
||
if (String(state.playerAccountId || "") !== aid) {
|
||
state.playerAccountId = aid;
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
state._playerEnrichKey = null;
|
||
}
|
||
}
|
||
|
||
const accountId = state.playerAccountId;
|
||
if (!accountId) {
|
||
root.innerHTML =
|
||
'<div class="rankings-empty">打开 /players/{account_id} 查看 PC 赛后生成的玩家主页。<br/>默认仅本机可见;在 pc/config.json 将 player_pages.public_share 设为 true 后可同步到站点。</div>';
|
||
return;
|
||
}
|
||
|
||
const loadKey = `${accountId}:${state.playerMatchId || ""}`;
|
||
if (state._playerLoadKey === loadKey) {
|
||
if (state.playerMatchId) {
|
||
if (state._playerMatch) renderPlayerMatchDetail(root, state._playerMatch);
|
||
else
|
||
root.innerHTML =
|
||
'<div class="rankings-empty">未找到该场比赛(未公开或尚未同步)。</div>';
|
||
} else if (state._playerProfile) {
|
||
renderPlayerProfile(root, state._playerProfile);
|
||
} else {
|
||
root.innerHTML =
|
||
'<div class="rankings-empty">未找到玩家主页(未公开或尚未同步)。</div>';
|
||
}
|
||
return;
|
||
}
|
||
|
||
root.innerHTML = '<div class="rankings-empty">加载中…</div>';
|
||
const requested = loadKey;
|
||
(async () => {
|
||
let profile = null;
|
||
let match = null;
|
||
try {
|
||
if (state.playerMatchId) {
|
||
const ensured = await ensurePlayerMatch(accountId, state.playerMatchId);
|
||
match = ensured.match;
|
||
if (!match) {
|
||
match = await fetchPlayerJson(accountId, state.playerMatchId);
|
||
}
|
||
} else if (state.page === "home") {
|
||
// Leave「加载中」as soon as /me returns (even an empty syncing shell).
|
||
// Hard enrich runs after first paint inside renderPlayerProfile.
|
||
profile = await fetchMyPlayerProfile();
|
||
} else {
|
||
profile = await fetchPlayerJson(accountId);
|
||
if (!profile) {
|
||
// Cold miss on /players/{id}: sync once (TTL on later visits).
|
||
profile = await enrichPlayerProfile(accountId, {
|
||
includeGsi: true,
|
||
force: false,
|
||
});
|
||
if (profile && profile.error) profile = null;
|
||
}
|
||
}
|
||
} catch (_) {
|
||
/* empty */
|
||
}
|
||
if (`${state.playerAccountId}:${state.playerMatchId || ""}` !== requested) {
|
||
return;
|
||
}
|
||
state._playerLoadKey = requested;
|
||
state._playerProfile = profile;
|
||
state._playerMatch = match;
|
||
renderPlayersPage();
|
||
})();
|
||
}
|
||
|
||
function setPage(page) {
|
||
if (
|
||
page !== "home" &&
|
||
page !== "heroes" &&
|
||
page !== "rankings" &&
|
||
page !== "matches" &&
|
||
page !== "streamers" &&
|
||
page !== "trends" &&
|
||
page !== "mechanics" &&
|
||
page !== "items" &&
|
||
page !== "patches" &&
|
||
page !== "players"
|
||
)
|
||
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 === "home") {
|
||
state.selectedKey = null;
|
||
state.selectedItemKey = null;
|
||
state.inspect = null;
|
||
const aid = authAccountId();
|
||
if (String(state.playerAccountId || "") !== String(aid || "")) {
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
state._playerEnrichKey = null;
|
||
}
|
||
state.playerAccountId = aid;
|
||
state.playerMatchId = null;
|
||
} else if (page === "players") {
|
||
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() {
|
||
syncAuthChrome();
|
||
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 playersView = $("#players-view");
|
||
const streamersView = $("#streamers-view");
|
||
const trendsView = $("#trends-view");
|
||
const mechanicsView = $("#mechanics-view");
|
||
const itemsView = $("#items-view");
|
||
const patchesView = $("#patches-view");
|
||
const showPlayers = state.page === "players" || state.page === "home";
|
||
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 (playersView) playersView.classList.toggle("hidden", !showPlayers);
|
||
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 === "players" || state.page === "home") {
|
||
renderPlayersPage();
|
||
} 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 &&
|
||
[
|
||
"home",
|
||
"heroes",
|
||
"rankings",
|
||
"matches",
|
||
"streamers",
|
||
"trends",
|
||
"mechanics",
|
||
"items",
|
||
"patches",
|
||
"players",
|
||
].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;
|
||
}
|
||
// PC post-match player pages + logged-in /home.
|
||
if (state.page === "players" || state.page === "home") {
|
||
let nextAccount = null;
|
||
if (state.page === "home") {
|
||
nextAccount = authAccountId();
|
||
} else if (
|
||
patch.playerAccountId &&
|
||
/^\d+$/.test(String(patch.playerAccountId))
|
||
) {
|
||
nextAccount = String(patch.playerAccountId);
|
||
}
|
||
const nextMatch =
|
||
patch.playerMatchId && /^\d+$/.test(String(patch.playerMatchId))
|
||
? String(patch.playerMatchId)
|
||
: null;
|
||
if (
|
||
nextAccount !== state.playerAccountId ||
|
||
nextMatch !== state.playerMatchId
|
||
) {
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
if (nextAccount !== state.playerAccountId) {
|
||
state._playerEnrichKey = null;
|
||
}
|
||
}
|
||
state.playerAccountId = nextAccount;
|
||
state.playerMatchId = nextMatch;
|
||
} else {
|
||
state.playerAccountId = null;
|
||
state.playerMatchId = null;
|
||
state._playerLoadKey = null;
|
||
state._playerProfile = null;
|
||
state._playerMatch = null;
|
||
state._playerEnrichKey = null;
|
||
}
|
||
// 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();
|
||
bindAuthChrome();
|
||
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();
|
||
await fetchAuthMe();
|
||
syncAuthChrome();
|
||
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.patch_summaries = state.data.patch_summaries || { by_version: {} };
|
||
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;
|
||
});
|