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:
@@ -55,7 +55,8 @@ Windows 上的 Dota 2 **天梯选将识别**工具:用 GSI 感知选将阶段
|
||||
| `data/hero_fear_overrides.json` | 英雄→害怕装备手工加减(合并进 item_fears) |
|
||||
| `data/hero_abilities.json` | 英雄技能与机制汇总(由 `fetch_hero_abilities.py` 生成) |
|
||||
| `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/roles/` / `templates/modes/` | 位置 / 模式辅助模板 |
|
||||
| `assets/role_icons/` | Valve 选人筛选角色图标(`filter_*`,品红 chroma 去黑边) |
|
||||
@@ -131,6 +132,7 @@ python evaluate.py
|
||||
- 代码标识符、日志、错误信息、注释用**英文**;用户可见摘要可用中文。
|
||||
- 改匹配阈值或裁切时,用 `evaluate.py` / 标注帧验证,并更新 `CHANGELOG.md` 与必要时的 `DESIGN.md`。
|
||||
- 改 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`+幻想系推导),仅关系预览筛选用;基础属性/血蓝等数值字段供关系预览详情条,勿塞机制文案。
|
||||
- 不要重新引入 real 模板双层库、`cdn_penalty`、`build_library.py`。
|
||||
|
||||
|
||||
@@ -45,6 +45,21 @@
|
||||
不再被牵连出滚轮;压缩参数行距使典型技能在高屏零滚轮(无头 Chrome + CDP 实测
|
||||
`scrollHeight == clientHeight` 验证)。整块宽度上限 1000→1320px、视频 700→820px、
|
||||
详情列 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
|
||||
|
||||
|
||||
@@ -229,6 +229,14 @@ Dota 2 (-gamestateintegration)
|
||||
- **展示**:网格左上角青色方标 + 名次 1/2/3(区别于 Plus 右上三角)。
|
||||
- **预览**:`serve_relations.py` 仿选将网格只读查看;数据手改 `data/relations.json` 或从 xlsx 导入。
|
||||
另有顶级 **物品** 页(基础/升级,网格↔竖式布局),目录见 `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`。
|
||||
|
||||
---
|
||||
|
||||
@@ -41,6 +41,8 @@ python fetch_patches.py # 近一年版本日志 + 详情 → data/patches
|
||||
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) 竖版裁切):点英雄高亮克制 / 被克制 / 搭档,下方显示定位,并以标签页切换 **技能**(可点选魔晶/神杖升级与天赋树)、**核心装备**、**被克装备**、**版本变更**(该英雄近一年各版本改动)。
|
||||
英雄页上方网格固定高度;点选技能 / 核心装备 / 被克装备后,详情显示在下方(装备含合成,非弹窗)。
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
+9902
-6
File diff suppressed because it is too large
Load Diff
@@ -69,7 +69,7 @@ def main() -> None:
|
||||
out.mkdir(parents=True)
|
||||
|
||||
# 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)
|
||||
|
||||
# Data snapshot (same payload as serve_relations /api/data).
|
||||
|
||||
@@ -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
@@ -448,7 +448,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if path.startswith("/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"})
|
||||
return
|
||||
fpath = UI_ICONS / key
|
||||
|
||||
+92
-1
@@ -147,6 +147,7 @@ function renderBoard() {
|
||||
function onHeroClick(key) {
|
||||
state.selectedKey = state.selectedKey === key ? null : key;
|
||||
state.inspect = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -161,6 +162,7 @@ function renderTagbar() {
|
||||
allBtn.className = state.tagFilters.size ? "" : "active";
|
||||
allBtn.addEventListener("click", () => {
|
||||
state.tagFilters.clear();
|
||||
syncStateToUrl({ replace: true });
|
||||
render();
|
||||
});
|
||||
bar.appendChild(allBtn);
|
||||
@@ -172,6 +174,7 @@ function renderTagbar() {
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.tagFilters.has(tag)) state.tagFilters.delete(tag);
|
||||
else state.tagFilters.add(tag);
|
||||
syncStateToUrl({ replace: true });
|
||||
render();
|
||||
});
|
||||
bar.appendChild(btn);
|
||||
@@ -1286,6 +1289,7 @@ function renderDetail() {
|
||||
// Reset inspect to the tab default (first skill / first item).
|
||||
state.inspect = null;
|
||||
ensureHeroInspectDefault(state.selectedKey);
|
||||
syncStateToUrl();
|
||||
renderDetail();
|
||||
});
|
||||
tabbar.appendChild(btn);
|
||||
@@ -1368,6 +1372,7 @@ function matchesItemQuery(key, meta) {
|
||||
function onShopItemClick(key) {
|
||||
state.selectedItemKey = state.selectedItemKey === key ? null : key;
|
||||
state.selectedKey = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
|
||||
@@ -1522,6 +1527,7 @@ function renderItemDetail() {
|
||||
mountItemInspect(root, meta, {
|
||||
onPickItem: (key) => {
|
||||
state.selectedItemKey = key;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
},
|
||||
});
|
||||
@@ -1718,6 +1724,7 @@ function renderPatches() {
|
||||
.join("");
|
||||
select.onchange = () => {
|
||||
state.selectedPatch = select.value;
|
||||
syncStateToUrl();
|
||||
renderPatches();
|
||||
const board = $("#patches-view");
|
||||
if (board) board.scrollTo(0, 0);
|
||||
@@ -1780,6 +1787,7 @@ function setPage(page) {
|
||||
state.selectedKey = null;
|
||||
state.inspect = null;
|
||||
}
|
||||
syncStateToUrl();
|
||||
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() {
|
||||
const input = $("#q");
|
||||
if (input) {
|
||||
@@ -1822,6 +1895,12 @@ function bindSearch() {
|
||||
input.addEventListener("input", () => {
|
||||
state.query = input.value;
|
||||
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");
|
||||
@@ -1830,6 +1909,11 @@ function bindSearch() {
|
||||
itemInput.addEventListener("input", () => {
|
||||
state.itemQuery = itemInput.value;
|
||||
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")) {
|
||||
state.query = "";
|
||||
$("#q").value = "";
|
||||
syncStateToUrl({ replace: true });
|
||||
renderBoard();
|
||||
return;
|
||||
}
|
||||
if (state.selectedKey) {
|
||||
state.selectedKey = null;
|
||||
state.inspect = null;
|
||||
syncStateToUrl();
|
||||
render();
|
||||
}
|
||||
return;
|
||||
@@ -1864,11 +1950,13 @@ async function main() {
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -1909,7 +1997,10 @@ async function main() {
|
||||
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 || {};
|
||||
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) => {
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
</head>
|
||||
<body>
|
||||
<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="主分页">
|
||||
<button type="button" class="main-tab active" data-page="heroes">英雄</button>
|
||||
<button type="button" class="main-tab" data-page="items">物品</button>
|
||||
@@ -47,7 +51,7 @@
|
||||
|
||||
<main id="patches-view" class="board patches-board hidden">
|
||||
<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>
|
||||
</div>
|
||||
<div class="patches-detail" id="patches-detail"></div>
|
||||
@@ -55,6 +59,7 @@
|
||||
|
||||
<section class="detail" id="detail" aria-live="polite"></section>
|
||||
|
||||
<script src="/router.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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
@@ -42,6 +42,39 @@ body {
|
||||
background: rgba(8, 12, 20, 0.45);
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -1532,14 +1565,23 @@ body:has(#items-view:not(.hidden)) {
|
||||
}
|
||||
.patches-title {
|
||||
margin: 0;
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
.patches-kicker {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
letter-spacing: 0.14em;
|
||||
}
|
||||
.patches-ver {
|
||||
color: var(--sel);
|
||||
margin-left: 4px;
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
#patch-select {
|
||||
flex: 0 1 auto;
|
||||
|
||||
Reference in New Issue
Block a user