@cs2dak/react 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +7 -0
- package/package.json +4 -4
- package/src/components/DataTable.tsx +272 -0
- package/src/components/EconomyPanel.tsx +13 -8
- package/src/components/EventBracket.tsx +204 -0
- package/src/components/HeatmapCanvas.tsx +26 -3
- package/src/components/KillFeed.tsx +1 -0
- package/src/components/MapRoles.test.ts +50 -0
- package/src/components/MapRoles.tsx +122 -0
- package/src/components/MatchWorkspace.test.ts +102 -6
- package/src/components/MatchWorkspace.tsx +1082 -149
- package/src/components/Pagination.tsx +54 -0
- package/src/components/Primitives.tsx +105 -0
- package/src/components/RadarFieldCanvas.test.ts +39 -0
- package/src/components/RadarFieldCanvas.tsx +322 -0
- package/src/components/ScoreboardTable.tsx +1 -1
- package/src/components/SeasonLeaderboard.test.ts +1 -1
- package/src/components/SeasonLeaderboard.tsx +9 -7
- package/src/components/TeamComparisonPanel.tsx +168 -0
- package/src/index.ts +19 -3
- package/src/theme.css +1847 -411
- package/src/components/EconomyConversionPanel.tsx +0 -53
package/LICENSE
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
Licensing note / 适用范围
|
|
2
|
+
-------------------------
|
|
3
|
+
The MIT License below applies to this repository EXCEPT apps/dak-studio/,
|
|
4
|
+
which is licensed under the GNU AGPL-3.0-only (see apps/dak-studio/LICENSE).
|
|
5
|
+
Ecosystem packages (@cs2dak/*, python exporter) are MIT; the DAK Studio
|
|
6
|
+
product is AGPL. Third-party attributions: THIRD-PARTY-NOTICES.md.
|
|
7
|
+
|
|
1
8
|
MIT License
|
|
2
9
|
|
|
3
10
|
Copyright (c) 2026 Starfie1d
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cs2dak/react",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"exports": {
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"lucide-react": "^0.468.0",
|
|
12
|
-
"@cs2dak/contract": "1.
|
|
13
|
-
"@cs2dak/presentation": "
|
|
14
|
-
"@cs2dak/maps": "0.
|
|
12
|
+
"@cs2dak/contract": "1.1.0",
|
|
13
|
+
"@cs2dak/presentation": "2.0.0",
|
|
14
|
+
"@cs2dak/maps": "1.0.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
17
17
|
"react": ">=18",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { Pagination } from "./Pagination";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 产品中立的通用数据表:列配置 → 客户端排序(升/降切换)→ 可选分页 → 渲染。
|
|
6
|
+
*
|
|
7
|
+
* 设计目标:取代仓库里 4 套各自为政的排序实现(stu-col-sortable / stu-sort-header /
|
|
8
|
+
* dak-col-sortable / 一堆静态表)。**只下沉逻辑与结构,样式靠 classes 预设注入**——
|
|
9
|
+
* 这样 DAK Studio 的表保留 `stu-*` 长相(STUDIO_TABLE_CLASSES),React 原生用法走
|
|
10
|
+
* `dak-*`(DAK_TABLE_CLASSES),两套设计语言不互相污染。
|
|
11
|
+
*
|
|
12
|
+
* 纯展示:不查数据库、不跑分析,只吃 rows + 列配置。复杂单元格(按钮/证据链/着色)
|
|
13
|
+
* 通过列的 `render(row)` 自定义;行级交互通过 `onRowClick` / `rowClassName` /
|
|
14
|
+
* `rowProps`(hover 联动等特例)。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type SortDirection = "asc" | "desc";
|
|
18
|
+
|
|
19
|
+
/** 热力图色调:供列 `heat` 回调返回,由 classes.heatCell 映射为 CSS class。 */
|
|
20
|
+
export type HeatTone = "high" | "mid" | "low" | "neutral";
|
|
21
|
+
|
|
22
|
+
export interface DataTableColumn<T> {
|
|
23
|
+
key: string;
|
|
24
|
+
/** 表头内容(可含 ⓘ 等节点)。 */
|
|
25
|
+
label: ReactNode;
|
|
26
|
+
/** 数字列右对齐 + 等宽。默认 false(左对齐文本列)。 */
|
|
27
|
+
numeric?: boolean;
|
|
28
|
+
/** 该列可点排序。需配 `sortValue`。 */
|
|
29
|
+
sortable?: boolean;
|
|
30
|
+
/** 排序取值;返回 null 始终排在末尾。 */
|
|
31
|
+
sortValue?: (row: T) => number | string | null;
|
|
32
|
+
/** 自定义单元格渲染;优先于 `format`。 */
|
|
33
|
+
render?: (row: T) => ReactNode;
|
|
34
|
+
/** 简单文本单元格。 */
|
|
35
|
+
format?: (row: T) => ReactNode;
|
|
36
|
+
/** 表头 title 提示。 */
|
|
37
|
+
title?: string;
|
|
38
|
+
/** 可选热力着色:返回色阶名,套用 classes.heatCell。 */
|
|
39
|
+
heat?: (row: T) => HeatTone | undefined;
|
|
40
|
+
/** 单元格附加 class。 */
|
|
41
|
+
cellClassName?: (row: T) => string | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface DataTableClasses {
|
|
45
|
+
table: string;
|
|
46
|
+
numeric: string;
|
|
47
|
+
sortable: string;
|
|
48
|
+
sortableActive: string;
|
|
49
|
+
rowClickable?: string;
|
|
50
|
+
/** 热力单元格 class 生成器(可选)。 */
|
|
51
|
+
heatCell?: (tone: HeatTone) => string;
|
|
52
|
+
/** 内部分页控件 class。 */
|
|
53
|
+
pagination?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** DAK Studio 表(Tactical Slate / stu-*)。CSS 在 studio.css。 */
|
|
57
|
+
export const STUDIO_TABLE_CLASSES: DataTableClasses = {
|
|
58
|
+
table: "stu-mini-table",
|
|
59
|
+
numeric: "stu-num",
|
|
60
|
+
sortable: "stu-col-sortable",
|
|
61
|
+
sortableActive: "stu-col-sortable",
|
|
62
|
+
rowClickable: "stu-row-clickable",
|
|
63
|
+
heatCell: (tone) => `stu-heat-cell stu-heat-${tone}`,
|
|
64
|
+
pagination: "stu-pagination",
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** React 包原生表(dak-*)。CSS 在 theme.css。 */
|
|
68
|
+
export const DAK_TABLE_CLASSES: DataTableClasses = {
|
|
69
|
+
table: "dak-table",
|
|
70
|
+
numeric: "dak-mono",
|
|
71
|
+
sortable: "dak-col-sortable",
|
|
72
|
+
sortableActive: "dak-col-sorted",
|
|
73
|
+
rowClickable: "dak-row-clickable",
|
|
74
|
+
pagination: "dak-pagination",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export interface DataTableProps<T> {
|
|
78
|
+
columns: DataTableColumn<T>[];
|
|
79
|
+
rows: T[];
|
|
80
|
+
rowKey: (row: T) => string;
|
|
81
|
+
classes?: DataTableClasses;
|
|
82
|
+
/** 初始排序列 key;缺省不排序(保留 rows 原序)。 */
|
|
83
|
+
initialSortKey?: string;
|
|
84
|
+
initialSortDirection?: SortDirection;
|
|
85
|
+
/** 设置后启用分页。 */
|
|
86
|
+
pageSize?: number;
|
|
87
|
+
/** 受控分页:外部指定当前页(用于 hover 联动等场景)。缺省走内部 state。 */
|
|
88
|
+
page?: number;
|
|
89
|
+
onPageChange?: (page: number) => void;
|
|
90
|
+
paginationMaxButtons?: number;
|
|
91
|
+
/** 分页信息文字生成器(接收行总数)。 */
|
|
92
|
+
paginationInfo?: (total: number) => string;
|
|
93
|
+
onRowClick?: (row: T) => void;
|
|
94
|
+
rowClassName?: (row: T) => string | undefined;
|
|
95
|
+
/** 行级额外属性(hover 联动等特例)。 */
|
|
96
|
+
rowProps?: (row: T) => React.HTMLAttributes<HTMLTableRowElement>;
|
|
97
|
+
/** 首列前置一个序号列(如排行榜的 #)。 */
|
|
98
|
+
showRank?: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 通用排序切换 hook:同 key 切换升/降,换 key 默认降序。
|
|
103
|
+
* DataTable、SeasonLeaderboard、RoundRobinStage 共用同一模式。
|
|
104
|
+
*/
|
|
105
|
+
export function useSortable<K extends string>(initialKey: K, initialDesc = true) {
|
|
106
|
+
const [sortKey, setSortKey] = useState<K>(initialKey);
|
|
107
|
+
const [sortDesc, setSortDesc] = useState(initialDesc);
|
|
108
|
+
|
|
109
|
+
const handleSort = useCallback((key: K) => {
|
|
110
|
+
setSortKey((prev) => {
|
|
111
|
+
if (key === prev) setSortDesc((d) => !d);
|
|
112
|
+
else setSortDesc(true);
|
|
113
|
+
return key;
|
|
114
|
+
});
|
|
115
|
+
}, []);
|
|
116
|
+
|
|
117
|
+
/** 外部重置排序列(如切换 view 时)。 */
|
|
118
|
+
const resetSort = useCallback((key: K) => {
|
|
119
|
+
setSortKey(key);
|
|
120
|
+
setSortDesc(true);
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
return { sortKey, sortDesc, handleSort, resetSort };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function DataTable<T>({
|
|
127
|
+
columns,
|
|
128
|
+
rows,
|
|
129
|
+
rowKey,
|
|
130
|
+
classes = DAK_TABLE_CLASSES,
|
|
131
|
+
initialSortKey,
|
|
132
|
+
initialSortDirection = "desc",
|
|
133
|
+
pageSize,
|
|
134
|
+
page,
|
|
135
|
+
onPageChange,
|
|
136
|
+
paginationMaxButtons = 8,
|
|
137
|
+
paginationInfo,
|
|
138
|
+
onRowClick,
|
|
139
|
+
rowClassName,
|
|
140
|
+
rowProps,
|
|
141
|
+
showRank = false,
|
|
142
|
+
}: DataTableProps<T>) {
|
|
143
|
+
const [sortKey, setSortKey] = useState<string | null>(initialSortKey ?? null);
|
|
144
|
+
const [sortDesc, setSortDesc] = useState(initialSortDirection === "desc");
|
|
145
|
+
const [internalPage, setInternalPage] = useState(0);
|
|
146
|
+
const controlled = page !== undefined;
|
|
147
|
+
const currentPage = controlled ? page : internalPage;
|
|
148
|
+
const setPage = controlled ? (onPageChange ?? (() => {})) : setInternalPage;
|
|
149
|
+
|
|
150
|
+
function handleSort(key: string) {
|
|
151
|
+
if (sortKey === key) setSortDesc((d) => !d);
|
|
152
|
+
else {
|
|
153
|
+
setSortKey(key);
|
|
154
|
+
setSortDesc(true);
|
|
155
|
+
}
|
|
156
|
+
setPage(0);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const sortedRows = useMemo(() => {
|
|
160
|
+
if (sortKey == null) return rows;
|
|
161
|
+
const col = columns.find((c) => c.key === sortKey);
|
|
162
|
+
if (!col?.sortValue) return rows;
|
|
163
|
+
const dir = sortDesc ? 1 : -1;
|
|
164
|
+
return [...rows].sort((a, b) => {
|
|
165
|
+
const va = col.sortValue!(a);
|
|
166
|
+
const vb = col.sortValue!(b);
|
|
167
|
+
if (va == null && vb == null) return 0;
|
|
168
|
+
if (va == null) return 1; // null 始终末尾
|
|
169
|
+
if (vb == null) return -1;
|
|
170
|
+
if (typeof va === "string" || typeof vb === "string") {
|
|
171
|
+
return String(va).localeCompare(String(vb)) * dir;
|
|
172
|
+
}
|
|
173
|
+
return (vb - va) * dir;
|
|
174
|
+
});
|
|
175
|
+
}, [rows, columns, sortKey, sortDesc]);
|
|
176
|
+
|
|
177
|
+
const totalPages = pageSize ? Math.max(1, Math.ceil(sortedRows.length / pageSize)) : 1;
|
|
178
|
+
const safePage = Math.min(currentPage, totalPages - 1);
|
|
179
|
+
const pageRows = pageSize
|
|
180
|
+
? sortedRows.slice(safePage * pageSize, (safePage + 1) * pageSize)
|
|
181
|
+
: sortedRows;
|
|
182
|
+
|
|
183
|
+
const arrow = (key: string) => (sortKey === key ? (sortDesc ? " ↓" : " ↑") : "");
|
|
184
|
+
|
|
185
|
+
return (
|
|
186
|
+
<>
|
|
187
|
+
{pageSize != null && (
|
|
188
|
+
<Pagination
|
|
189
|
+
page={safePage}
|
|
190
|
+
totalPages={totalPages}
|
|
191
|
+
onChange={setPage}
|
|
192
|
+
maxButtons={paginationMaxButtons}
|
|
193
|
+
info={paginationInfo?.(sortedRows.length)}
|
|
194
|
+
className={classes.pagination}
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
<table className={classes.table}>
|
|
198
|
+
<thead>
|
|
199
|
+
<tr>
|
|
200
|
+
{showRank && <th className={classes.numeric}>#</th>}
|
|
201
|
+
{columns.map((col) => {
|
|
202
|
+
const active = sortKey === col.key;
|
|
203
|
+
const thClass = [
|
|
204
|
+
col.numeric ? classes.numeric : "",
|
|
205
|
+
col.sortable ? classes.sortable : "",
|
|
206
|
+
col.sortable && active ? classes.sortableActive : "",
|
|
207
|
+
]
|
|
208
|
+
.filter(Boolean)
|
|
209
|
+
.join(" ");
|
|
210
|
+
return (
|
|
211
|
+
<th
|
|
212
|
+
key={col.key}
|
|
213
|
+
className={thClass || undefined}
|
|
214
|
+
title={col.title}
|
|
215
|
+
aria-sort={active ? (sortDesc ? "descending" : "ascending") : undefined}
|
|
216
|
+
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
|
217
|
+
>
|
|
218
|
+
{col.label}
|
|
219
|
+
{col.sortable ? arrow(col.key) : ""}
|
|
220
|
+
</th>
|
|
221
|
+
);
|
|
222
|
+
})}
|
|
223
|
+
</tr>
|
|
224
|
+
</thead>
|
|
225
|
+
<tbody>
|
|
226
|
+
{pageRows.map((row, i) => {
|
|
227
|
+
const extra = rowProps?.(row) ?? {};
|
|
228
|
+
return (
|
|
229
|
+
<tr
|
|
230
|
+
key={rowKey(row)}
|
|
231
|
+
{...extra}
|
|
232
|
+
className={[onRowClick ? classes.rowClickable : "", rowClassName?.(row) ?? "", extra.className ?? ""]
|
|
233
|
+
.filter(Boolean)
|
|
234
|
+
.join(" ") || undefined}
|
|
235
|
+
onClick={onRowClick ? () => onRowClick(row) : extra.onClick}
|
|
236
|
+
tabIndex={onRowClick ? 0 : extra.tabIndex}
|
|
237
|
+
role={onRowClick ? "button" : extra.role}
|
|
238
|
+
onKeyDown={
|
|
239
|
+
onRowClick
|
|
240
|
+
? (e) => {
|
|
241
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
onRowClick(row);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
: extra.onKeyDown
|
|
247
|
+
}
|
|
248
|
+
>
|
|
249
|
+
{showRank && <td className={classes.numeric}>{safePage * (pageSize ?? 0) + i + 1}</td>}
|
|
250
|
+
{columns.map((col) => {
|
|
251
|
+
const tone = col.heat?.(row);
|
|
252
|
+
const cellClass = [
|
|
253
|
+
col.numeric ? classes.numeric : "",
|
|
254
|
+
tone && classes.heatCell ? classes.heatCell(tone) : "",
|
|
255
|
+
col.cellClassName?.(row) ?? "",
|
|
256
|
+
]
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.join(" ");
|
|
259
|
+
return (
|
|
260
|
+
<td key={col.key} className={cellClass || undefined}>
|
|
261
|
+
{col.render ? col.render(row) : col.format ? col.format(row) : null}
|
|
262
|
+
</td>
|
|
263
|
+
);
|
|
264
|
+
})}
|
|
265
|
+
</tr>
|
|
266
|
+
);
|
|
267
|
+
})}
|
|
268
|
+
</tbody>
|
|
269
|
+
</table>
|
|
270
|
+
</>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import type { EconomyPoint } from "@cs2dak/contract";
|
|
1
|
+
import type { EconomyPoint, EconomyType } from "@cs2dak/contract";
|
|
2
2
|
import { ECONOMY_LABEL_SHORT } from "@cs2dak/presentation";
|
|
3
3
|
|
|
4
4
|
export interface EconomyPanelProps {
|
|
5
5
|
points: EconomyPoint[];
|
|
6
6
|
teamAName: string;
|
|
7
7
|
teamBName: string;
|
|
8
|
+
/** 点击回合卡跳转到该回合的 2D 回放。 */
|
|
9
|
+
onJumpRound?: (roundNumber: number) => void;
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
const width = 760;
|
|
11
13
|
const height = 260;
|
|
12
14
|
const pad = { left: 48, right: 18, top: 24, bottom: 34 };
|
|
13
|
-
const economyColors = {
|
|
15
|
+
const economyColors: Record<EconomyType, string> = {
|
|
14
16
|
pistol: "rgba(255, 198, 77, 0.18)",
|
|
15
17
|
eco: "rgba(104, 115, 129, 0.18)",
|
|
16
18
|
semi: "rgba(73, 182, 255, 0.16)",
|
|
17
19
|
force: "rgba(255, 122, 33, 0.18)",
|
|
18
|
-
full: "rgba(83, 215, 126, 0.16)"
|
|
19
|
-
|
|
20
|
-
} as const;
|
|
20
|
+
full: "rgba(83, 215, 126, 0.16)"
|
|
21
|
+
};
|
|
21
22
|
|
|
22
23
|
const upsetLabels: Record<string, string> = {
|
|
23
24
|
eco: "ECO翻",
|
|
@@ -35,11 +36,11 @@ function getUpsetType(point: EconomyPoint): "eco" | "semi" | "force" | null {
|
|
|
35
36
|
const winnerEco = point.winnerTeamKey === "teamA" ? point.teamAEconomy : point.teamBEconomy;
|
|
36
37
|
const loserEco = point.winnerTeamKey === "teamA" ? point.teamBEconomy : point.teamAEconomy;
|
|
37
38
|
if (winnerEco !== "eco" && winnerEco !== "semi" && winnerEco !== "force") return null;
|
|
38
|
-
if (loserEco !== "full"
|
|
39
|
+
if (loserEco !== "full") return null;
|
|
39
40
|
return winnerEco;
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
export function EconomyPanel({ points, teamAName, teamBName }: EconomyPanelProps) {
|
|
43
|
+
export function EconomyPanel({ points, teamAName, teamBName, onJumpRound }: EconomyPanelProps) {
|
|
43
44
|
const maxValue = Math.max(1, ...points.flatMap((point) => [point.teamA, point.teamB]));
|
|
44
45
|
const xFor = (index: number) => pad.left + (index / Math.max(points.length - 1, 1)) * (width - pad.left - pad.right);
|
|
45
46
|
const yFor = (value: number) => pad.top + (1 - value / maxValue) * (height - pad.top - pad.bottom);
|
|
@@ -113,8 +114,12 @@ export function EconomyPanel({ points, teamAName, teamBName }: EconomyPanelProps
|
|
|
113
114
|
const upsetType = getUpsetType(point);
|
|
114
115
|
return (
|
|
115
116
|
<div
|
|
116
|
-
className={`dak-economy-round${upsetType ? " dak-economy-round-upset" : ""}`}
|
|
117
|
+
className={`dak-economy-round${upsetType ? " dak-economy-round-upset" : ""}${onJumpRound ? " dak-economy-round-clickable" : ""}`}
|
|
117
118
|
key={point.roundNumber}
|
|
119
|
+
role={onJumpRound ? "button" : undefined}
|
|
120
|
+
tabIndex={onJumpRound ? 0 : undefined}
|
|
121
|
+
onClick={onJumpRound ? () => onJumpRound(point.roundNumber) : undefined}
|
|
122
|
+
onKeyDown={onJumpRound ? (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onJumpRound(point.roundNumber); } } : undefined}
|
|
118
123
|
>
|
|
119
124
|
<b>R{point.roundNumber}</b>
|
|
120
125
|
{upsetType && (
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { BracketCell, ElimModel, SwissModel } from "@cs2dak/contract";
|
|
2
|
+
|
|
3
|
+
// ── 内部接口 ──────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
interface CellHandlers {
|
|
6
|
+
onOpenMatch?: (entryId: string) => void;
|
|
7
|
+
onSelectCell?: (key: string) => void;
|
|
8
|
+
selectedKey?: string | null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// ── MatchBox(DOM 模式) ──────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
function MatchBox({ cell, onOpenMatch, onSelectCell, selectedKey }: { cell: BracketCell } & CellHandlers) {
|
|
14
|
+
if (cell.empty) {
|
|
15
|
+
return (
|
|
16
|
+
<button type="button" className={`dak-eb-box dak-eb-box-empty${selectedKey === cell.key ? " dak-eb-box-sel" : ""}`} onClick={() => onSelectCell?.(cell.key)}>
|
|
17
|
+
+ 附加 demo
|
|
18
|
+
</button>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
const openId = cell.entryIds?.[0];
|
|
22
|
+
const onClick = () => (openId && onOpenMatch ? onOpenMatch(openId) : onSelectCell?.(cell.key));
|
|
23
|
+
const interactive = Boolean((openId && onOpenMatch) || onSelectCell);
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={`dak-eb-box${selectedKey === cell.key ? " dak-eb-box-sel" : ""}${interactive ? " dak-eb-box-click" : ""}`}
|
|
27
|
+
role={interactive ? "button" : undefined}
|
|
28
|
+
onClick={interactive ? onClick : undefined}
|
|
29
|
+
>
|
|
30
|
+
<div className={`dak-eb-team${cell.winner === "A" ? " dak-eb-win" : cell.winner === "B" ? " dak-eb-lose" : ""}`}>
|
|
31
|
+
<span>{cell.teamA ?? "—"}</span>
|
|
32
|
+
<b>{cell.scoreA ?? (cell.teamA ? "" : "")}</b>
|
|
33
|
+
</div>
|
|
34
|
+
<div className={`dak-eb-team${cell.winner === "B" ? " dak-eb-win" : cell.winner === "A" ? " dak-eb-lose" : ""}`}>
|
|
35
|
+
<span>{cell.teamB ?? "—"}</span>
|
|
36
|
+
<b>{cell.scoreB ?? ""}</b>
|
|
37
|
+
</div>
|
|
38
|
+
{cell.date && <div className="dak-eb-date">{new Date(cell.date).toLocaleDateString("zh-CN")}</div>}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ── SwissBracket ──────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** 瑞士轮 Buchholz 图:按轮次成列,列内按战绩组(高战绩在上),右侧晋级(绿)/ 淘汰(红)终列。 */
|
|
46
|
+
export function SwissBracket({
|
|
47
|
+
model,
|
|
48
|
+
onAddToGroup,
|
|
49
|
+
...handlers
|
|
50
|
+
}: { model: SwissModel; onAddToGroup?: (slotId: string) => void } & CellHandlers) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="dak-eb dak-eb-swiss">
|
|
53
|
+
{model.columns.map((col, colIndex) => (
|
|
54
|
+
<div key={`col-${colIndex}-${col.round}`} className="dak-eb-col">
|
|
55
|
+
<div className="dak-eb-col-head">第 {col.round} 轮</div>
|
|
56
|
+
<div className="dak-eb-col-body">
|
|
57
|
+
{col.groups.map((group) => (
|
|
58
|
+
<div key={`${col.round}-${group.record}`} className="dak-eb-group">
|
|
59
|
+
<div className="dak-eb-group-head">{group.record}</div>
|
|
60
|
+
{group.matches.map((cell) => <MatchBox key={cell.key} cell={cell} {...handlers} />)}
|
|
61
|
+
{group.addSlotId && (
|
|
62
|
+
<button type="button" className="dak-eb-add" onClick={() => onAddToGroup?.(group.addSlotId!)}>+ 添加比赛</button>
|
|
63
|
+
)}
|
|
64
|
+
</div>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
))}
|
|
69
|
+
{(model.advanced.length > 0 || model.eliminated.length > 0) && (
|
|
70
|
+
<div className="dak-eb-col dak-eb-outcomes">
|
|
71
|
+
<div className="dak-eb-col-head">结果</div>
|
|
72
|
+
{model.advanced.length > 0 && (
|
|
73
|
+
<div className="dak-eb-outcome dak-eb-advanced">
|
|
74
|
+
<div className="dak-eb-group-head">晋级 ({model.advanced.length})</div>
|
|
75
|
+
{model.advanced.map((row, index) => <div key={`adv-${index}-${row.team}`} className="dak-eb-outcome-row"><span>{row.team}</span><b>{row.record}</b></div>)}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
{model.eliminated.length > 0 && (
|
|
79
|
+
<div className="dak-eb-outcome dak-eb-eliminated">
|
|
80
|
+
<div className="dak-eb-group-head">淘汰 ({model.eliminated.length})</div>
|
|
81
|
+
{model.eliminated.map((row, index) => <div key={`eli-${index}-${row.team}`} className="dak-eb-outcome-row"><span>{row.team}</span><b>{row.record}</b></div>)}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── ElimBracket ───────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
const laneLabels = { single: "淘汰赛", winner: "胜者组", loser: "败者组", grand: "总决赛" } as const;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 淘汰赛 bracket。
|
|
96
|
+
* - 无 nodes(单败 / 制作器):DOM 列布局,MatchBox 可点击。
|
|
97
|
+
* - 有 nodes(双败 / GSL):SVG lane-aware 布局 + 晋级连线,节点可点击。
|
|
98
|
+
*/
|
|
99
|
+
export function ElimBracket({ model, ...handlers }: { model: ElimModel } & CellHandlers) {
|
|
100
|
+
const { nodes } = model;
|
|
101
|
+
|
|
102
|
+
// ── SVG lane-aware 模式(双败 / GSL,有 bracketNodes) ──────────────────
|
|
103
|
+
if (nodes && nodes.length > 0) {
|
|
104
|
+
const rounds = [...new Set(nodes.map((node) => node.round))].sort((a, b) => a - b);
|
|
105
|
+
const roundIndex = new Map(rounds.map((round, index) => [round, index]));
|
|
106
|
+
const lanes = (["winner", "loser", "grand", "single"] as const).filter((lane) => nodes.some((node) => node.lane === lane));
|
|
107
|
+
// 预分组:O(N) 建 Map,替代嵌套循环中 O(L*R*N) 的 nodes.filter()
|
|
108
|
+
const nodesByLaneRound = new Map<string, typeof nodes>();
|
|
109
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
|
110
|
+
for (const node of nodes) {
|
|
111
|
+
const key = `${node.lane}|${node.round}`;
|
|
112
|
+
const group = nodesByLaneRound.get(key);
|
|
113
|
+
if (group) group.push(node);
|
|
114
|
+
else nodesByLaneRound.set(key, [node]);
|
|
115
|
+
}
|
|
116
|
+
const maxPerRound = Math.max(1, ...rounds.map((round) => {
|
|
117
|
+
let count = 0;
|
|
118
|
+
for (const lane of lanes) count += (nodesByLaneRound.get(`${lane}|${round}`)?.length ?? 0);
|
|
119
|
+
return count;
|
|
120
|
+
}));
|
|
121
|
+
const laneMode = lanes.length > 1;
|
|
122
|
+
const laneHeight = Math.max(150, maxPerRound * 52);
|
|
123
|
+
const width = Math.max(520, rounds.length * 260 + (laneMode ? 86 : 0));
|
|
124
|
+
const height = laneMode ? lanes.length * laneHeight : Math.max(180, maxPerRound * 82);
|
|
125
|
+
const positions = new Map<string, { x: number; y: number }>();
|
|
126
|
+
for (const [laneIndex, lane] of lanes.entries()) {
|
|
127
|
+
for (const round of rounds) {
|
|
128
|
+
const rows = nodesByLaneRound.get(`${lane}|${round}`) ?? [];
|
|
129
|
+
rows.forEach((node, index) => positions.set(node.id, {
|
|
130
|
+
x: (roundIndex.get(round) ?? 0) * 260 + (laneMode ? 86 : 12),
|
|
131
|
+
y: laneMode ? laneIndex * laneHeight + ((index + 0.5) * laneHeight) / rows.length : ((index + 0.5) * height) / rows.length,
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 按 node id 查找对应的 BracketCell(已在 columns 中)
|
|
136
|
+
const cellById = new Map<string, BracketCell>();
|
|
137
|
+
for (const col of model.columns) {
|
|
138
|
+
for (const cell of col.matches) cellById.set(cell.key, cell);
|
|
139
|
+
}
|
|
140
|
+
const label = model.columns.length > 0 ? model.columns.map((c) => c.label).join(" · ") : "淘汰赛";
|
|
141
|
+
|
|
142
|
+
return <div className="dak-bracket-diagram" role="img" aria-label={`${label} 胜败晋级关系`}>
|
|
143
|
+
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
|
|
144
|
+
{laneMode && lanes.map((lane, index) => <g key={lane}>
|
|
145
|
+
{index > 0 && <line className="dak-bracket-lane-separator" x1="0" x2={width} y1={index * laneHeight} y2={index * laneHeight} />}
|
|
146
|
+
<text className="dak-bracket-lane-label" x="12" y={index * laneHeight + 24}>{laneLabels[lane]}</text>
|
|
147
|
+
</g>)}
|
|
148
|
+
{/* 晋级连线 */}
|
|
149
|
+
{nodes.flatMap((node) => {
|
|
150
|
+
const from = positions.get(node.id)!;
|
|
151
|
+
return ([{ target: node.nextWinNodeId, loss: false }, { target: node.nextLossNodeId, loss: true }] as const).flatMap(({ target, loss }) => {
|
|
152
|
+
const to = target ? positions.get(target) : null;
|
|
153
|
+
if (!to) return [];
|
|
154
|
+
const startX = from.x + 210;
|
|
155
|
+
const endX = to.x;
|
|
156
|
+
const midX = (startX + endX) / 2;
|
|
157
|
+
return <path key={`${node.id}-${target}-${loss ? "loss" : "win"}`} className={loss ? "dak-bracket-edge dak-bracket-edge-loss" : "dak-bracket-edge"} d={`M ${startX} ${from.y} C ${midX} ${from.y}, ${midX} ${to.y}, ${endX} ${to.y}`}><title>{`${node.label} ${loss ? "败者" : "胜者"}进入 ${nodeById.get(target!)?.label ?? target}`}</title></path>;
|
|
158
|
+
});
|
|
159
|
+
})}
|
|
160
|
+
{/* 节点 */}
|
|
161
|
+
{nodes.map((node) => {
|
|
162
|
+
const position = positions.get(node.id)!;
|
|
163
|
+
const cell = cellById.get(node.id);
|
|
164
|
+
const openId = cell?.entryIds?.[0];
|
|
165
|
+
const interactive = Boolean((openId && handlers.onOpenMatch) || handlers.onSelectCell);
|
|
166
|
+
const score = cell
|
|
167
|
+
? (cell.teamA || cell.teamB) ? `${cell.teamA ?? "—"} ${cell.scoreA ?? ""} : ${cell.teamB ?? "—"} ${cell.scoreB ?? ""}` : "待导入"
|
|
168
|
+
: "待导入";
|
|
169
|
+
const isSelected = handlers.selectedKey === node.id;
|
|
170
|
+
return <g
|
|
171
|
+
key={node.id}
|
|
172
|
+
transform={`translate(${position.x} ${position.y - 25})`}
|
|
173
|
+
role={interactive ? "button" : undefined}
|
|
174
|
+
tabIndex={interactive ? 0 : undefined}
|
|
175
|
+
style={{ cursor: interactive ? "pointer" : undefined }}
|
|
176
|
+
onClick={interactive ? () => {
|
|
177
|
+
if (openId && handlers.onOpenMatch) handlers.onOpenMatch(openId);
|
|
178
|
+
else handlers.onSelectCell?.(node.id);
|
|
179
|
+
} : undefined}
|
|
180
|
+
>
|
|
181
|
+
<rect className="dak-bracket-box" width="210" height="50" rx="4" />
|
|
182
|
+
<rect className={`dak-bracket-box${isSelected ? " dak-eb-box-sel" : ""}`} width="210" height="50" rx="4" fill="transparent" />
|
|
183
|
+
<text className="dak-bracket-box-title" x="10" y="19">{node.label}</text>
|
|
184
|
+
<text className="dak-bracket-box-match" x="10" y="38">{score}</text>
|
|
185
|
+
</g>;
|
|
186
|
+
})}
|
|
187
|
+
</svg>
|
|
188
|
+
</div>;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── DOM 列模式(单败 / 制作器,无 nodes) ───────────────────────────────
|
|
192
|
+
return (
|
|
193
|
+
<div className="dak-eb dak-eb-elim">
|
|
194
|
+
{model.columns.map((col) => (
|
|
195
|
+
<div key={col.round} className="dak-eb-col dak-eb-elim-col">
|
|
196
|
+
<div className="dak-eb-col-head">{col.label}</div>
|
|
197
|
+
<div className="dak-eb-col-body dak-eb-elim-matches">
|
|
198
|
+
{col.matches.map((cell) => <MatchBox key={cell.key} cell={cell} {...handlers} />)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
))}
|
|
202
|
+
</div>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* This replaces the previous DOM-span approach which could not render true density.
|
|
10
10
|
*/
|
|
11
11
|
import type { DemoViewModel, GrenadeType, HeatmapPoint, Side, TeamKey } from "@cs2dak/contract";
|
|
12
|
-
import { getMapCalibration, worldToRadar } from "@cs2dak/maps";
|
|
12
|
+
import { getMapCalibration, worldToRadar, hasLowerLevel, levelAt, type MapLevel } from "@cs2dak/maps";
|
|
13
13
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
14
14
|
|
|
15
15
|
// ── HeatmapRenderer (adapted from simpleheat / CS Demo Manager, BSD-2-Clause) ─
|
|
@@ -172,6 +172,9 @@ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onMo
|
|
|
172
172
|
|
|
173
173
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
174
174
|
const calibration = getMapCalibration(map.name);
|
|
175
|
+
// de_nuke / de_vertigo 双层:按 z 高度把点位拆到上下层分别渲染
|
|
176
|
+
const dualLevel = !!(calibration && hasLowerLevel(calibration) && map.lowerRadarImageUrl);
|
|
177
|
+
const [level, setLevel] = useState<MapLevel>("upper");
|
|
175
178
|
|
|
176
179
|
const counts = useMemo(() => ({
|
|
177
180
|
death: points.filter((p) => p.kind === "death").length,
|
|
@@ -189,6 +192,7 @@ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onMo
|
|
|
189
192
|
if (sideFilter !== "all" && p.side !== sideFilter) return false;
|
|
190
193
|
if (mode === "grenade" && !matchesGrenadeFilter(p.grenadeType, grenadeFilter)) return false;
|
|
191
194
|
if (selectedSteamIds.size > 0 && p.steamId64 && !selectedSteamIds.has(p.steamId64)) return false;
|
|
195
|
+
if (dualLevel && levelAt(p.z, cal) !== level) return false;
|
|
192
196
|
return true;
|
|
193
197
|
})
|
|
194
198
|
.flatMap((p) => {
|
|
@@ -199,7 +203,7 @@ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onMo
|
|
|
199
203
|
(radar.y / cal.radarSize) * CANVAS_SIZE,
|
|
200
204
|
] as [number, number]];
|
|
201
205
|
});
|
|
202
|
-
}, [points, mode, sideFilter, grenadeFilter, selectedSteamIds, calibration]);
|
|
206
|
+
}, [points, mode, sideFilter, grenadeFilter, selectedSteamIds, calibration, dualLevel, level]);
|
|
203
207
|
|
|
204
208
|
// Per-point stamp alpha following CS Demo Manager's approach: target ~10 overlapping
|
|
205
209
|
// events to fully saturate a spot. Floor at 0.1 so isolated events are faintly visible.
|
|
@@ -243,6 +247,22 @@ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onMo
|
|
|
243
247
|
{MODE_LABELS[kind]} <span>{counts[kind]}</span>
|
|
244
248
|
</button>
|
|
245
249
|
))}
|
|
250
|
+
{dualLevel && (
|
|
251
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="地图层级">
|
|
252
|
+
{(["upper", "lower"] as const).map((nextLevel) => (
|
|
253
|
+
<button
|
|
254
|
+
key={nextLevel}
|
|
255
|
+
type="button"
|
|
256
|
+
role="radio"
|
|
257
|
+
aria-checked={level === nextLevel}
|
|
258
|
+
className={level === nextLevel ? "dak-sf-chip dak-sf-chip-active" : "dak-sf-chip"}
|
|
259
|
+
onClick={() => setLevel(nextLevel)}
|
|
260
|
+
>
|
|
261
|
+
{nextLevel === "upper" ? "上层" : "下层"}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
)}
|
|
246
266
|
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="阵营">
|
|
247
267
|
{(["all", "ct", "t"] as const).map((s) => (
|
|
248
268
|
<button
|
|
@@ -306,7 +326,10 @@ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onMo
|
|
|
306
326
|
{/* ── Radar image + canvas ── */}
|
|
307
327
|
<div
|
|
308
328
|
className="dak-heatmap"
|
|
309
|
-
style={
|
|
329
|
+
style={(() => {
|
|
330
|
+
const url = dualLevel && level === "lower" ? map.lowerRadarImageUrl : map.radarImageUrl;
|
|
331
|
+
return url ? { backgroundImage: `url(${url})` } : undefined;
|
|
332
|
+
})()}
|
|
310
333
|
>
|
|
311
334
|
{!map.radarImageUrl && <SchematicRadar mapName={map.name} />}
|
|
312
335
|
<canvas ref={canvasRef} className="dak-heatmap-canvas" aria-hidden="true" />
|
|
@@ -34,6 +34,7 @@ export function KillFeed({ kills, currentTick, tickrate }: KillFeedProps) {
|
|
|
34
34
|
{k.throughSmoke && <b className="dak-kf-badge dak-kf-smk">SMK</b>}
|
|
35
35
|
{k.noScope && <b className="dak-kf-badge dak-kf-ns">NS</b>}
|
|
36
36
|
{k.flashAssist && <b className="dak-kf-badge dak-kf-fa">FA</b>}
|
|
37
|
+
{k.wallbang && <b className="dak-kf-badge dak-kf-wb">WB</b>}
|
|
37
38
|
</span>
|
|
38
39
|
</div>
|
|
39
40
|
))}
|