v0.5.109: hero role toolbar, skill side-by-side layout, taller detail panel.
Move role filters between grid and detail, fix skill video/text layout and detail height jump, unify item dividers, drop OpenDota matchup cross, sync docs and assets. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+116
-107
@@ -56,6 +56,21 @@ function rankIconSrc(file) {
|
||||
return assetUrl(`rank/${name}`);
|
||||
}
|
||||
|
||||
const ROLE_ICON_FILES = {
|
||||
核心: "Carry.png",
|
||||
辅助: "Support.png",
|
||||
推进: "Pusher.png",
|
||||
逃生: "Escape.png",
|
||||
爆发: "Nuker.png",
|
||||
先手: "Initiator.png",
|
||||
控制: "Disabler.png",
|
||||
耐久: "Durable.png",
|
||||
};
|
||||
|
||||
function roleIconSrc(file) {
|
||||
return assetUrl(`role-icon/${encodeURIComponent(file)}`);
|
||||
}
|
||||
|
||||
function siteVersionLabel() {
|
||||
const v = typeof SITE_VERSION === "string" ? SITE_VERSION.trim() : "";
|
||||
return v ? `v${v}` : "";
|
||||
@@ -243,10 +258,37 @@ function renderTagbar() {
|
||||
if (!bar) return;
|
||||
const order = state.data.tag_order || [];
|
||||
bar.innerHTML = "";
|
||||
|
||||
const addLabel = (button, text, iconFile) => {
|
||||
if (iconFile) {
|
||||
const icon = document.createElement("img");
|
||||
icon.className = "tagbar-role-icon";
|
||||
icon.src = roleIconSrc(iconFile);
|
||||
icon.alt = "";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
button.appendChild(icon);
|
||||
} else {
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "tagbar-all-icon";
|
||||
icon.setAttribute("aria-hidden", "true");
|
||||
icon.innerHTML =
|
||||
'<svg viewBox="0 0 16 16" focusable="false"><path d="M1.5 1.5h5v5h-5zM9.5 1.5h5v5h-5zM1.5 9.5h5v5h-5zM9.5 9.5h5v5h-5z"/></svg>';
|
||||
button.appendChild(icon);
|
||||
}
|
||||
const label = document.createElement("span");
|
||||
label.textContent = text;
|
||||
button.appendChild(label);
|
||||
};
|
||||
|
||||
const selectedHero = state.selectedKey ? heroByKey(state.selectedKey) : null;
|
||||
const heroTags = new Set(selectedHero?.tags || []);
|
||||
|
||||
const allBtn = document.createElement("button");
|
||||
allBtn.type = "button";
|
||||
allBtn.textContent = "全部";
|
||||
allBtn.className = state.tagFilters.size ? "" : "active";
|
||||
addLabel(allBtn, "全部");
|
||||
// "全部" only when no filter and no hero selected; hero roles use `.matched`.
|
||||
allBtn.className =
|
||||
!state.tagFilters.size && !state.selectedKey ? "active" : "";
|
||||
allBtn.addEventListener("click", () => {
|
||||
state.tagFilters.clear();
|
||||
syncStateToUrl({ replace: true });
|
||||
@@ -254,10 +296,16 @@ function renderTagbar() {
|
||||
});
|
||||
bar.appendChild(allBtn);
|
||||
for (const tag of order) {
|
||||
const iconFile = ROLE_ICON_FILES[tag];
|
||||
if (!iconFile) continue;
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.textContent = tag;
|
||||
addLabel(btn, tag, iconFile);
|
||||
if (state.tagFilters.has(tag)) btn.classList.add("active");
|
||||
if (heroTags.has(tag)) {
|
||||
btn.classList.add("matched");
|
||||
btn.title = "该英雄定位";
|
||||
}
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
|
||||
else state.tagFilters.add(tag);
|
||||
@@ -1674,7 +1722,6 @@ function stratzMatchupPack() {
|
||||
kind: "global_aggregate",
|
||||
label_zh: "全局聚合(未按段位 / 分路 / 周过滤)",
|
||||
},
|
||||
cross_source: null,
|
||||
stats: {},
|
||||
}
|
||||
);
|
||||
@@ -1985,13 +2032,6 @@ function formatMatchupWr(wr) {
|
||||
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";
|
||||
@@ -2040,40 +2080,20 @@ function buildMatchupColumn(title, entries, scoreKey, scoreLabel) {
|
||||
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(" · ");
|
||||
meta.textContent = [`胜率 ${wrText}`, `${formatPickCount(row.games)} 场`].join(
|
||||
" · "
|
||||
);
|
||||
mid.appendChild(name);
|
||||
mid.appendChild(meta);
|
||||
|
||||
const score = document.createElement("span");
|
||||
score.className = "matchup-score";
|
||||
score.textContent = formatAdvantage(row[scoreKey]);
|
||||
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(" · ");
|
||||
].join(" · ");
|
||||
|
||||
item.appendChild(img);
|
||||
item.appendChild(mid);
|
||||
@@ -2115,20 +2135,9 @@ function buildMatchupsPanel(heroKey) {
|
||||
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(" ");
|
||||
hint.textContent = "数据来自 STRATZ(相对优势,全局聚合)。";
|
||||
notes.appendChild(hint);
|
||||
|
||||
const stale = cell.stale === true;
|
||||
@@ -2369,7 +2378,7 @@ function mountItemInspect(root, meta, { onPickItem, hideIcon = false } = {}) {
|
||||
|
||||
const nameRow = document.createElement("div");
|
||||
nameRow.className = "item-detail-name-row";
|
||||
const title = document.createElement("div");
|
||||
const title = document.createElement("h3");
|
||||
title.className = "item-detail-name";
|
||||
title.textContent = meta.name_loc || meta.key;
|
||||
nameRow.appendChild(title);
|
||||
@@ -2590,28 +2599,6 @@ function appendVitalBar(parent, kind, label, value, regen, scale) {
|
||||
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;
|
||||
@@ -2672,38 +2659,67 @@ function buildHeroVitalsAttrs(hero) {
|
||||
return strip.childNodes.length ? strip : null;
|
||||
}
|
||||
|
||||
/** Official dota2.com.cn herostatic/stats icons (served from /ui-icon/). */
|
||||
function combatStatIconSrc(name) {
|
||||
return assetUrl(`ui-icon/${name}`);
|
||||
}
|
||||
|
||||
/** Right rail: Attack / Defense / Mobility as 3 side-by-side columns (skills tab). */
|
||||
function buildHeroCombatStats(hero) {
|
||||
// Rows: [iconFile, labelZh, value] — icons match dota2.com.cn hero pages.
|
||||
const combatGroups = [
|
||||
{
|
||||
title: "攻击",
|
||||
rows: [
|
||||
hero.damage_min != null && hero.damage_max != null
|
||||
? ["伤害", `${hero.damage_min}–${hero.damage_max}`]
|
||||
? ["icon_damage.png", "伤害", `${hero.damage_min}–${hero.damage_max}`]
|
||||
: null,
|
||||
hero.attack_rate != null
|
||||
? ["icon_attack_time.png", "间隔", fmtNum(hero.attack_rate, 1)]
|
||||
: null,
|
||||
hero.attack_range != null
|
||||
? ["icon_attack_range.png", "距离", String(hero.attack_range)]
|
||||
: null,
|
||||
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)]
|
||||
? [
|
||||
"icon_projectile_speed.png",
|
||||
"弹道",
|
||||
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,
|
||||
hero.armor != null
|
||||
? ["icon_armor.png", "护甲", fmtNum(hero.armor, 1)]
|
||||
: null,
|
||||
hero.magic_resist != null
|
||||
? ["icon_magic_resist.png", "魔抗", `${hero.magic_resist}%`]
|
||||
: null,
|
||||
].filter(Boolean),
|
||||
},
|
||||
{
|
||||
title: "机动性",
|
||||
rows: [
|
||||
hero.move_speed != null ? ["移速", 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}`]
|
||||
hero.move_speed != null
|
||||
? ["icon_movement_speed.png", "移速", String(hero.move_speed)]
|
||||
: null,
|
||||
// OpenDota omits the engine default 0.6; show it like dota2.com.cn.
|
||||
[
|
||||
"icon_turn_rate.png",
|
||||
"转身",
|
||||
fmtNum(hero.turn_rate != null ? hero.turn_rate : 0.6, 1),
|
||||
],
|
||||
[
|
||||
"icon_vision.png",
|
||||
"视野",
|
||||
`${hero.vision_day != null ? hero.vision_day : 1800} / ${
|
||||
hero.vision_night != null ? hero.vision_night : 800
|
||||
}`,
|
||||
],
|
||||
].filter(Boolean),
|
||||
},
|
||||
].filter((g) => g.rows.length);
|
||||
@@ -2730,30 +2746,21 @@ function buildHeroCombatStats(hero) {
|
||||
section.appendChild(title);
|
||||
const list = document.createElement("dl");
|
||||
list.className = "hero-stats-combat-list";
|
||||
for (const [label, val] of g.rows) {
|
||||
for (const [icon, label, val] of g.rows) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "hero-stat-row";
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = label;
|
||||
const img = document.createElement("img");
|
||||
img.className = "hero-stat-icon";
|
||||
img.src = combatStatIconSrc(icon);
|
||||
img.alt = label;
|
||||
img.title = label;
|
||||
img.width = 20;
|
||||
img.height = 20;
|
||||
img.decoding = "async";
|
||||
dt.appendChild(img);
|
||||
const dd = document.createElement("dd");
|
||||
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;
|
||||
}
|
||||
dd.textContent = val;
|
||||
row.appendChild(dt);
|
||||
row.appendChild(dd);
|
||||
list.appendChild(row);
|
||||
@@ -2771,12 +2778,11 @@ function renderDetail() {
|
||||
root.innerHTML = "";
|
||||
|
||||
if (!state.selectedKey) {
|
||||
root.classList.add("empty");
|
||||
root.textContent = "点击英雄查看定位、技能、核心装备、公开数据与被克装备";
|
||||
root.classList.add("empty", "is-collapsed");
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.remove("empty");
|
||||
root.classList.remove("is-collapsed", "empty");
|
||||
const hero = heroByKey(state.selectedKey);
|
||||
if (!hero) {
|
||||
root.classList.add("empty");
|
||||
@@ -2786,7 +2792,7 @@ function renderDetail() {
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "detail-head";
|
||||
const title = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.className = "detail-name";
|
||||
title.textContent = hero.name_loc || hero.key;
|
||||
head.appendChild(title);
|
||||
@@ -2887,7 +2893,6 @@ function renderDetail() {
|
||||
|
||||
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);
|
||||
@@ -3739,6 +3744,7 @@ 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)
|
||||
@@ -3845,6 +3851,7 @@ function renderMatchesPage() {
|
||||
const summary = total
|
||||
? `共 ${total} 场 · 第 ${page}/${pageCount} 页`
|
||||
: "";
|
||||
if (sub) sub.textContent = summary;
|
||||
|
||||
if (!rows.length) {
|
||||
body.innerHTML = state.matchesPlayerId
|
||||
@@ -5385,7 +5392,8 @@ function syncChrome() {
|
||||
btn.classList.toggle("active", btn.dataset.page === state.page);
|
||||
});
|
||||
const heroesTb = $("#heroes-toolbar");
|
||||
const itemsTb = $("#items-toolbar");
|
||||
const heroSearch = $("#q");
|
||||
const itemSearch = $("#q-item");
|
||||
const heroesView = $("#heroes-view");
|
||||
const rankingsView = $("#rankings-view");
|
||||
const matchesView = $("#matches-view");
|
||||
@@ -5396,7 +5404,8 @@ function syncChrome() {
|
||||
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 (heroSearch) heroSearch.classList.toggle("hidden", state.page !== "heroes");
|
||||
if (itemSearch) itemSearch.classList.toggle("hidden", state.page !== "items");
|
||||
if (heroesView) heroesView.classList.toggle("hidden", state.page !== "heroes");
|
||||
if (rankingsView) rankingsView.classList.toggle("hidden", state.page !== "rankings");
|
||||
if (matchesView) matchesView.classList.toggle("hidden", state.page !== "matches");
|
||||
|
||||
Reference in New Issue
Block a user