Add hash routing and Dota 2 logo to relations preview.

- New web/relations/router.js (parseHash/serializeHash/installRouter):
  top tabs, hero + detail sub-tab, item, patch version, tag filters and
  search query all sync to URL (#/heroes/axe/core, ?tags=...&q=...);
  pushState for discrete picks, replaceState for debounced search.
- export_relations_site.py copies router.js so static export deep-links.
- Header shows dota2_logo.png + title, served via /ui-icon/ whitelist.
- fetch_dota2_logo.py fetches the transparent emblem asset.
- Docs: CHANGELOG / README / DESIGN / AGENTS updated.
This commit is contained in:
voson
2026-07-27 13:09:14 +08:00
parent a91789b72f
commit 1d0428b4a3
13 changed files with 10306 additions and 17 deletions
+3 -1
View File
@@ -55,7 +55,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears | | `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears |
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) | | `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
| `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) | | `data/hero_item_fears.json` | 英雄怕的装备(规则推导;预览「怕」行) |
| `web/relations/` | 关系只读预览页静态资源 | | `web/relations/` | 关系只读预览页静态资源`index.html` / `app.js` / `style.css` / `router.js` |
| `web/relations/router.js` | Hash 路由:`parseHash` / `serializeHash` / `installRouter` / `syncStateToUrl`;状态↔URL 双向同步(顶层标签 / 英雄 + 子标签 / 物品 / 版本 / 标签筛选 / 搜索) |
| `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) | | `templates/cdn/` | 全英雄 CDN 模板(需提交或由脚本生成) |
| `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 | | `templates/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) | | `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
@@ -131,6 +132,7 @@ python evaluate.py
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。 - 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md` - 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`
- 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。 - 改 GSI cfg 时同步核对 `gsi_setup.py` 与 Dota `gamestate_integration` 目录。
- 改预览路由形态(URL 段 / query 参数 / 默认值)时同步 `web/relations/router.js``parseHash` / `serializeHash`)与 `app.js``applyPatch` 校验;新增可路由状态维度时在两处都加,并在 `syncStateToUrl` 调用点(含搜索 debounce)接好。Hash 路由不命中后端,`serve_relations.py` 无需改;`export_relations_site.py` 的导出文件列表须含 `router.js`
-`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。 -`data/heroes.json` 结构时同步 `grid.py`(依赖 `attr` / `name_loc`);`roles``overlay.py` 使用;`aliases` 为中文口语/俗称(勿与 `name_loc` 重复),重跑 `fetch_cdn_templates.py` 会按 `key` 合并保留;`tags` 为中文定位(核心/辅助/…/幻象,由 `roles`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
- 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py` - 不要重新引入 real 模板双层库、`cdn_penalty``build_library.py`
+15
View File
@@ -45,6 +45,21 @@
不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测 不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测
`scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、 `scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、
详情列 720→760px。 详情列 720→760px。
- 关系预览顶栏左上角新增 Dota 2 logo +「上分帝」标题(`assets/ui_icons/dota2_logo.png`
透明 emblem),经 `/ui-icon/` 路由与静态导出统一分发;窄屏自动隐藏标题或整组标识,
不影响居中分页布局。
- 关系预览新增 **Hash 路由**:顶层标签(英雄 / 物品 / 版本)、选中英雄 + 详情子标签
(技能 / 核心装备 / 被克装备 / 版本变更)、物品页选中物品、版本页选中版本均同步进
URL;英雄页的定位标签筛选与搜索框文本以 query 参数(`?tags=核心,辅助&q=axe`)进
URL。形态如 `#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41`
`#/patches` = 最新版,序列化时省略与最新同名的版本号)。刷新保状态、可分享 /
深度链接、浏览器前进后退还原;搜索框 300ms debounce + `replaceState`(不刷历史栈),
标签筛选用 `replaceState`,其余离散选择用 `pushState`。坏链接(未知英雄 / 物品 /
版本)优雅降级为丢弃该项。新增 `web/relations/router.js``parseHash` /
`serializeHash` / `installRouter`),`app.js``main()` 装载并首屏 `applyUrlToState`
`export_relations_site.py` 静态导出列表补入 `router.js`,静态站点深度链接同样可用
(可部署 GitHub Pages,无需 rewrite)。Hash 路由不命中后端路径,`serve_relations.py`
零改动。
### Fixed ### Fixed
+8
View File
@@ -229,6 +229,14 @@ Dota 2 (-gamestateintegration)
- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。 - **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。
- **预览**`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。 - **预览**`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。
另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json` 另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `data/item_shop.json`
- **预览 Hash 路由**:状态(顶层标签 / 选中英雄 + 详情子标签 / 选中物品 / 选中版本 /
标签筛选 / 搜索)双向同步进 URL(`#/heroes/axe/core?tags=核心&q=axe` 等)。选 Hash
而非 Path 路由:静态导出(GitHub Pages)无需 rewrite、`serve_relations.py` 零改动、
相对路径子站亦可用。写 URL 用 `history.pushState`/`replaceState`(静默,不触发
`hashchange`,无回环);前进/后退靠 `hashchange` 监听重 parse + 校验 + render。
搜索 debounce 300ms + `replaceState` 防刷历史栈;坏链接丢弃该项不崩。路由逻辑集中在
`web/relations/router.js``app.js` 只在 `main()``installRouter` + 各 state 变更点
`syncStateToUrl`
- **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json` - **不做**:避用标、Steam 登录页、爬 Dotabuff;关系**不**写入 `data/heroes.json`
--- ---
+2
View File
@@ -41,6 +41,8 @@ python fetch_patches.py # 近一年版本日志 + 详情 → data/patches
python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新) python serve_relations.py # 只读预览 http://127.0.0.1:8765(改 JSON 后刷新)
``` ```
预览页带 **Hash 路由**`#/heroes/axe/core``#/items/black_king_bar``#/patches/7.41``#/patches` = 最新);英雄页的定位标签筛选与搜索框也进 URL(`?tags=核心&q=axe`)。刷新保状态、可分享 / 深度链接、浏览器前进后退还原。`python export_relations_site.py` 导出的静态站点同样支持深度链接(可部署 GitHub Pages)。
仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。 仿局内选将四列网格(图标来自 [dota2.com/heroes](https://www.dota2.com/heroes) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。 英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+9902 -6
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -69,7 +69,7 @@ def main() -> None:
out.mkdir(parents=True) out.mkdir(parents=True)
# Frontend (uses relative paths — works from any sub-path). # Frontend (uses relative paths — works from any sub-path).
for name in ("index.html", "app.js", "style.css"): for name in ("index.html", "router.js", "app.js", "style.css"):
shutil.copy2(WEB_DIR / name, out / name) shutil.copy2(WEB_DIR / name, out / name)
# Data snapshot (same payload as serve_relations /api/data). # Data snapshot (same payload as serve_relations /api/data).
+77
View File
@@ -0,0 +1,77 @@
"""Fetch the official Dota 2 emblem icon (no wordmark) for the preview header.
Source (discovered, not guessed) from the dota2.com.cn site HTML:
<link rel="Shortcut Icon" href="//www.dota2.com.cn/favicon.ico"/>
That .ico is a multi-frame container whose largest frames are 256x256 PNGs of
the red Dota 2 map-marker emblem with a transparent background — i.e. the
brand icon alone, no "DOTA 2" text. We extract the first 256x256 PNG frame,
decode with OpenCV, and write it to assets/ui_icons/dota2_logo.png so it rides
the existing /ui-icon/ route + static-export glob pipeline.
"""
import struct
import sys
import cv2
import numpy as np
from common import UI_ICONS
from http_utils import http_bytes
FAVICON_URL = "https://www.dota2.com.cn/favicon.ico"
OUT = UI_ICONS / "dota2_logo.png"
TARGET_SIZE = 256
def _extract_largest_png_frame(data: bytes) -> bytes | None:
"""Return the bytes of the largest PNG-embedded frame in an ICO blob."""
if len(data) < 6 or data[:4] != b"\x00\x00\x01\x00":
return None
count = struct.unpack("<H", data[4:6])[0]
best: tuple[int, bytes] | None = None
for i in range(count):
off = 6 + 16 * i
if off + 16 > len(data):
break
w = data[off]
size = struct.unpack("<I", data[off + 8 : off + 12])[0]
img_off = struct.unpack("<I", data[off + 12 : off + 16])[0]
width = 256 if w == 0 else w
blob = data[img_off : img_off + size]
if blob[:8] != b"\x89PNG\r\n\x1a\n":
continue
if best is None or width * width > best[0]:
best = (width * width, blob)
return best[1] if best else None
def main() -> int:
if OUT.is_file() and OUT.stat().st_size >= 512:
print(f"exists: {OUT} ({OUT.stat().st_size} bytes)")
return 0
data = http_bytes(FAVICON_URL, timeout=30)
if not data or len(data) < 16:
print("empty favicon download", file=sys.stderr)
return 1
png = _extract_largest_png_frame(data)
if png is None:
print("no PNG frame in .ico", file=sys.stderr)
return 1
arr = cv2.imdecode(np.frombuffer(png, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
if arr is None:
print("decode failed", file=sys.stderr)
return 1
UI_ICONS.mkdir(parents=True, exist_ok=True)
ok = cv2.imwrite(str(OUT), arr)
if not ok:
print("write failed", file=sys.stderr)
return 1
h, w = arr.shape[:2]
channels = arr.shape[2] if arr.ndim == 3 else 1
transparent = int(np.count_nonzero(arr[:, :, 3] == 0)) if channels == 4 else 0
print(f"saved {OUT} ({w}x{h}, {OUT.stat().st_size} bytes, {transparent} px transparent)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -448,7 +448,7 @@ class Handler(BaseHTTPRequestHandler):
return return
if path.startswith("/ui-icon/"): if path.startswith("/ui-icon/"):
key = path[len("/ui-icon/") :] key = path[len("/ui-icon/") :]
if key not in ("cooldown.png",): if key not in ("cooldown.png", "dota2_logo.png"):
self._json(400, {"error": "bad ui icon"}) self._json(400, {"error": "bad ui icon"})
return return
fpath = UI_ICONS / key fpath = UI_ICONS / key
+92 -1
View File
@@ -147,6 +147,7 @@ function renderBoard() {
function onHeroClick(key) { function onHeroClick(key) {
state.selectedKey = state.selectedKey === key ? null : key; state.selectedKey = state.selectedKey === key ? null : key;
state.inspect = null; state.inspect = null;
syncStateToUrl();
render(); render();
} }
@@ -161,6 +162,7 @@ function renderTagbar() {
allBtn.className = state.tagFilters.size ? "" : "active"; allBtn.className = state.tagFilters.size ? "" : "active";
allBtn.addEventListener("click", () => { allBtn.addEventListener("click", () => {
state.tagFilters.clear(); state.tagFilters.clear();
syncStateToUrl({ replace: true });
render(); render();
}); });
bar.appendChild(allBtn); bar.appendChild(allBtn);
@@ -172,6 +174,7 @@ function renderTagbar() {
btn.addEventListener("click", () => { btn.addEventListener("click", () => {
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag); if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
else state.tagFilters.add(tag); else state.tagFilters.add(tag);
syncStateToUrl({ replace: true });
render(); render();
}); });
bar.appendChild(btn); bar.appendChild(btn);
@@ -1286,6 +1289,7 @@ function renderDetail() {
// Reset inspect to the tab default (first skill / first item). // Reset inspect to the tab default (first skill / first item).
state.inspect = null; state.inspect = null;
ensureHeroInspectDefault(state.selectedKey); ensureHeroInspectDefault(state.selectedKey);
syncStateToUrl();
renderDetail(); renderDetail();
}); });
tabbar.appendChild(btn); tabbar.appendChild(btn);
@@ -1368,6 +1372,7 @@ function matchesItemQuery(key, meta) {
function onShopItemClick(key) { function onShopItemClick(key) {
state.selectedItemKey = state.selectedItemKey === key ? null : key; state.selectedItemKey = state.selectedItemKey === key ? null : key;
state.selectedKey = null; state.selectedKey = null;
syncStateToUrl();
render(); render();
} }
@@ -1522,6 +1527,7 @@ function renderItemDetail() {
mountItemInspect(root, meta, { mountItemInspect(root, meta, {
onPickItem: (key) => { onPickItem: (key) => {
state.selectedItemKey = key; state.selectedItemKey = key;
syncStateToUrl();
render(); render();
}, },
}); });
@@ -1718,6 +1724,7 @@ function renderPatches() {
.join(""); .join("");
select.onchange = () => { select.onchange = () => {
state.selectedPatch = select.value; state.selectedPatch = select.value;
syncStateToUrl();
renderPatches(); renderPatches();
const board = $("#patches-view"); const board = $("#patches-view");
if (board) board.scrollTo(0, 0); if (board) board.scrollTo(0, 0);
@@ -1780,6 +1787,7 @@ function setPage(page) {
state.selectedKey = null; state.selectedKey = null;
state.inspect = null; state.inspect = null;
} }
syncStateToUrl();
render(); render();
} }
@@ -1815,6 +1823,71 @@ function render() {
} }
} }
// Search-box URL sync timer (debounced replace so typing does not spam history).
let searchSyncTimer = 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", "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", "patches"].includes(patch.detailTab)
) {
state.detailTab = 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;
// 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;
}
render();
}
function bindSearch() { function bindSearch() {
const input = $("#q"); const input = $("#q");
if (input) { if (input) {
@@ -1822,6 +1895,12 @@ function bindSearch() {
input.addEventListener("input", () => { input.addEventListener("input", () => {
state.query = input.value; state.query = input.value;
if (state.page === "heroes") renderBoard(); if (state.page === "heroes") renderBoard();
// Debounced replace so each keystroke does not push a history entry.
if (searchSyncTimer) clearTimeout(searchSyncTimer);
searchSyncTimer = setTimeout(() => {
searchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
}); });
} }
const itemInput = $("#q-item"); const itemInput = $("#q-item");
@@ -1830,6 +1909,11 @@ function bindSearch() {
itemInput.addEventListener("input", () => { itemInput.addEventListener("input", () => {
state.itemQuery = itemInput.value; state.itemQuery = itemInput.value;
if (state.page === "items") renderItemShop(); if (state.page === "items") renderItemShop();
if (searchSyncTimer) clearTimeout(searchSyncTimer);
searchSyncTimer = setTimeout(() => {
searchSyncTimer = 0;
syncStateToUrl({ replace: true });
}, SEARCH_SYNC_DEBOUNCE_MS);
}); });
} }
} }
@@ -1851,12 +1935,14 @@ async function main() {
if (state.query && document.activeElement === $("#q")) { if (state.query && document.activeElement === $("#q")) {
state.query = ""; state.query = "";
$("#q").value = ""; $("#q").value = "";
syncStateToUrl({ replace: true });
renderBoard(); renderBoard();
return; return;
} }
if (state.selectedKey) { if (state.selectedKey) {
state.selectedKey = null; state.selectedKey = null;
state.inspect = null; state.inspect = null;
syncStateToUrl();
render(); render();
} }
return; return;
@@ -1864,11 +1950,13 @@ async function main() {
if (state.itemQuery && document.activeElement === $("#q-item")) { if (state.itemQuery && document.activeElement === $("#q-item")) {
state.itemQuery = ""; state.itemQuery = "";
$("#q-item").value = ""; $("#q-item").value = "";
syncStateToUrl({ replace: true });
renderItemShop(); renderItemShop();
return; return;
} }
if (state.selectedItemKey) { if (state.selectedItemKey) {
state.selectedItemKey = null; state.selectedItemKey = null;
syncStateToUrl();
render(); render();
} }
}); });
@@ -1909,7 +1997,10 @@ async function main() {
state.data.patches = state.data.patches || []; state.data.patches = state.data.patches || [];
state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} }; state.data.patch_lookup = state.data.patch_lookup || { items: {}, abilities: {}, heroes: {} };
state.data.patch_details = state.data.patch_details || {}; state.data.patch_details = state.data.patch_details || {};
render(); // Install hash router now that state.data is loaded (validation needs it),
// then apply the initial URL (deep-link support) which also renders.
installRouter({ getState: () => state, applyPatch });
applyUrlToState();
} }
main().catch((err) => { main().catch((err) => {
+6 -1
View File
@@ -8,6 +8,10 @@
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
<div class="brand">
<img class="brand-logo" src="ui-icon/dota2_logo.png" alt="Dota 2" />
<span class="brand-title">上分帝</span>
</div>
<nav class="main-tabs" aria-label="主分页"> <nav class="main-tabs" aria-label="主分页">
<button type="button" class="main-tab active" data-page="heroes">英雄</button> <button type="button" class="main-tab active" data-page="heroes">英雄</button>
<button type="button" class="main-tab" data-page="items">物品</button> <button type="button" class="main-tab" data-page="items">物品</button>
@@ -47,7 +51,7 @@
<main id="patches-view" class="board patches-board hidden"> <main id="patches-view" class="board patches-board hidden">
<div class="patches-header"> <div class="patches-header">
<h2 class="patches-title">游戏性更新 <span class="patches-ver" id="patch-ver"></span></h2> <h2 class="patches-title"><span class="patches-kicker">游戏性更新</span><span class="patches-ver" id="patch-ver"></span></h2>
<select id="patch-select" aria-label="选择版本"></select> <select id="patch-select" aria-label="选择版本"></select>
</div> </div>
<div class="patches-detail" id="patches-detail"></div> <div class="patches-detail" id="patches-detail"></div>
@@ -55,6 +59,7 @@
<section class="detail" id="detail" aria-live="polite"></section> <section class="detail" id="detail" aria-live="polite"></section>
<script src="/router.js"></script>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+151
View File
@@ -0,0 +1,151 @@
/* global window, history, location */
/**
* Hash-based router for the relations preview (web/relations).
*
* Synchronizes the browser URL with app state across these dimensions:
* - page: heroes | items | patches (top-level tab)
* - hero: selected hero key + detail sub-tab (skills|core|fears|patches)
* - item: selected shop item key (items page)
* - patch: selected version string (patches page; latest when absent)
* - query params (heroes page only): tags=csv, q=search text
*
* Two-way binding without feedback loops:
* - App calls syncStateToUrl() after each state mutation. We use
* history.pushState/replaceState, which update the URL silently
* (no hashchange event fires) — so applyUrlToState is never re-entered.
* - Back/forward navigation fires hashchange → applyUrlToState parses
* the new hash, hands a validated patch back to app, and app re-renders.
*
* Depends on app.js for state shape and render(); installed via installRouter().
*/
const ROUTE_DEFAULT = "#/heroes";
const VALID_PAGES = ["heroes", "items", "patches"];
const VALID_DETAIL_TABS = ["skills", "core", "fears", "patches"];
let _deps = null;
/**
* Bind the router. deps = {
* getState: () => state,
* applyPatch: (patch) => void // validates + merges + renders
* }
* Call once after state.data is loaded.
*/
function installRouter(deps) {
_deps = deps;
window.addEventListener("hashchange", applyUrlToState);
}
/** Parse a hash string into a state patch (all fields nullable). */
function parseHash(hash) {
const out = {
page: null,
heroKey: null,
detailTab: null,
itemKey: null,
patchVersion: null,
tags: null,
query: null,
};
let raw = hash || "";
if (raw.startsWith("#")) raw = raw.slice(1);
if (!raw.startsWith("/")) raw = "/" + raw;
const qIdx = raw.indexOf("?");
const pathPart = qIdx >= 0 ? raw.slice(0, qIdx) : raw;
const queryPart = qIdx >= 0 ? raw.slice(qIdx + 1) : "";
const segs = pathPart.split("/").filter(Boolean);
if (!segs.length) return out;
const page = segs[0];
if (!VALID_PAGES.includes(page)) return out;
out.page = page;
if (page === "heroes") {
if (segs[1]) out.heroKey = safeDecode(segs[1]);
if (segs[2]) out.detailTab = safeDecode(segs[2]);
} else if (page === "items") {
if (segs[1]) out.itemKey = safeDecode(segs[1]);
} else if (page === "patches") {
if (segs[1]) out.patchVersion = safeDecode(segs[1]);
}
const params = new URLSearchParams(queryPart);
const tagsParam = params.get("tags");
if (tagsParam) {
out.tags = new Set(
tagsParam.split(",").map((s) => safeDecode(s)).filter(Boolean)
);
}
const qParam = params.get("q");
if (qParam != null) out.query = qParam;
return out;
}
function safeDecode(s) {
try {
return decodeURIComponent(s);
} catch {
return "";
}
}
/** Serialize current app state into a hash string (with leading '#'). */
function serializeHash(state) {
if (!state || !VALID_PAGES.includes(state.page)) return ROUTE_DEFAULT;
let hash = "#" + state.page;
if (state.page === "heroes") {
if (state.selectedKey) {
hash += "/" + encodeURIComponent(state.selectedKey);
if (state.detailTab && VALID_DETAIL_TABS.includes(state.detailTab)) {
hash += "/" + encodeURIComponent(state.detailTab);
}
}
} else if (state.page === "items") {
if (state.selectedItemKey) {
hash += "/" + encodeURIComponent(state.selectedItemKey);
}
} else if (state.page === "patches") {
// Omit version when it is the latest — bare #/patches means "latest".
if (state.selectedPatch) {
const patches = (state.data && state.data.patches) || [];
const isLatest =
patches.length > 0 && patches[0].version === state.selectedPatch;
if (!isLatest) {
hash += "/" + encodeURIComponent(state.selectedPatch);
}
}
}
if (state.page === "heroes") {
const qs = new URLSearchParams();
if (state.tagFilters && state.tagFilters.size) {
// URLSearchParams.set already percent-encodes the value, so join raw
// tag names with "," — do NOT pre-encodeURIComponent (double encoding).
qs.set("tags", Array.from(state.tagFilters).join(","));
}
if (state.query) qs.set("q", state.query);
const s = qs.toString();
if (s) hash += "?" + s;
}
return hash;
}
/** Write current state into the URL. Use replace:true for high-frequency
* updates (search typing, tag toggles) to avoid history-stack spam. */
function syncStateToUrl({ replace = false } = {}) {
if (!_deps) return;
const hash = serializeHash(_deps.getState());
if (hash === window.location.hash) return; // no-op when state matches URL
if (replace) {
history.replaceState(null, "", hash);
} else {
history.pushState(null, "", hash);
}
}
/** Parse current location.hash, hand it to app for validation+merge, render.
* Invoked on hashchange (back/forward / direct location.hash writes) and
* once at startup for deep-link support. */
function applyUrlToState() {
if (!_deps) return;
const patch = parseHash(window.location.hash);
_deps.applyPatch(patch);
}
+48 -6
View File
@@ -42,6 +42,39 @@ body {
background: rgba(8, 12, 20, 0.45); background: rgba(8, 12, 20, 0.45);
flex: 0 0 auto; flex: 0 0 auto;
} }
/* Brand mark: official Dota 2 logo + 上分帝 title, pinned to the top-left
corner of the topbar. Absolutely positioned so the centered tab row and
toolbar layout are left untouched. */
.brand {
position: absolute;
left: 20px;
top: 12px;
height: 40px;
display: flex;
align-items: center;
gap: 12px;
pointer-events: none;
z-index: 2;
}
.brand-logo {
height: 34px;
width: auto;
display: block;
}
.brand-title {
font-size: 18px;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--text);
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6);
}
@media (max-width: 880px) {
.brand-title { display: none; }
}
@media (max-width: 620px) {
.brand { display: none; }
}
.main-tabs { .main-tabs {
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -1532,14 +1565,23 @@ body:has(#items-view:not(.hidden)) {
} }
.patches-title { .patches-title {
margin: 0; margin: 0;
font-size: 26px; display: flex;
font-weight: 700; flex-direction: column;
letter-spacing: 0.02em; align-items: flex-start;
color: var(--text); gap: 4px;
}
.patches-kicker {
font-size: 15px;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.14em;
} }
.patches-ver { .patches-ver {
color: var(--sel); font-size: 40px;
margin-left: 4px; font-weight: 800;
color: var(--text);
letter-spacing: 0.02em;
line-height: 1;
} }
#patch-select { #patch-select {
flex: 0 1 auto; flex: 0 1 auto;