Files
climperor/web/frontend/app.js
T
vosonandCursor b01552ee6e v0.5.84: matches origin filter, mobile gate, refresh reliability.
Ship Web refresh cache/lock, mobile demand gate, matches 职业/国服 filter, and related site updates through 0.5.84.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 18:31:55 +08:00

5712 lines
186 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* global fetch, document, ABILITY_VIDEO_BASE, STATIC_ASSET_BASE, SITE_VERSION */
function trimBase(raw) {
return typeof raw === "string" ? raw.trim().replace(/\/+$/, "") : "";
}
/** OSS base for ability demos; empty => same-origin `/ability-video/...`. */
function abilityVideoBase() {
return trimBase(ABILITY_VIDEO_BASE);
}
function abilityVideoUrl(heroKey, abilityKey) {
const rel = `/ability-video/${heroKey}/${abilityKey}.webm`;
const base = abilityVideoBase();
return base ? `${base}${rel}` : rel;
}
/** OSS base for icons/portraits; empty => same-origin relative paths. */
function staticAssetBase() {
return trimBase(STATIC_ASSET_BASE);
}
function assetUrl(rel) {
const clean = String(rel || "").replace(/^\//, "");
const base = staticAssetBase();
return base ? `${base}/${clean}` : `/${clean}`;
}
function attrIconSrc(key) {
return assetUrl(`attr/${key}.png`);
}
function portraitSrc(key) {
return assetUrl(`portrait/${encodeURIComponent(key)}.png?v=wide`);
}
function abilityIconSrc(abilityKey) {
return assetUrl(`ability/${encodeURIComponent(abilityKey)}.png`);
}
function innateIconSrc() {
return assetUrl("ability/innate.png");
}
function talentTreeIconSrc() {
return assetUrl("ability/talent_tree.png");
}
function itemCatIconSrc(icon) {
return assetUrl(`item-cat/${encodeURIComponent(icon)}`);
}
/** Rank medal / filter icon under /rank/<file>. */
function rankIconSrc(file) {
const name = String(file || "rank_icon_0.png").replace(/^.*[\\/]/, "");
return assetUrl(`rank/${name}`);
}
function siteVersionLabel() {
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
return v ? `v${v}` : "";
}
function renderPatchesSiteVersion() {
const el = $("#patches-site-version");
if (!el) return;
const label = siteVersionLabel();
el.textContent = label;
el.setAttribute("aria-hidden", label ? "false" : "true");
}
const state = {
data: null,
page: "heroes", // heroes | rankings | matches | streamers | trends | mechanics | items | patches
selectedKey: null,
selectedItemKey: null,
/** Hero-page inspect pane: { type:'skill', id } | { type:'item', key } | null */
inspect: null,
detailTab: "skills", // skills | core | fears | trends | matchups | matches | streamers | patches
/** OpenDota / STRATZ bracket key for hero-detail trends: herald…immortal */
statsBracket: "legend",
tagFilters: new Set(),
query: "",
itemQuery: "",
/** Currently selected patch version on the 版本 page; null -> latest. */
selectedPatch: null,
/** Immortal leaderboard region: china | europe | americas | se_asia */
rankingRegion: "china",
/** Top-level matches page: OpenDota account_id string (null = all stars) */
matchesPlayerId: null,
/** Top-level matches page: all | pro | china */
matchesOrigin: "all",
/** Top-level matches page: 1-based page index */
matchesPage: 1,
/** Top-level 走势 page medal bracket */
trendsBracket: "legend",
/** Sort key for trends board: wr_end | pr_end */
trendsSort: "wr_end",
/** Sort direction for trends board */
trendsSortDir: "desc",
/** Mechanics page: applies-effect key (basic_dispel, stun, …) */
mechanicEffect: "basic_dispel",
};
const $ = (sel) => document.querySelector(sel);
function counterEdge(a, b) {
return (state.data.relations.counters || []).find((e) => e.a === a && e.b === b) || null;
}
function synergyEdge(a, b) {
const [x, y] = [a, b].slice().sort();
return (state.data.relations.synergies || []).find((e) => e.a === x && e.b === y) || null;
}
/** All relation kinds from selected S to peer P. */
function relationsOf(peerKey) {
const s = state.selectedKey;
if (!s || !peerKey || s === peerKey) return null;
const out = {
counters: counterEdge(s, peerKey),
countered: counterEdge(peerKey, s),
synergy: synergyEdge(s, peerKey),
};
if (!out.counters && !out.countered && !out.synergy) return null;
return out;
}
function matchesTags(h) {
if (!state.tagFilters.size) return true;
const tags = new Set(h.tags || []);
for (const t of state.tagFilters) {
if (!tags.has(t)) return false;
}
return true;
}
function matchesQuery(h) {
const q = state.query.trim();
if (!q) return true;
const qLower = q.toLowerCase();
if ((h.name_loc || "").includes(q)) return true;
if ((h.name || "").toLowerCase().includes(qLower)) return true;
if ((h.key || "").toLowerCase().includes(qLower)) return true;
for (const a of h.aliases || []) {
if (String(a).includes(q) || String(a).toLowerCase().includes(qLower)) return true;
}
for (const a of h.abbr || []) {
if (String(a).toLowerCase().includes(qLower)) return true;
}
return false;
}
function matchesFilters(h) {
return matchesTags(h) && matchesQuery(h);
}
function renderBoard() {
if (!state.data) return;
const root = $("#columns");
root.innerHTML = "";
root.className = "columns";
const { by_attr, attr_order, attr_cols, attr_labels } = state.data;
for (const attr of attr_order) {
const col = document.createElement("section");
col.className = "col";
col.innerHTML = `<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);
}
}
function onHeroClick(key) {
state.selectedKey = state.selectedKey === key ? null : key;
state.inspect = null;
syncStateToUrl();
render();
}
function renderTagbar() {
const bar = $("#tagbar");
if (!bar) return;
const order = state.data.tag_order || [];
bar.innerHTML = "";
const allBtn = document.createElement("button");
allBtn.type = "button";
allBtn.textContent = "全部";
allBtn.className = state.tagFilters.size ? "" : "active";
allBtn.addEventListener("click", () => {
state.tagFilters.clear();
syncStateToUrl({ replace: true });
render();
});
bar.appendChild(allBtn);
for (const tag of order) {
const btn = document.createElement("button");
btn.type = "button";
btn.textContent = tag;
if (state.tagFilters.has(tag)) btn.classList.add("active");
btn.addEventListener("click", () => {
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
else state.tagFilters.add(tag);
syncStateToUrl({ replace: true });
render();
});
bar.appendChild(btn);
}
}
function heroByKey(key) {
return (state.data.heroes || []).find((h) => h.key === key) || null;
}
function itemMeta(id) {
const items = state.data.hero_items?.items || {};
return items[String(id)] || null;
}
/** Resolve item id → { key, name_loc, … } via match catalog, hero_items, shop. */
let _itemIdIndex = null;
function itemMetaFromId(id) {
if (id == null) return null;
const sid = String(id);
const fromMatches = (state.data.hero_matches?.items || {})[sid];
const fromProMatches = (state.data.pro_matches?.items || {})[sid];
const fromCore = itemMeta(id);
if (!_itemIdIndex) {
_itemIdIndex = new Map();
const shop = state.data.item_shop?.items || {};
for (const [key, row] of Object.entries(shop)) {
if (!row || row.id == null) continue;
_itemIdIndex.set(String(row.id), {
key,
dname: row.dname || key,
name_loc: row.name_loc || row.dname || key,
});
}
}
const fromShop = _itemIdIndex.get(sid) || null;
const base = fromShop || fromCore || fromProMatches || fromMatches;
if (!base) return null;
const key = base.key || fromMatches?.key || fromCore?.key;
return {
key,
dname: base.dname || fromMatches?.dname || key,
name_loc:
fromShop?.name_loc ||
fromCore?.name_loc ||
fromMatches?.name_loc ||
fromMatches?.dname ||
key,
};
}
function abilityLabelForKey(heroKey, abilityKey) {
if (!abilityKey) return "";
const pack = skillEntriesFor(heroKey);
if (pack?.entries) {
const hit = pack.entries.find((e) => e.ability_key === abilityKey);
if (hit?.name_loc) return hit.name_loc;
}
const cell = (state.data.hero_abilities?.by_hero || {})[heroKey];
const abilities = cell?.abilities;
if (Array.isArray(abilities)) {
for (const ab of abilities) {
if (ab?.key === abilityKey) return ab.name_loc || abilityKey;
}
}
const talents = cell?.talents;
if (Array.isArray(talents)) {
for (const row of talents) {
if (row?.key === abilityKey) return row.name_loc || abilityKey;
}
}
return abilityKey.replace(/^special_bonus_/, "").replace(/_/g, " ");
}
function coreItemsFor(heroKey) {
const cell = (state.data.hero_items?.by_hero || {})[heroKey];
if (cell == null) return null;
return Array.isArray(cell) ? cell : [];
}
/** Relative usage % within the hero's Top-N core list (sum of counts = 100%). */
function coreItemUsagePcts(entries) {
if (!Array.isArray(entries) || !entries.length) return [];
const sum = entries.reduce((acc, e) => acc + (Number(e.count) || 0), 0);
if (sum <= 0) return entries.map(() => null);
return entries.map((e) => {
const c = Number(e.count) || 0;
return Math.round((c / sum) * 1000) / 10;
});
}
const BRACKET_LABELS = {
herald: "先锋",
guardian: "卫士",
crusader: "中军",
archon: "统帅",
legend: "传奇",
ancient: "万古",
divine: "超凡",
immortal: "冠绝",
};
/** OpenDota / client: Immortal sample is folded into Divine when too small. */
const IMMORTAL_MERGE_TIP =
"由于样本过小,冠绝一世玩家的相关数据已与超凡入圣玩家合并。";
/** Valve regional Immortal leaderboard; scores are not comparable across regions. */
const IMMORTAL_LEADERBOARD_TIP =
"官方 Immortal 地区榜 · 各区分数不可比";
const POSITION_LABELS = {
POSITION_1: "一号位",
POSITION_2: "二号位",
POSITION_3: "三号位",
POSITION_4: "四号位",
POSITION_5: "五号位",
};
/** Icon file under assets/rank_icons/ for each medal bracket. */
const BRACKET_RANK_ICON = {
herald: "rank_icon_1.png",
guardian: "rank_icon_2.png",
crusader: "rank_icon_3.png",
archon: "rank_icon_4.png",
legend: "rank_icon_5.png",
ancient: "rank_icon_6.png",
divine: "rank_icon_7.png",
immortal: "rank_icon_8.png",
};
/** Recent matches tab: newest N ladder+league games. */
const MATCHES_DISPLAY_LIMIT = 10;
const PRO_MATCHES_PAGE_SIZE = 20;
function heroStatsPack() {
return (
state.data?.hero_stats || {
brackets: [],
by_hero: {},
totals: {},
fetched_at: null,
window_days: 7,
window_label_zh: "近约 7 天公开对局",
}
);
}
/** Short Chinese label for the OpenDota recent-match window. */
function heroStatsWindowLabel() {
const pack = heroStatsPack();
if (pack.window_label_zh) return String(pack.window_label_zh);
const days = Number(pack.window_days) || 7;
return `近约 ${days} 天公开对局`;
}
function heroStatsFor(heroKey) {
const cell = (heroStatsPack().by_hero || {})[heroKey];
return cell && typeof cell === "object" ? cell : null;
}
function pickWinForBracket(cell, bracket) {
if (!cell) return null;
if (bracket === "pub") return cell.pub || null;
if (bracket === "pro") return cell.pro || null;
if (bracket === "turbo") return cell.turbo || null;
return (cell.brackets && cell.brackets[bracket]) || null;
}
/** Immortal is merged into Divine when the Immortal sample is empty/tiny. */
function pickWinForTrends(cell, bracket) {
if (bracket !== "immortal") return pickWinForBracket(cell, bracket);
const divine = pickWinForBracket(cell, "divine") || { pick: 0, win: 0 };
const immortal = pickWinForBracket(cell, "immortal") || { pick: 0, win: 0 };
return {
pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0),
win: (Number(divine.win) || 0) + (Number(immortal.win) || 0),
};
}
function totalsForBracket(bracket) {
ensureHeroStatsTotals();
const totals = heroStatsPack().totals || {};
if (bracket === "pub") return totals.pub || null;
if (bracket === "pro") return totals.pro || null;
if (bracket === "turbo") return totals.turbo || null;
return (totals.brackets && totals.brackets[bracket]) || null;
}
function totalsForTrends(bracket) {
if (bracket !== "immortal") return totalsForBracket(bracket);
const divine = totalsForBracket("divine") || { pick: 0 };
const immortal = totalsForBracket("immortal") || { pick: 0 };
return {
pick: (Number(divine.pick) || 0) + (Number(immortal.pick) || 0),
};
}
/** If payload lacks totals (old cache), derive pick sums from by_hero. */
function ensureHeroStatsTotals() {
const pack = heroStatsPack();
if (!pack || !pack.by_hero) return;
const totals = pack.totals;
if (totals && totals.pub && Number(totals.pub.pick) > 0) return;
const brackets = Array.isArray(pack.brackets) && pack.brackets.length
? pack.brackets
: Object.keys(BRACKET_RANK_ICON).filter((k) => k !== "pub");
const out = {
pub: { pick: 0 },
brackets: {},
pro: { pick: 0, ban: 0 },
turbo: { pick: 0 },
};
for (const name of brackets) out.brackets[name] = { pick: 0 };
for (const cell of Object.values(pack.by_hero)) {
if (!cell || typeof cell !== "object") continue;
out.pub.pick += Number(cell.pub?.pick) || 0;
out.pro.pick += Number(cell.pro?.pick) || 0;
out.pro.ban += Number(cell.pro?.ban) || 0;
out.turbo.pick += Number(cell.turbo?.pick) || 0;
for (const name of brackets) {
out.brackets[name].pick += Number(cell.brackets?.[name]?.pick) || 0;
}
}
pack.totals = out;
}
/** Matches ≈ sum of hero picks / 10 (each game contributes 10 picks). */
function approxMatches(totalPickSum) {
const s = Number(totalPickSum) || 0;
return s > 0 ? s / 10 : 0;
}
function winratePct(pw) {
if (!pw) return null;
const pick = Number(pw.pick) || 0;
const win = Number(pw.win) || 0;
if (pick <= 0) return null;
return Math.round((win / pick) * 1000) / 10;
}
/** Pick rate % of matches that included this hero in the sample window. */
function pickratePct(pw, bracket) {
if (!pw) return null;
const pick = Number(pw.pick) || 0;
if (pick <= 0) return null;
const matches = approxMatches(totalsForTrends(bracket)?.pick);
if (matches <= 0) return null;
return Math.round((pick / matches) * 1000) / 10;
}
/**
* Ban rate % — OpenDota heroStats only has pro_ban for the pro scene.
* Ranked / pub / turbo return null.
*/
function banratePct(pw, bracket) {
if (bracket !== "pro" || !pw) return null;
const ban = Number(pw.ban) || 0;
if (ban <= 0) return null;
const matches = approxMatches(totalsForBracket("pro")?.pick);
if (matches <= 0) return null;
return Math.round((ban / matches) * 1000) / 10;
}
function formatPickCount(n) {
const v = Number(n) || 0;
if (v >= 10000) return `${(v / 10000).toFixed(v >= 100000 ? 0 : 1)}万`;
return String(v);
}
function formatRatePct(v) {
if (v == null || Number.isNaN(v)) return "—";
return `${v}%`;
}
function formatCounterRate(v) {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return `${(n * 100).toFixed(1)}%`;
}
function fearedItemsFor(heroKey) {
const fears = state.data.hero_item_fears;
const byHero = fears && fears.by_hero;
// null → whole dataset missing; [] → this hero has no feared items
if (!byHero || !Object.keys(byHero).length) return null;
const cell = byHero[heroKey];
if (cell == null) return [];
return Array.isArray(cell) ? cell : [];
}
function heroAbilityCell(heroKey) {
const cell = (state.data.hero_abilities?.by_hero || {})[heroKey];
if (cell == null) return null;
// Legacy: by_hero[key] was a bare ability array
if (Array.isArray(cell)) return { abilities: cell, talents: [] };
return {
abilities: Array.isArray(cell.abilities) ? cell.abilities : [],
talents: Array.isArray(cell.talents) ? cell.talents : [],
};
}
/** Floating talent popover (body-level; avoids #detail overflow clipping). */
let talentPopoverEl = null;
let talentPopoverHideTimer = 0;
function closeTalentPopover() {
if (talentPopoverHideTimer) {
clearTimeout(talentPopoverHideTimer);
talentPopoverHideTimer = 0;
}
if (talentPopoverEl) {
talentPopoverEl.remove();
talentPopoverEl = null;
}
document.querySelectorAll(".skill-icon.talent-trigger[aria-expanded='true']").forEach((el) => {
el.setAttribute("aria-expanded", "false");
});
}
function positionTalentPopover(trigger) {
if (!talentPopoverEl || !trigger) return;
const gap = 10;
const margin = 8;
const r = trigger.getBoundingClientRect();
// Measure with provisional place off-screen if needed.
let pr = talentPopoverEl.getBoundingClientRect();
if (pr.width < 2) {
talentPopoverEl.style.left = "0px";
talentPopoverEl.style.top = "0px";
pr = talentPopoverEl.getBoundingClientRect();
}
let left = r.left + r.width / 2 - pr.width / 2;
let top = r.top - pr.height - gap;
left = Math.max(margin, Math.min(left, window.innerWidth - pr.width - margin));
if (top < margin) {
top = r.bottom + gap;
talentPopoverEl.classList.add("below");
} else {
talentPopoverEl.classList.remove("below");
}
talentPopoverEl.style.left = `${Math.round(left)}px`;
talentPopoverEl.style.top = `${Math.round(top)}px`;
// Keep the beak centered on the tree icon even when the panel is clamped.
const beak = talentPopoverEl.querySelector(".talent-tree-beak");
if (beak) {
const iconCx = r.left + r.width / 2;
const beakX = iconCx - left;
beak.style.left = `${Math.round(beakX)}px`;
}
}
function buildTalentTreeCard(talents) {
const tree = document.createElement("div");
tree.className = "talent-tree";
tree.setAttribute("aria-label", "天赋树");
const treeLab = document.createElement("div");
treeLab.className = "talent-tree-label";
treeLab.textContent = "天赋树";
tree.appendChild(treeLab);
const rows = document.createElement("div");
rows.className = "talent-rows";
for (const lv of [25, 20, 15, 10]) {
const row = document.createElement("div");
row.className = "talent-row";
const left = talents.find((t) => t.level === lv && t.side === "left");
const right = talents.find((t) => t.level === lv && t.side === "right");
const leftEl = document.createElement("div");
leftEl.className = "talent-cell";
leftEl.textContent = left?.name_loc || "—";
leftEl.title = left?.name_loc || "";
const mid = document.createElement("div");
mid.className = "talent-level";
const midNum = document.createElement("span");
midNum.className = "talent-level-num";
midNum.textContent = String(lv);
mid.appendChild(midNum);
const rightEl = document.createElement("div");
rightEl.className = "talent-cell";
rightEl.textContent = right?.name_loc || "—";
rightEl.title = right?.name_loc || "";
row.appendChild(leftEl);
row.appendChild(mid);
row.appendChild(rightEl);
rows.appendChild(row);
}
tree.appendChild(rows);
return tree;
}
function openTalentPopover(trigger, talents) {
if (talentPopoverHideTimer) {
clearTimeout(talentPopoverHideTimer);
talentPopoverHideTimer = 0;
}
if (talentPopoverEl) {
positionTalentPopover(trigger);
trigger.setAttribute("aria-expanded", "true");
return;
}
const pop = document.createElement("div");
pop.className = "talent-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", "天赋树");
pop.appendChild(buildTalentTreeCard(talents));
const beak = document.createElement("div");
beak.className = "talent-tree-beak";
beak.setAttribute("aria-hidden", "true");
pop.appendChild(beak);
pop.addEventListener("mouseenter", () => {
if (talentPopoverHideTimer) {
clearTimeout(talentPopoverHideTimer);
talentPopoverHideTimer = 0;
}
});
pop.addEventListener("mouseleave", () => {
talentPopoverHideTimer = setTimeout(closeTalentPopover, 120);
});
document.body.appendChild(pop);
talentPopoverEl = pop;
trigger.setAttribute("aria-expanded", "true");
positionTalentPopover(trigger);
}
function wireTalentTrigger(btn, talents) {
btn.addEventListener("mouseenter", () => openTalentPopover(btn, talents));
btn.addEventListener("mouseleave", () => {
talentPopoverHideTimer = setTimeout(closeTalentPopover, 120);
});
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (talentPopoverEl && btn.getAttribute("aria-expanded") === "true") {
closeTalentPopover();
} else {
openTalentPopover(btn, talents);
}
});
}
/**
* Ability icon loader.
* Innates (and shard/scepter chips of innate abilities) use the shared gold-droplet
* badge — many innate keys 404 on Steam CDN, matching dota2.com.
* Other abilities load `ability/{key}.png` with a quiet miss state on error.
*/
function setAbilityIcon(img, abilityKey, isInnate) {
if (isInnate) {
img.src = innateIconSrc();
img.onerror = null;
return;
}
img.src = abilityIconSrc(abilityKey);
img.onerror = () => {
img.onerror = null;
img.removeAttribute("src");
img.alt = "";
img.classList.add("missing");
};
}
/** Copy dota2.com detail-pane fields from a raw ability row onto an entry. */
const ABILITY_DETAIL_FIELDS = [
"target_label",
"affects_label",
"damage_label",
"immunity_label",
"cast_range",
"cast_point",
"channel_time",
"cooldown",
"mana_cost",
"lore_loc",
];
function withAbilityDetail(ent, ab) {
for (const f of ABILITY_DETAIL_FIELDS) ent[f] = ab[f] || "";
ent.specials = Array.isArray(ab.specials) ? ab.specials : [];
return ent;
}
/** Build icon-bar entries: base skills + shard/scepter upgrades (dota2.com style).
* Cached per hero key; invalidated when data reloads. */
const _skillEntriesCache = new Map();
function skillEntriesFor(heroKey) {
if (_skillEntriesCache.has(heroKey)) return _skillEntriesCache.get(heroKey);
const cell = heroAbilityCell(heroKey);
if (cell == null) return null;
const entries = [];
const base = [];
for (const ab of cell.abilities) {
if (ab.granted_by_shard || ab.granted_by_scepter) continue;
base.push(
withAbilityDetail(
{
id: `ability:${ab.key}`,
kind: "ability",
ability_key: ab.key,
name_loc: ab.name_loc || ab.key,
desc_loc: ab.desc_loc || "",
label: ab.is_innate ? "先天技能" : "",
dispellable: ab.dispellable || "none",
is_innate: !!ab.is_innate,
},
ab
)
);
}
// Official order among abilities: innate (circular) first, then Q/W/E/R.
// Talent-tree trigger is prepended separately in the icon row.
for (const ent of base) {
if (ent.is_innate) entries.push(ent);
}
for (const ent of base) {
if (!ent.is_innate) entries.push(ent);
}
for (const ab of cell.abilities) {
if (ab.granted_by_shard || (ab.shard_loc && ab.has_shard)) {
entries.push(
withAbilityDetail(
{
id: `shard:${ab.key}`,
kind: "shard",
ability_key: ab.key,
name_loc: ab.name_loc || ab.key,
desc_loc: ab.granted_by_shard
? ab.desc_loc || ab.shard_loc || ""
: ab.shard_loc || ab.desc_loc || "",
label: "魔晶技能升级",
dispellable: ab.dispellable || "none",
// Keep innate flag so Aghs chips of innates (e.g. ember_spirit_immolation)
// use the shared droplet icon + circular style; CDN has no per-key PNG.
is_innate: !!ab.is_innate,
},
ab
)
);
}
}
for (const ab of cell.abilities) {
if (ab.granted_by_scepter || (ab.scepter_loc && ab.has_scepter)) {
entries.push(
withAbilityDetail(
{
id: `scepter:${ab.key}`,
kind: "scepter",
ability_key: ab.key,
name_loc: ab.name_loc || ab.key,
desc_loc: ab.granted_by_scepter
? ab.desc_loc || ab.scepter_loc || ""
: ab.scepter_loc || ab.desc_loc || "",
label: "神杖技能升级",
dispellable: ab.dispellable || "none",
is_innate: !!ab.is_innate,
},
ab
)
);
}
}
const result = { entries, talents: cell.talents };
_skillEntriesCache.set(heroKey, result);
return result;
}
const DISPEL_LABEL = {
yes: "可驱散",
strong_only: "仅强驱散",
no: "不可驱散",
none: "",
};
function selectInspectSkill(id) {
state.inspect = id ? { type: "skill", id } : null;
renderDetail();
}
function selectInspectItem(key) {
if (!key) return;
state.inspect = { type: "item", key };
renderDetail();
}
function resolveItemDetail(key) {
if (!key) return null;
const shop = shopItem(key);
if (shop) return shop;
const metaExtra = (state.data.items_meta || {})[key] || null;
const items = state.data.hero_items?.items || {};
for (const row of Object.values(items)) {
if (row && row.key === key) {
return {
key,
name_loc: row.name_loc || row.dname || metaExtra?.name_loc || key,
dname: row.dname || key,
cost: row.cost ?? metaExtra?.cost,
components: metaExtra?.components || [],
builds_into: metaExtra?.builds_into || [],
desc_loc: metaExtra?.desc_loc || row.desc_loc || "",
};
}
}
if (metaExtra) {
return {
key,
name_loc: metaExtra.name_loc || key,
cost: metaExtra.cost,
components: metaExtra.components || [],
builds_into: metaExtra.builds_into || [],
desc_loc: metaExtra.desc_loc || "",
};
}
return { key, name_loc: key, components: [], builds_into: [] };
}
function appendItemIcon(list, { key, name, title, badge }) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "item";
btn.title = title || name || key || "";
btn.setAttribute("aria-label", title || name || key || "");
if (
key &&
state.inspect &&
state.inspect.type === "item" &&
state.inspect.key === key
) {
btn.classList.add("selected");
}
if (key) {
const img = document.createElement("img");
img.alt = name || key;
img.loading = "lazy";
setItemIcon(img, key);
btn.appendChild(img);
btn.addEventListener("click", () => selectInspectItem(key));
} else {
btn.textContent = name || "?";
btn.disabled = true;
}
if (badge != null && badge !== "") {
const mark = document.createElement("span");
mark.className = "item-usage-badge";
mark.textContent = typeof badge === "number" ? `${badge}%` : String(badge);
btn.appendChild(mark);
}
list.appendChild(btn);
return btn;
}
function buildSkillsPanel(heroKey) {
closeTalentPopover();
const absBlock = document.createElement("div");
absBlock.className = "detail-abilities";
absBlock.setAttribute("aria-label", "技能");
const skillPack = skillEntriesFor(heroKey);
if (skillPack == null) {
const miss = document.createElement("div");
miss.className = "detail-muted";
miss.textContent = "暂无技能数据";
absBlock.appendChild(miss);
return absBlock;
}
const { entries, talents } = skillPack;
const activeId =
state.inspect?.type === "skill"
? state.inspect.id
: null;
const icons = document.createElement("div");
icons.className = "skill-icons";
icons.setAttribute("role", "tablist");
icons.setAttribute("aria-label", "技能与升级");
if (talents.length) {
const talentBtn = document.createElement("button");
talentBtn.type = "button";
talentBtn.className = "skill-icon talent-trigger";
talentBtn.setAttribute("aria-label", "天赋树");
talentBtn.setAttribute("aria-expanded", "false");
talentBtn.setAttribute("aria-haspopup", "dialog");
talentBtn.title = "天赋树";
const talentImg = document.createElement("img");
talentImg.src = talentTreeIconSrc();
talentImg.alt = "天赋树";
talentImg.loading = "lazy";
talentBtn.appendChild(talentImg);
wireTalentTrigger(talentBtn, talents);
icons.appendChild(talentBtn);
}
for (const ent of entries) {
const btn = document.createElement("button");
btn.type = "button";
const isSel = !!(activeId && ent.id === activeId);
btn.className =
"skill-icon" +
(ent.kind !== "ability" ? ` kind-${ent.kind}` : "") +
(ent.is_innate ? " innate" : "") +
(isSel ? " active selected" : "");
btn.setAttribute("role", "tab");
btn.setAttribute("aria-selected", isSel ? "true" : "false");
btn.title = [ent.name_loc, ent.label].filter(Boolean).join(" · ");
const img = document.createElement("img");
img.alt = ent.name_loc;
img.loading = "lazy";
setAbilityIcon(img, ent.ability_key, !!ent.is_innate);
btn.appendChild(img);
if (ent.kind === "shard" || ent.kind === "scepter") {
const mark = document.createElement("span");
mark.className = "skill-aghs-mark";
mark.textContent = ent.kind === "shard" ? "晶" : "A";
btn.appendChild(mark);
}
btn.addEventListener("click", () => selectInspectSkill(ent.id));
icons.appendChild(btn);
}
absBlock.appendChild(icons);
return absBlock;
}
function buildCoreItemsPanel(heroKey) {
const block = document.createElement("div");
block.className = "detail-items detail-tab-panel";
block.setAttribute("aria-label", "核心装备");
const entries = coreItemsFor(heroKey);
if (entries == null) {
const miss = document.createElement("div");
miss.className = "detail-muted";
miss.textContent = "暂无装备数据";
block.appendChild(miss);
return block;
}
const list = document.createElement("div");
list.className = "item-list";
if (!entries.length) {
const empty = document.createElement("span");
empty.className = "detail-muted";
empty.textContent = "暂无";
list.appendChild(empty);
} else {
const pcts = coreItemUsagePcts(entries);
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const pct = pcts[i];
const meta = itemMeta(entry.id);
const key = meta?.key;
const name = meta?.name_loc || meta?.dname || (key ? key : `#${entry.id}`);
const pctLabel = pct != null ? `${pct}%` : null;
const tipParts = [name];
if (pctLabel) tipParts.push(`相对热度 ${pctLabel}`);
if (entry.count != null) tipParts.push(`count ${entry.count}`);
appendItemIcon(list, {
key,
name,
title: tipParts.join(" · "),
badge: pctLabel,
});
}
}
block.appendChild(list);
return block;
}
function matchRowDisplayOk(row) {
if (!row || typeof row !== "object") return false;
if (!row.won) return false;
if (row.origin === "public") {
const tier = Number(row.rank_tier ?? row.avg_rank_tier);
// Legend+ : OpenDota rank_tier >= 50
if (!Number.isFinite(tier) || tier < 50) return false;
}
return true;
}
function heroMatchesFor(heroKey) {
const cell = (state.data.hero_matches?.by_hero || {})[heroKey];
let rows = null;
if (cell != null) {
if (Array.isArray(cell)) rows = cell;
else if (Array.isArray(cell.matches)) rows = cell.matches;
}
const proCell = (state.data.pro_matches?.by_hero || {})[heroKey];
const proRows = Array.isArray(proCell?.matches) ? proCell.matches : [];
if (!rows && !proRows.length) return null;
const merged = [...(rows || []), ...proRows];
const seen = new Set();
const out = [];
for (const row of merged.sort(
(a, b) => (Number(b.start_time) || 0) - (Number(a.start_time) || 0)
)) {
const mid = Number(row?.match_id);
if (!mid || seen.has(mid)) continue;
if (!matchRowDisplayOk(row)) continue;
seen.add(mid);
out.push(row);
}
return out;
}
/** Pad 09 → "00"…"09" for HH:mm. */
function pad2(n) {
return String(n).padStart(2, "0");
}
/**
* Parse unix sec/ms, ISO string, or Date → epoch ms. Date-only YYYY-MM-DD is
* treated as Asia/Shanghai midnight (avoids UTC-day shift for CN dates).
*/
function parseTimeMs(input) {
if (input == null || input === "") return NaN;
if (input instanceof Date) {
const t = input.getTime();
return Number.isFinite(t) ? t : NaN;
}
if (typeof input === "number") {
if (!Number.isFinite(input) || input <= 0) return NaN;
return input > 1e12 ? input : input * 1000;
}
const s = String(input).trim();
if (!s) return NaN;
if (/^\d+(\.\d+)?$/.test(s)) {
const n = Number(s);
if (!Number.isFinite(n) || n <= 0) return NaN;
return n > 1e12 ? n : n * 1000;
}
const dayOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (dayOnly) {
const t = new Date(
`${dayOnly[1]}-${dayOnly[2]}-${dayOnly[3]}T00:00:00+08:00`
).getTime();
return Number.isFinite(t) ? t : NaN;
}
const t = Date.parse(s);
return Number.isFinite(t) ? t : NaN;
}
/** Calendar + clock parts in Asia/Shanghai (site audience). */
function shanghaiParts(ms) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).formatToParts(new Date(ms));
const get = (type) => {
const hit = parts.find((p) => p.type === type);
return hit ? hit.value : "";
};
let hour = get("hour");
// Some engines emit "24" for midnight under hour12:false.
if (hour === "24") hour = "00";
return {
year: Number(get("year")),
month: Number(get("month")),
day: Number(get("day")),
hour: Number(hour),
minute: Number(get("minute")),
};
}
/**
* Absolute wall time for CN UI.
* Same year: "6/13 19:00"; other year: "2024/6/13 19:00".
* dateOnly omits the clock.
*/
function formatAbsoluteTime(input, { dateOnly = false } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
const now = shanghaiParts(Date.now());
const date =
p.year === now.year
? `${p.month}/${p.day}`
: `${p.year}/${p.month}/${p.day}`;
if (dateOnly) return date;
return `${date} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/** Full absolute for tooltips: "2026/7/29 16:48". */
function formatAbsoluteTimeFull(input) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return "";
const p = shanghaiParts(ms);
return `${p.year}/${p.month}/${p.day} ${pad2(p.hour)}:${pad2(p.minute)}`;
}
/**
* Hybrid friendly time (CN feed convention):
* 刚刚 → N分钟前 → N小时前 → 昨天[ HH:mm] → N天前 → N周前 → M/D[ HH:mm].
* Returns { text, title } so callers can set hover absolute.
*/
function formatFriendlyTime(input, { dateOnly = false, now = Date.now() } = {}) {
const ms = parseTimeMs(input);
if (!Number.isFinite(ms)) return { text: "", title: "" };
const title = formatAbsoluteTimeFull(ms);
const abs = () => formatAbsoluteTime(ms, { dateOnly });
const diff = now - ms;
// Clock skew / future → absolute.
if (diff < -60_000) return { text: abs(), title };
const sec = Math.floor(Math.max(0, diff) / 1000);
if (sec < 45) return { text: "刚刚", title };
if (sec < 3600) {
return { text: `${Math.max(1, Math.floor(sec / 60))}分钟前`, title };
}
if (sec < 86400) {
return { text: `${Math.max(1, Math.floor(sec / 3600))}小时前`, title };
}
const p = shanghaiParts(ms);
const n = shanghaiParts(now);
const dayStart = (parts) => Date.UTC(parts.year, parts.month - 1, parts.day);
const dayDiff = Math.round((dayStart(n) - dayStart(p)) / 86400000);
if (dayDiff === 1) {
const text = dateOnly
? "昨天"
: `昨天 ${pad2(p.hour)}:${pad2(p.minute)}`;
return { text, title };
}
if (dayDiff >= 2 && dayDiff < 7) {
return { text: `${dayDiff}天前`, title };
}
if (dayDiff >= 7 && dayDiff < 45) {
return { text: `${Math.max(1, Math.floor(dayDiff / 7))}周前`, title };
}
return { text: abs(), title };
}
function formatMatchDuration(sec) {
const n = Number(sec) || 0;
if (n <= 0) return "—";
const m = Math.floor(n / 60);
const s = n % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
const RANK_TIER_MEDALS = [
null,
"herald",
"guardian",
"crusader",
"archon",
"legend",
"ancient",
"divine",
"immortal",
];
/** OpenDota rank_tier → { medalKey, stars, label, iconFile }. */
function parseRankTier(tier) {
const t = Number(tier);
if (!Number.isFinite(t) || t < 10) return null;
const medal = Math.min(8, Math.max(1, Math.floor(t / 10)));
const stars = Math.max(0, t % 10);
const medalKey = RANK_TIER_MEDALS[medal];
if (!medalKey) return null;
const base = BRACKET_LABELS[medalKey] || medalKey;
let label = base;
if (medalKey === "immortal") {
label = base;
} else if (stars > 0) {
label = `${base} ${stars}`;
}
return {
medalKey,
stars,
label,
iconFile: BRACKET_RANK_ICON[medalKey] || null,
};
}
function matchPlayerDisplayName(row) {
// Never show account id. Prefer display_name / pro name (jikroy) / Steam persona.
for (const key of ["display_name", "name", "personaname"]) {
const raw = row?.[key];
if (typeof raw === "string" && raw.trim()) return raw.trim();
}
return "匿名玩家";
}
function appendMatchItemIcon(list, id, timeSec) {
const meta = itemMetaFromId(id);
const key = meta?.key || null;
const name = meta?.name_loc || meta?.dname || (key ? key : `#${id}`);
if (!key) {
// Skip unknown ids — do not leave empty placeholder slots.
return;
}
const el = document.createElement("span");
el.className = "match-icon";
const timeLabel = formatMatchItemTime(timeSec);
el.title = timeLabel ? `${name} · ${timeLabel}` : name;
const img = document.createElement("img");
img.alt = name;
img.loading = "lazy";
setItemIcon(img, key, () => {
// Drop the slot entirely when neither local nor OSS has the icon.
el.remove();
});
el.appendChild(img);
if (timeLabel) {
const t = document.createElement("span");
t.className = "match-icon-time";
t.textContent = timeLabel;
el.appendChild(t);
}
list.appendChild(el);
}
/** OpenDota-style MM:SS (supports negative pre-game seconds). */
function formatMatchItemTime(sec) {
if (sec == null || sec === "") return null;
const n = Number(sec);
if (!Number.isFinite(n)) return null;
const neg = n < 0;
const abs = Math.abs(Math.trunc(n));
const m = Math.floor(abs / 60);
const s = abs % 60;
const body = `${m}:${String(s).padStart(2, "0")}`;
return neg ? `-${body}` : body;
}
function appendMatchBackpackGlyph(row) {
const glyph = document.createElement("span");
glyph.className = "match-backpack-glyph";
glyph.setAttribute("aria-hidden", "true");
glyph.title = "背包";
glyph.innerHTML =
'<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) {
const medal = document.createElement("img");
medal.className = "match-rank-icon";
medal.src = rankIconSrc(rank.iconFile);
let tip = rank.label;
if (row.leaderboard_rank) tip += ` #${row.leaderboard_rank}`;
if (!row.rank_tier && row.avg_rank_tier) tip += " · 局均段位";
medal.alt = tip;
medal.title = tip;
medal.loading = "lazy";
player.appendChild(medal);
}
const nameEl = document.createElement(row.account_id ? "a" : "span");
nameEl.className = "match-player-name";
nameEl.textContent = matchPlayerDisplayName(row);
if (row.account_id) {
nameEl.href = `https://www.dotabuff.com/players/${row.account_id}`;
nameEl.target = "_blank";
nameEl.rel = "noopener noreferrer";
nameEl.title = "在 Dotabuff 查看选手";
}
player.appendChild(nameEl);
if (!rank?.iconFile && row.leaderboard_rank) {
const rankLab = document.createElement("span");
rankLab.className = "match-rank-label";
rankLab.textContent = `#${row.leaderboard_rank}`;
player.appendChild(rankLab);
}
head.appendChild(player);
const kda = document.createElement("span");
kda.className = "match-kda";
kda.textContent = `${row.kills ?? 0}/${row.deaths ?? 0}/${row.assists ?? 0}`;
head.appendChild(kda);
const dur = document.createElement("span");
dur.className = "match-duration";
dur.textContent = formatMatchDuration(row.duration);
head.appendChild(dur);
const origin = document.createElement("span");
origin.className = "match-origin";
const kind = matchOriginKind(row);
if (kind === "china") {
origin.textContent = "国服";
origin.classList.add("match-origin-china");
} else if (kind === "ladder") {
origin.textContent = "天梯";
} else if (kind === "pro") {
origin.textContent = "职业";
origin.classList.add("match-origin-pro");
} else {
origin.textContent = "联赛";
}
head.appendChild(origin);
if (row.league_name) {
const league = document.createElement("span");
league.className = "match-league";
league.textContent = row.league_name;
league.title = row.league_name;
head.appendChild(league);
}
const whenInfo = formatFriendlyTime(row.start_time);
if (whenInfo.text) {
const timeEl = document.createElement("span");
timeEl.className = "match-time";
timeEl.textContent = whenInfo.text;
if (whenInfo.title) timeEl.title = whenInfo.title;
head.appendChild(timeEl);
}
const links = document.createElement("span");
links.className = "match-links";
if (row.match_id) {
const 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: "全局聚合(未按段位 / 分路 / 周过滤)",
},
cross_source: null,
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 matchupCrossLabel(cross) {
const status = cross?.status;
if (status === "agree") return "来源一致";
if (status === "conflict") return "来源分歧";
return null;
}
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);
const crossLab = matchupCrossLabel(row.cross);
meta.textContent = [
`胜率 ${wrText}`,
`${formatPickCount(row.games)} 场`,
crossLab,
]
.filter(Boolean)
.join(" · ");
mid.appendChild(name);
mid.appendChild(meta);
const score = document.createElement("span");
score.className = "matchup-score";
score.textContent = formatAdvantage(row[scoreKey]);
const crossStatus = row.cross?.status;
if (crossStatus === "agree") score.classList.add("is-agree");
if (crossStatus === "conflict") score.classList.add("is-conflict");
const odotaTip =
row.cross && row.cross.opendota_games != null
? ` · OpenDota 基线差 ${
row.cross.opendota_adv == null
? "—"
: formatAdvantage(row.cross.opendota_adv * 100)
}pp / ${formatPickCount(row.cross.opendota_games)} 场`
: "";
score.title = [
`STRATZ 相对优势 ${formatAdvantage(row[scoreKey])}(非胜率百分点)`,
`对局胜率 ${wrText}`,
`${formatPickCount(row.games)} 场`,
crossLab ? `交叉:${crossLab}` : "",
odotaTip,
]
.filter(Boolean)
.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 hintBits = [
"数字是相对表现(综合胜率与自身强弱),不是直接胜率;也不分段位、分路,和「走势」页不是同一套数据。",
];
const cross = pack.cross_source;
if (cross && cross.available === false) {
hintBits.push("暂无其他数据源可对照,交叉结论仅供参考。");
} else if (cross && cross.available) {
hintBits.push(
"「来源一致 / 分歧」表示是否与 OpenDota 公开统计方向相同,仅作对照。"
);
}
const hint = document.createElement("p");
hint.className = "matchup-note-line";
hint.textContent = hintBits.join(" ");
notes.appendChild(hint);
const stale = cell.stale === true;
if (stale) {
const line = document.createElement("p");
line.className = "matchup-note-line is-stale";
line.textContent = "本轮更新失败,当前仍是上一版数据,可能已过时。";
notes.appendChild(line);
}
const fetchedInfo = formatFriendlyTime(
cell.fetched_at || pack.fetched_at
);
if (fetchedInfo.text) {
const time = document.createElement("p");
time.className = "matchup-note-time";
time.textContent = `更新于 ${fetchedInfo.text}`;
if (fetchedInfo.title) time.title = fetchedInfo.title;
notes.appendChild(time);
}
block.appendChild(notes);
return block;
}
function buildFearItemsPanel(heroKey) {
const block = document.createElement("div");
block.className = "detail-items detail-tab-panel";
block.setAttribute("aria-label", "被克装备");
const fears = fearedItemsFor(heroKey);
const list = document.createElement("div");
list.className = "item-list";
if (fears == null) {
const miss = document.createElement("span");
miss.className = "detail-muted";
miss.textContent =
"暂无克制装备数据";
list.appendChild(miss);
} else if (!fears.length) {
const empty = document.createElement("span");
empty.className = "detail-muted";
empty.textContent = "暂无";
list.appendChild(empty);
} else {
for (const entry of fears) {
const key = entry.item;
const name = entry.name_loc || key;
const reason = entry.reason || "";
const stats = entry.stats || null;
const rawPurchaseRate = stats?.purchase_rate;
const hasPurchaseRate =
rawPurchaseRate != null &&
rawPurchaseRate !== "" &&
Number.isFinite(Number(rawPurchaseRate));
const purchaseRate = hasPurchaseRate
? formatCounterRate(stats.purchase_rate)
: null;
const tipParts = [name, reason].filter(Boolean);
if (hasPurchaseRate) {
tipParts.push(`对阵购买率 ${purchaseRate}`);
if (stats.games) tipParts.push(`样本 ${stats.games} 场`);
}
const item = document.createElement("div");
item.className = "fear-item";
appendItemIcon(item, {
key,
name,
title: tipParts.join(" · "),
badge: purchaseRate,
});
list.appendChild(item);
}
}
block.appendChild(list);
return block;
}
function mountSkillInspect(root, skillEnt, heroKey) {
root.classList.remove("empty");
// Head: name (+ shard/scepter label). The large icon is intentionally
// omitted — the selected skill is already highlighted in the icon row above.
const head = document.createElement("div");
head.className = "skill-info-head";
const titles = document.createElement("div");
titles.className = "skill-info-titles";
const nameEl = document.createElement("div");
nameEl.className = "skill-info-name";
nameEl.textContent = skillEnt.name_loc;
titles.appendChild(nameEl);
if (skillEnt.label) {
const lab = document.createElement("div");
lab.className =
"skill-info-label" +
(skillEnt.kind === "shard"
? " shard"
: skillEnt.kind === "scepter"
? " scepter"
: "");
lab.textContent = skillEnt.label;
titles.appendChild(lab);
}
head.appendChild(titles);
// Official-page layout: demo clip on the left, name/desc column on the right.
const body = document.createElement("div");
body.className = "skill-info-body";
// Official demo clip: local `/ability-video/...` (serve_relations) or OSS base
// from config.js (static export / Pages). Hidden when the file is missing.
if (heroKey && skillEnt.ability_key) {
const video = document.createElement("video");
video.className = "skill-info-video";
video.src = abilityVideoUrl(heroKey, skillEnt.ability_key);
video.muted = true;
video.loop = true;
video.autoplay = true;
video.playsInline = true;
video.crossOrigin = "anonymous";
video.addEventListener("error", () => video.remove());
body.appendChild(video);
}
const main = document.createElement("div");
main.className = "skill-info-main";
main.appendChild(head);
const desc = document.createElement("div");
desc.className = "skill-info-desc";
desc.textContent = 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";
lab.textContent = 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);
}
// Cooldown / mana footer with icons (actives only; passives have neither).
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);
}
main.appendChild(costs);
}
// Flavor lore line.
if (skillEnt.lore_loc) {
const lore = document.createElement("div");
lore.className = "skill-info-lore";
lore.textContent = skillEnt.lore_loc;
main.appendChild(lore);
}
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("div");
title.className = "item-detail-name";
title.textContent = meta.name_loc || meta.key;
nameRow.appendChild(title);
if (meta.cost != null) {
const cost = document.createElement("span");
cost.className = "detail-item-cost";
cost.textContent = String(meta.cost);
nameRow.appendChild(cost);
}
wrap.appendChild(nameRow);
const head = document.createElement("div");
head.className = "item-detail-head";
if (!hideIcon) {
const icon = document.createElement("img");
icon.className = "item-detail-large-icon";
icon.src = itemIconSrc(meta.key);
icon.alt = meta.name_loc || meta.key;
head.appendChild(icon);
}
const info = document.createElement("div");
info.className = "item-detail-info";
const descText = stripItemDesc(meta.desc_loc);
if (descText) {
const desc = document.createElement("div");
desc.className = "skill-info-desc";
desc.textContent = descText;
info.appendChild(desc);
} else {
const muted = document.createElement("div");
muted.className = "detail-muted";
muted.textContent = meta.section_label || "物品";
info.appendChild(muted);
}
head.appendChild(info);
wrap.appendChild(head);
const comps = meta.components || [];
if (comps.length) {
const recipe = document.createElement("div");
recipe.className = "item-detail-recipe";
const recipeLabel = document.createElement("div");
recipeLabel.className = "craft-label";
recipeLabel.textContent = "合成";
recipe.appendChild(recipeLabel);
const row = document.createElement("div");
row.className = "craft-row";
for (const key of comps) {
row.appendChild(makeCraftChip(key, { onPick: onPickItem }));
}
recipe.appendChild(row);
wrap.appendChild(recipe);
}
const ups = meta.builds_into || [];
if (ups.length) {
const upBlock = document.createElement("div");
upBlock.className = "item-detail-upgrades";
const upLabel = document.createElement("div");
upLabel.className = "craft-label";
upLabel.textContent = "可升级为";
upBlock.appendChild(upLabel);
const row = document.createElement("div");
row.className = "craft-row craft-upgrades";
for (const key of ups) {
row.appendChild(makeCraftChip(key, { onPick: onPickItem }));
}
upBlock.appendChild(row);
wrap.appendChild(upBlock);
}
root.appendChild(wrap);
}
/** First normal ability (typically Q); never default to talent tree or innate. */
function defaultSkillEntry(heroKey) {
const pack = skillEntriesFor(heroKey);
if (!pack?.entries?.length) return null;
return (
pack.entries.find((e) => e.kind === "ability" && !e.is_innate) || null
);
}
function defaultItemKey(heroKey) {
if (state.detailTab === "core") {
const entries = coreItemsFor(heroKey);
if (!entries?.length) return null;
return itemMeta(entries[0].id)?.key || null;
}
if (state.detailTab === "fears") {
const fears = fearedItemsFor(heroKey);
if (!fears?.length) return null;
const first = fears[0];
return typeof first === "string" ? first : first?.item || first?.key || null;
}
return null;
}
function ensureHeroInspectDefault(heroKey) {
if (state.inspect) {
if (state.inspect.type === "skill") {
const pack = skillEntriesFor(heroKey);
if (pack?.entries?.some((e) => e.id === state.inspect.id)) return;
}
if (state.inspect.type === "item") {
// Keep only if still on an items tab; otherwise fall through to tab default.
if (state.detailTab === "core" || state.detailTab === "fears") return;
}
}
if (state.detailTab === "skills") {
const first = defaultSkillEntry(heroKey);
if (first) {
state.inspect = { type: "skill", id: first.id };
return;
}
} else if (state.detailTab === "core" || state.detailTab === "fears") {
const key = defaultItemKey(heroKey);
if (key) {
state.inspect = { type: "item", key };
return;
}
}
state.inspect = null;
}
function renderHeroInspect(heroKey) {
const pane = document.createElement("div");
pane.className = "hero-inspect";
pane.setAttribute("aria-label", "选中详情");
ensureHeroInspectDefault(heroKey);
if (!state.inspect) {
pane.classList.add("empty");
pane.textContent =
state.detailTab === "skills"
? "点击技能查看详情"
: "点击装备查看详情与合成";
return pane;
}
if (state.inspect.type === "skill") {
const pack = skillEntriesFor(heroKey);
const ent = pack?.entries?.find((e) => e.id === state.inspect.id);
if (!ent) {
pane.classList.add("empty");
pane.textContent = "点击技能查看详情";
return pane;
}
mountSkillInspect(pane, ent, heroKey);
return pane;
}
const meta = resolveItemDetail(state.inspect.key);
if (!meta) {
pane.classList.add("empty");
pane.textContent = "未找到物品数据";
return pane;
}
mountItemInspect(pane, meta, { onPickItem: selectInspectItem, hideIcon: true });
return pane;
}
function fmtGain(v) {
if (v == null || Number.isNaN(Number(v))) return "";
const n = Number(v);
const s = Number.isInteger(n) ? String(n) : n.toFixed(1).replace(/\.0$/, "");
return `+${s}`;
}
function fmtNum(v, digits = 1) {
if (v == null || Number.isNaN(Number(v))) return "";
const n = Number(v);
if (Number.isInteger(n)) return String(n);
return n.toFixed(digits).replace(/\.?0+$/, "");
}
function fmtRegen(v) {
if (v == null || Number.isNaN(Number(v))) return null;
return `+${fmtNum(v, 2)}`;
}
/** Shared HP/Mana bar scale: max base resource across all heroes (cached). */
let _resourceBarScaleCache = null;
function resourceBarScale() {
if (_resourceBarScaleCache != null) return _resourceBarScaleCache;
let max = 0;
for (const h of state.data?.heroes || []) {
if (h.health != null) max = Math.max(max, Number(h.health) || 0);
if (h.mana != null) max = Math.max(max, Number(h.mana) || 0);
}
_resourceBarScaleCache = max > 0 ? max : 1000;
return _resourceBarScaleCache;
}
/** Fill width % on shared scale; clamp so tiny values stay visible. */
function resourceBarPct(value, scale) {
const MIN_PCT = 8;
const n = Number(value);
if (!Number.isFinite(n) || n <= 0 || !(scale > 0)) return MIN_PCT;
return Math.max(MIN_PCT, Math.min(100, (n / scale) * 100));
}
function appendVitalBar(parent, kind, label, value, regen, scale) {
const bar = document.createElement("div");
bar.className = `hero-stat-bar ${kind}`;
const pct = resourceBarPct(value, scale);
bar.innerHTML =
`<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);
}
/** Hero role tags (skills tab center column, above skill icons). */
function buildHeroTagsRow(hero) {
const tags = document.createElement("div");
tags.className = "detail-tags";
tags.setAttribute("aria-label", "英雄定位");
const tagList = hero.tags || [];
if (!tagList.length) {
const none = document.createElement("span");
none.className = "detail-muted";
none.textContent = "暂无定位";
tags.appendChild(none);
return tags;
}
for (const t of tagList) {
const chip = document.createElement("span");
chip.className = "tag-chip";
chip.textContent = t;
tags.appendChild(chip);
}
return tags;
}
/** 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;
}
/** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */
function buildHeroCombatStats(hero) {
const combatGroups = [
{
title: "攻击",
rows: [
hero.damage_min != null && hero.damage_max != null
? ["伤害", `${hero.damage_min}${hero.damage_max}`]
: null,
hero.attack_rate != null ? ["间隔", fmtNum(hero.attack_rate, 1)] : null,
hero.attack_range != null ? ["距离", 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
? ["弹道", String(hero.projectile_speed)]
: null,
].filter(Boolean),
},
{
title: "防御",
rows: [
hero.armor != null ? ["护甲", fmtNum(hero.armor, 1)] : null,
hero.magic_resist != null ? ["魔抗", `${hero.magic_resist}%`] : null,
].filter(Boolean),
},
{
title: "机动性",
rows: [
hero.move_speed != null ? ["移速", String(hero.move_speed)] : null,
hero.turn_rate != null ? ["转身", fmtNum(hero.turn_rate, 1)] : null,
hero.vision_day != null && hero.vision_night != null
? ["视野", `白天:${hero.vision_day}\n夜晚:${hero.vision_night}`]
: null,
].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 [label, val] of g.rows) {
const row = document.createElement("div");
row.className = "hero-stat-row";
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
if (typeof val === "string" && val.includes("\n")) {
for (const line of val.split("\n")) {
const div = document.createElement("div");
const idx = line.indexOf("");
if (idx > 0) {
const sub = document.createElement("span");
sub.className = "hero-stat-row-sub";
sub.textContent = line.slice(0, idx + 1);
div.appendChild(sub);
div.appendChild(document.createTextNode(line.slice(idx + 1)));
} else {
div.textContent = line;
}
dd.appendChild(div);
}
} else {
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.textContent = "点击英雄查看定位、技能、核心装备、公开数据与被克装备";
return;
}
root.classList.remove("empty");
const hero = heroByKey(state.selectedKey);
if (!hero) {
root.classList.add("empty");
root.textContent = "未找到英雄数据";
return;
}
const head = document.createElement("div");
head.className = "detail-head";
const title = document.createElement("div");
title.className = "detail-name";
title.textContent = hero.name_loc || hero.key;
head.appendChild(title);
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(buildHeroTagsRow(hero));
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);
}
}
function shopCatalog() {
return state.data.item_shop || { basic: { sections: [] }, upgraded: { sections: [] }, items: {} };
}
function shopItem(key) {
return (shopCatalog().items || {})[key] || null;
}
function matchesItemQuery(key, meta) {
const q = state.itemQuery.trim();
if (!q) return true;
const qLower = q.toLowerCase();
const name = meta?.name_loc || key;
if (name.includes(q)) return true;
if (key.toLowerCase().includes(qLower)) return true;
if ((meta?.name || "").toLowerCase().includes(qLower)) return true;
return false;
}
function onShopItemClick(key) {
state.selectedItemKey = state.selectedItemKey === key ? null : key;
state.selectedKey = null;
syncStateToUrl();
render();
}
function makeShopItemButton(key) {
const meta = shopItem(key) || { key, name_loc: key };
const btn = document.createElement("button");
btn.type = "button";
btn.className = "shop-item";
const cost = meta.cost != null ? ` ${meta.cost}` : "";
btn.title = `${meta.name_loc || key}${cost}`;
if (key === state.selectedItemKey) btn.classList.add("selected");
if (!matchesItemQuery(key, meta)) btn.style.display = "none";
const img = document.createElement("img");
img.src = itemIconSrc(key);
img.alt = meta.name_loc || key;
img.loading = "lazy";
btn.appendChild(img);
if (meta.charges != null && meta.charges > 0) {
const ch = document.createElement("span");
ch.className = "shop-charges";
ch.textContent = String(meta.charges);
btn.appendChild(ch);
}
btn.addEventListener("click", () => onShopItemClick(key));
return btn;
}
function appendShopColumn(parent, sec) {
const col = document.createElement("section");
col.className = "shop-column";
col.dataset.section = sec.id || "";
if (sec.icon) {
const cat = document.createElement("img");
cat.className = "shop-column-cat";
cat.src = itemCatIconSrc(sec.icon);
cat.alt = sec.label || "";
cat.title = sec.label || "";
col.appendChild(cat);
}
const head = document.createElement("h4");
head.className = "shop-column-label";
head.textContent = sec.label || sec.id || "";
col.appendChild(head);
for (const key of sec.items || []) {
col.appendChild(makeShopItemButton(key));
}
parent.appendChild(col);
}
function renderItemShop() {
const root = $("#item-shop");
if (!root) return;
root.innerHTML = "";
root.className = "item-shop layout-cn";
const shop = shopCatalog();
const basicSecs = shop.basic?.sections || [];
const upSecs = shop.upgraded?.sections || [];
if (!basicSecs.length && !upSecs.length) {
root.classList.add("empty");
root.textContent = "暂无商店数据";
return;
}
const basicWrap = document.createElement("div");
basicWrap.className = "shop-group";
for (const sec of basicSecs) appendShopColumn(basicWrap, sec);
const upWrap = document.createElement("div");
upWrap.className = "shop-group";
for (const sec of upSecs) appendShopColumn(upWrap, sec);
root.appendChild(basicWrap);
root.appendChild(upWrap);
}
function itemIconSrc(key) {
const stem = key.startsWith("recipe_") ? "recipe" : key;
return assetUrl(`item/${encodeURIComponent(stem)}.png`);
}
/** Project OSS base for static icons (same bucket as deploy). */
const PROJECT_OSS_ASSET_BASE =
"https://climperor.oss-cn-shanghai.aliyuncs.com";
function itemIconOssSrc(key) {
const stem = key.startsWith("recipe_") ? "recipe" : key;
return `${PROJECT_OSS_ASSET_BASE}/item/${encodeURIComponent(stem)}.png`;
}
/**
* Prefer configured STATIC_ASSET_BASE / same-origin, then fall back to project OSS
* so match builds still show neutrals / aegis after local cache misses.
*/
function setItemIcon(img, key, onMiss) {
if (!key) {
if (typeof onMiss === "function") onMiss();
return;
}
const primary = itemIconSrc(key);
const oss = itemIconOssSrc(key);
img.src = primary;
img.onerror = () => {
if (img.dataset.ossTried === "1" || primary === oss) {
img.onerror = null;
img.removeAttribute("src");
if (typeof onMiss === "function") onMiss();
return;
}
img.dataset.ossTried = "1";
img.src = oss;
};
}
function makeCraftChip(key, { clickable = true, onPick = null } = {}) {
const meta =
resolveItemDetail(key) ||
shopItem(key) || {
key,
name_loc: key.startsWith("recipe_") ? "卷轴" : key,
};
const btn = document.createElement("button");
btn.type = "button";
btn.className = "craft-chip";
if (meta.is_recipe || key.startsWith("recipe_")) btn.classList.add("is-recipe");
const cost = meta.cost != null ? ` ${meta.cost}` : "";
btn.title = `${meta.name_loc || key}${cost}`;
if (!clickable || meta.is_recipe || key.startsWith("recipe_")) {
btn.disabled = true;
} else {
btn.addEventListener("click", () => {
if (typeof onPick === "function") onPick(key);
});
}
const img = document.createElement("img");
img.src = itemIconSrc(key);
img.alt = meta.name_loc || key;
btn.appendChild(img);
if (meta.is_recipe || key.startsWith("recipe_")) {
const badge = document.createElement("span");
badge.className = "craft-recipe-cost";
badge.textContent = meta.cost != null ? String(meta.cost) : "卷轴";
btn.appendChild(badge);
}
return btn;
}
function stripItemDesc(text) {
return String(text || "")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/?h1[^>]*>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/%[A-Za-z0-9_]+%/g, "?")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function renderItemDetail() {
const root = $("#item-detail-box");
if (!root) return;
root.innerHTML = "";
if (!state.selectedItemKey) {
root.classList.add("empty");
root.textContent = "点击物品查看详情与合成";
return;
}
const meta = resolveItemDetail(state.selectedItemKey);
if (!meta) {
root.classList.add("empty");
root.textContent = "未找到物品数据";
return;
}
mountItemInspect(root, meta, {
onPickItem: (key) => {
state.selectedItemKey = key;
syncStateToUrl();
render();
},
});
}
// Stat-icon labels for patch hero_notes (icon field). Attr values map to the
// bundled attr/{agi,str,int,all}.png icons; everything else renders as a chip.
const STAT_LABELS = {
agility: "敏捷", strength: "力量", intelligence: "智力", all: "全才",
agi: "敏捷", str: "力量", int: "智力",
damage: "攻击力", damage_min: "最小攻击", damage_max: "最大攻击",
armor: "护甲", magic_resistance: "魔抗", magic_resist: "魔抗",
movement: "移速", movement_speed: "移速",
attack_speed: "攻速", attack_rate: "攻速",
attack_range: "攻击距离", attack_time: "攻击间隔",
sight_range_day: "白天视野", sight_range_night: "夜间视野",
vision_day: "白天视野", vision_night: "夜间视野",
health: "生命", mana: "魔法", health_regen: "生命回复", mana_regen: "魔法回复",
turn_rate: "转身", projectile_speed: "弹速",
cooldown: "冷却", mana_cost: "魔法消耗", duration: "持续时间",
cast_range: "施法距离", channel_time: "持续施法",
};
const STAT_ATTR_ICON = {
agility: "agi", strength: "str", intelligence: "int", all: "all",
agi: "agi", str: "str", int: "int",
};
function statChip(icon) {
if (!icon) return "";
const a = STAT_ATTR_ICON[icon];
if (a) return `<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) {
const esc = escapeHtml(note == null ? "" : String(note));
return esc.replace(/&lt;br\s*\/?&gt;/gi, "<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;
}
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="this.style.visibility='hidden'">`
: "";
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="this.style.visibility='hidden'">`
: "";
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="this.style.visibility='hidden'">`
: "";
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(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;
}
function ensureDefaultPatch() {
const patches = state.data?.patches || [];
if (!patches.length) return;
if (!state.selectedPatch || !patches.some((p) => p.version === state.selectedPatch)) {
state.selectedPatch = patches[0].version;
}
}
function renderPatches() {
if (!state.data) return;
const select = $("#patch-select");
const verEl = $("#patch-ver");
const root = $("#patches-detail");
if (!root) return;
const patches = state.data.patches || [];
if (!patches.length) {
if (select) select.innerHTML = "";
if (verEl) verEl.textContent = "";
root.innerHTML = '<div class="patches-empty">暂无版本数据</div>';
renderPatchesSiteVersion();
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) || "";
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>';
renderPatchesSiteVersion();
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);
renderPatchesSiteVersion();
}
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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");
const sub = $("#matches-sub");
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>';
if (sub) sub.textContent = "";
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);
if (sub) {
sub.textContent = 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 }));
if (pageCount > 1) {
wrap.appendChild(
buildMatchesPager(page, pageCount, (next) => {
state.matchesPage = next;
syncStateToUrl();
renderMatchesPage();
const board = $("#matches-view");
if (board) board.scrollTop = 0;
})
);
}
body.replaceChildren(wrap);
}
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 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" : "账号",
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 (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. Local `serve_relations.py` returns
* an empty stub (no probing), so that path keeps the data.json is_live
* fallback. 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;
if (cell.stale) console.info(`live-status: ${row.id} using stale carry-over`);
if (row.is_live !== cell.is_live) {
row.is_live = cell.is_live;
changed = true;
}
}
if (!changed) return;
// Update rendered cards in place (top-level page + hero detail panel);
// rows already merged, so any later re-render picks up the new state.
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 = null;
this.src = assetUrl("ability/innate.png");
};
const textWrap = document.createElement("span");
textWrap.className = "mechanics-text";
const primary = document.createElement("span");
primary.className = "mechanics-primary";
primary.textContent = row.ability.name_loc || row.ability.key;
const secondary = document.createElement("span");
secondary.className = "mechanics-secondary";
secondary.textContent = row.hero.name_loc || row.hero.key;
textWrap.appendChild(primary);
textWrap.appendChild(secondary);
foot.appendChild(abImg);
foot.appendChild(textWrap);
btn.appendChild(portrait);
btn.appendChild(foot);
btn.title = `${row.hero.name_loc || row.hero.key} · ${row.ability.name_loc || row.ability.key}`;
btn.addEventListener("click", () => {
state.page = "heroes";
state.selectedKey = row.hero.key;
state.detailTab = "skills";
state.inspect = { type: "skill", id: "ability:" + row.ability.key };
syncStateToUrl();
render();
});
skillsList.appendChild(btn);
}
}
skillsSec.appendChild(skillsList);
body.appendChild(skillsSec);
if (items.length) {
const itemsSec = document.createElement("section");
itemsSec.className = "mechanics-section";
const itemsTitle = document.createElement("h3");
itemsTitle.className = "mechanics-section-title";
itemsTitle.innerHTML =
"物品 <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 setPage(page) {
if (
page !== "heroes" &&
page !== "rankings" &&
page !== "matches" &&
page !== "streamers" &&
page !== "trends" &&
page !== "mechanics" &&
page !== "items" &&
page !== "patches"
)
return;
closeTalentPopover();
state.page = page;
if (page === "heroes") {
state.selectedItemKey = null;
} else if (page === "rankings") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "matches") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "streamers") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "trends") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "mechanics") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
} else if (page === "items") {
state.selectedKey = null;
state.inspect = null;
} else if (page === "patches") {
state.selectedKey = null;
state.selectedItemKey = null;
state.inspect = null;
}
syncStateToUrl();
render();
}
function syncChrome() {
document.querySelectorAll(".main-tab").forEach((btn) => {
btn.classList.toggle("active", btn.dataset.page === state.page);
});
const heroesTb = $("#heroes-toolbar");
const itemsTb = $("#items-toolbar");
const heroesView = $("#heroes-view");
const rankingsView = $("#rankings-view");
const matchesView = $("#matches-view");
const streamersView = $("#streamers-view");
const trendsView = $("#trends-view");
const mechanicsView = $("#mechanics-view");
const itemsView = $("#items-view");
const patchesView = $("#patches-view");
const detail = $("#detail");
if (heroesTb) heroesTb.classList.toggle("hidden", state.page !== "heroes");
if (itemsTb) itemsTb.classList.toggle("hidden", state.page !== "items");
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
if (streamersView) streamersView.classList.toggle("hidden", state.page !== "streamers");
if (trendsView) trendsView.classList.toggle("hidden", state.page !== "trends");
if (mechanicsView) mechanicsView.classList.toggle("hidden", state.page !== "mechanics");
if (itemsView) itemsView.classList.toggle("hidden", state.page !== "items");
if (patchesView) patchesView.classList.toggle("hidden", state.page !== "patches");
if (detail) detail.classList.toggle("hidden", state.page !== "heroes");
}
function render() {
syncChrome();
if (state.page === "heroes") {
renderTagbar();
renderBoard();
renderDetail();
} else if (state.page === "rankings") {
renderRankings();
} else if (state.page === "matches") {
renderMatchesPage();
} else if (state.page === "streamers") {
renderStreamers();
} else if (state.page === "trends") {
renderTrends();
} else if (state.page === "mechanics") {
renderMechanics();
} else if (state.page === "items") {
renderItemDetail();
renderItemShop();
} else {
renderPatches();
}
}
// Search-box URL sync timers (debounced replace so typing does not spam history).
let heroSearchSyncTimer = 0;
let itemSearchSyncTimer = 0;
const SEARCH_SYNC_DEBOUNCE_MS = 300;
/**
* Apply a router-produced state patch (from a parsed URL) to state, then render.
* Validates every field against loaded data so bad deep-links degrade gracefully
* (unknown hero/item/version is dropped rather than crashing the UI).
*/
function applyPatch(patch) {
if (!state.data) {
render();
return;
}
// Page (default heroes on bad/missing).
if (
patch.page &&
["heroes", "rankings", "matches", "streamers", "trends", "mechanics", "items", "patches"].includes(patch.page)
) {
state.page = patch.page;
} else {
state.page = "heroes";
}
// Hero + detail sub-tab.
if (patch.heroKey && heroByKey(patch.heroKey)) {
state.selectedKey = patch.heroKey;
if (
patch.detailTab &&
["skills", "core", "fears", "trends", "stats", "matchups", "matches", "streamers", "patches"].includes(patch.detailTab)
) {
state.detailTab = patch.detailTab === "stats" ? "trends" : patch.detailTab;
} else {
state.detailTab = "skills";
}
} else {
state.selectedKey = null;
state.detailTab = "skills";
}
// Reset inspect so ensureHeroInspectDefault re-picks per tab (inspect is URL-less).
state.inspect = null;
// Trends board bracket + sort (top-level 走势 page).
if (
patch.trendsBracket &&
Object.prototype.hasOwnProperty.call(BRACKET_LABELS, patch.trendsBracket)
) {
state.trendsBracket = patch.trendsBracket;
} else if (state.page === "trends" && !state.trendsBracket) {
state.trendsBracket = "legend";
}
if (patch.trendsSort === "wr_end" || patch.trendsSort === "pr_end") {
state.trendsSort = patch.trendsSort;
} else if (state.page === "trends" && patch.page) {
state.trendsSort = "wr_end";
}
// Mechanics page effect key.
{
const mechOrder = (state.data.mechanic_query && state.data.mechanic_query.order) || [];
if (patch.mechanicEffect && mechOrder.includes(patch.mechanicEffect)) {
state.mechanicEffect = patch.mechanicEffect;
} else if (state.page === "mechanics") {
state.mechanicEffect = mechOrder[0] || "basic_dispel";
}
}
// Immortal region (rankings page).
{
const lb = leaderboardsData();
const regions = lb.regions || {};
const order = lb.region_order || [];
const valid = new Set([...order, ...Object.keys(regions)]);
if (patch.rankingRegion && valid.has(patch.rankingRegion)) {
state.rankingRegion = patch.rankingRegion;
} else if (state.page === "rankings") {
state.rankingRegion = lb.default_region || order[0] || "china";
}
}
// Star-player filter + origin + page (matches page).
if (state.page === "matches") {
if (patch.matchesPlayerId && /^\d+$/.test(String(patch.matchesPlayerId))) {
state.matchesPlayerId = String(patch.matchesPlayerId);
} else {
state.matchesPlayerId = null;
}
if (
patch.matchesOrigin === "pro" ||
patch.matchesOrigin === "china" ||
patch.matchesOrigin === "all"
) {
state.matchesOrigin = patch.matchesOrigin;
} else {
state.matchesOrigin = "all";
}
const mp = Number(patch.matchesPage);
state.matchesPage =
Number.isFinite(mp) && mp >= 1 ? Math.floor(mp) : 1;
}
// Item (items page) — must exist in the shop catalog.
if (patch.itemKey && shopItem(patch.itemKey)) {
state.selectedItemKey = patch.itemKey;
} else {
state.selectedItemKey = null;
}
// Patch version (patches page; null means latest).
if (
patch.patchVersion &&
(state.data.patches || []).some((p) => p.version === patch.patchVersion)
) {
state.selectedPatch = patch.patchVersion;
} else {
state.selectedPatch = null;
}
// Tag filters (heroes page only) — drop unknown tag names.
if (patch.tags instanceof Set) {
const valid = new Set(state.data.tag_order || []);
state.tagFilters = new Set([...patch.tags].filter((t) => valid.has(t)));
}
if (typeof patch.query === "string") {
state.query = patch.query;
const inp = $("#q");
if (inp && inp.value !== state.query) inp.value = state.query;
}
if (typeof patch.itemQuery === "string") {
state.itemQuery = patch.itemQuery;
const inp = $("#q-item");
if (inp && inp.value !== state.itemQuery) inp.value = state.itemQuery;
}
render();
}
function bindSearch() {
const input = $("#q");
if (input) {
input.value = state.query;
input.addEventListener("input", () => {
state.query = input.value;
if (state.page === "heroes") renderBoard();
if (heroSearchSyncTimer) clearTimeout(heroSearchSyncTimer);
heroSearchSyncTimer = setTimeout(() => {
heroSearchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
});
}
const itemInput = $("#q-item");
if (itemInput) {
itemInput.value = state.itemQuery;
itemInput.addEventListener("input", () => {
state.itemQuery = itemInput.value;
if (state.page === "items") renderItemShop();
if (itemSearchSyncTimer) clearTimeout(itemSearchSyncTimer);
itemSearchSyncTimer = setTimeout(() => {
itemSearchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
});
}
}
function bindPageChrome() {
document.querySelectorAll(".main-tab").forEach((btn) => {
btn.addEventListener("click", () => setPage(btn.dataset.page));
});
}
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;
}
if (state.selectedKey) {
state.selectedKey = null;
state.inspect = null;
syncStateToUrl();
render();
}
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);
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();
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");
const res = await fetch("data.json");
if (!res.ok) throw new Error(`data.json: HTTP ${res.status}`);
state.data = await res.json();
state.data.relations = state.data.relations || { counters: [], synergies: [] };
state.data.hero_items = state.data.hero_items || { items: {}, by_hero: {} };
state.data.hero_stats = state.data.hero_stats || {
brackets: [],
by_hero: {},
totals: {},
fetched_at: null,
window_days: 7,
window_label_zh: "近约 7 天公开对局",
};
state.data.hero_matches = state.data.hero_matches || {
meta: {},
items: {},
by_hero: {},
};
state.data.hero_item_fears = state.data.hero_item_fears || { items: {}, by_hero: {} };
state.data.hero_abilities = state.data.hero_abilities || { by_hero: {} };
state.data.item_shop = state.data.item_shop || {
basic: { sections: [] },
upgraded: { sections: [] },
items: {},
};
state.data.items_meta = state.data.items_meta || {};
state.data.patches = state.data.patches || [];
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
state.data.patch_details = state.data.patch_details || {};
state.data.leaderboards = state.data.leaderboards || {
fetched_at: null,
source: "valve",
default_region: "china",
region_order: ["china", "europe", "americas", "se_asia"],
regions: {},
};
state.data.pro_matches = state.data.pro_matches || {
meta: {},
items: {},
pros: {},
by_pro: {},
by_hero: {},
};
state.data.streamers = state.data.streamers || {
fetched_at: null,
source: "manual+douyin",
platform_meta: {
douyin: { label_zh: "抖音", icon: "ui-icon/platform_douyin.png" },
},
streamers: [],
};
_skillEntriesCache.clear();
_itemIdIndex = null;
_resourceBarScaleCache = null;
_innateAbilityKeys = null;
installRouter({ getState: () => state, applyPatch });
applyUrlToState();
refreshStreamerLiveStatus();
}
main().catch((err) => {
const el = $("#columns") || $("#item-shop");
if (el) el.textContent = "加载失败: " + err;
});