@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
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 分页控件(产品中立):页码按钮 + 前后翻页 + 可选信息文字。
|
|
3
|
+
*
|
|
4
|
+
* 纯展示:只吃 page/totalPages,回报目标页。样式由 className 决定,
|
|
5
|
+
* 默认 `dak-pagination`(theme.css);DAK Studio 通过 `stu-pagination` 复用同一逻辑。
|
|
6
|
+
*/
|
|
7
|
+
export function Pagination({
|
|
8
|
+
page,
|
|
9
|
+
totalPages,
|
|
10
|
+
onChange,
|
|
11
|
+
maxButtons = 8,
|
|
12
|
+
info,
|
|
13
|
+
className = "dak-pagination",
|
|
14
|
+
}: {
|
|
15
|
+
page: number;
|
|
16
|
+
totalPages: number;
|
|
17
|
+
onChange: (page: number) => void;
|
|
18
|
+
maxButtons?: number;
|
|
19
|
+
info?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
if (totalPages <= 1) return null;
|
|
23
|
+
const safePage = Math.min(page, Math.max(0, totalPages - 1));
|
|
24
|
+
const radius = Math.floor((maxButtons - 1) / 2);
|
|
25
|
+
const start = Math.max(0, Math.min(safePage - radius, totalPages - maxButtons));
|
|
26
|
+
return (
|
|
27
|
+
<nav className={className} aria-label="分页">
|
|
28
|
+
<button type="button" disabled={safePage === 0} onClick={() => onChange(safePage - 1)}>
|
|
29
|
+
‹
|
|
30
|
+
</button>
|
|
31
|
+
{Array.from({ length: Math.min(totalPages, maxButtons) }, (_, i) => {
|
|
32
|
+
const p = start + i;
|
|
33
|
+
return (
|
|
34
|
+
<button
|
|
35
|
+
key={p}
|
|
36
|
+
type="button"
|
|
37
|
+
className={safePage === p ? "active" : ""}
|
|
38
|
+
onClick={() => onChange(p)}
|
|
39
|
+
>
|
|
40
|
+
{p + 1}
|
|
41
|
+
</button>
|
|
42
|
+
);
|
|
43
|
+
})}
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
disabled={safePage >= totalPages - 1}
|
|
47
|
+
onClick={() => onChange(safePage + 1)}
|
|
48
|
+
>
|
|
49
|
+
›
|
|
50
|
+
</button>
|
|
51
|
+
{info && <span className={`${className}-info`}>{info}</span>}
|
|
52
|
+
</nav>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { EvidenceRef } from "@cs2dak/contract";
|
|
3
|
+
import type { AnalysisFinding } from "@cs2dak/presentation";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 产品中立原语(docs/design-language.md §3/§4)。
|
|
7
|
+
* 所有产品的空态、证据跳转与 ⓘ 口径说明必须经由本文件,禁止各自私有实现。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** 空态三件套:empty(无数据)/ insufficient(数据不足)/ error(失败)。加载中用 dak-loading。 */
|
|
11
|
+
export function EmptyState({
|
|
12
|
+
variant = "empty",
|
|
13
|
+
title,
|
|
14
|
+
hint,
|
|
15
|
+
mark,
|
|
16
|
+
action
|
|
17
|
+
}: {
|
|
18
|
+
variant?: "empty" | "insufficient" | "error";
|
|
19
|
+
title: string;
|
|
20
|
+
/** 文案需说明「需要什么才能点亮本页」 */
|
|
21
|
+
hint?: ReactNode;
|
|
22
|
+
/** 仅完整空页用大图标,区块级空态省略 */
|
|
23
|
+
mark?: boolean;
|
|
24
|
+
action?: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
return (
|
|
27
|
+
<div className={`dak-empty dak-empty-${variant}`}>
|
|
28
|
+
{mark && <div className="dak-empty-mark">⌖</div>}
|
|
29
|
+
<h2>{title}</h2>
|
|
30
|
+
{hint != null && <p>{hint}</p>}
|
|
31
|
+
{action}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Query-first 证据链接:统计值 → 回合列表 / 2D 回放。hover 显示去向。 */
|
|
37
|
+
export function EvidenceLink({
|
|
38
|
+
onOpen,
|
|
39
|
+
disabled,
|
|
40
|
+
hint = "打开该场比赛复盘",
|
|
41
|
+
children
|
|
42
|
+
}: {
|
|
43
|
+
onOpen: () => void;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** hover 提示去向 */
|
|
46
|
+
hint?: string;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
}) {
|
|
49
|
+
return (
|
|
50
|
+
<button type="button" className="dak-evidence" disabled={disabled} onClick={onOpen} title={hint}>
|
|
51
|
+
{children}
|
|
52
|
+
</button>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 派生指标的 ⓘ 口径说明(公式、窗口参数、已知误差)。 */
|
|
57
|
+
export function MetricInfo({ note }: { note?: ReactNode }) {
|
|
58
|
+
if (note == null || note === false || (typeof note === "string" && note.trim().length === 0)) return null;
|
|
59
|
+
return (
|
|
60
|
+
<span className="dak-info" tabIndex={0} aria-label="口径说明">
|
|
61
|
+
ⓘ<span className="dak-info-tip" role="tooltip">{note}</span>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 一行的解释边界;Finding 与 observation 都可复用。 */
|
|
67
|
+
export function LimitNote({ children }: { children: ReactNode }) {
|
|
68
|
+
return <p className="dak-limit-note"><b>限制</b>{children}</p>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 系统 Finding 的纯展示外壳;证据定位与用户动作由 Studio container 注入。 */
|
|
72
|
+
export function FindingPanel({
|
|
73
|
+
id,
|
|
74
|
+
finding,
|
|
75
|
+
onOpenEvidence,
|
|
76
|
+
action,
|
|
77
|
+
}: {
|
|
78
|
+
id?: string;
|
|
79
|
+
finding: AnalysisFinding;
|
|
80
|
+
onOpenEvidence?: (evidence: EvidenceRef, finding: AnalysisFinding) => void;
|
|
81
|
+
action?: ReactNode;
|
|
82
|
+
}) {
|
|
83
|
+
return (
|
|
84
|
+
<article id={id} className="dak-finding-panel">
|
|
85
|
+
<div className="dak-finding-head">
|
|
86
|
+
<div>
|
|
87
|
+
<small>{finding.capability}</small>
|
|
88
|
+
<h3>{finding.title}</h3>
|
|
89
|
+
</div>
|
|
90
|
+
{action}
|
|
91
|
+
</div>
|
|
92
|
+
<p>{finding.statement}</p>
|
|
93
|
+
<div className="dak-finding-meta">
|
|
94
|
+
<span>{finding.sample.label}{finding.sample.numerator != null ? ` · ${finding.sample.numerator}${finding.sample.denominator != null ? `/${finding.sample.denominator}` : ""}` : ""}</span>
|
|
95
|
+
<span>{finding.baseline ? `参照:${finding.baseline}` : "仅描述性样本"}</span>
|
|
96
|
+
</div>
|
|
97
|
+
{finding.evidence.length > 0 && onOpenEvidence && (
|
|
98
|
+
<button type="button" className="dak-evidence" onClick={() => onOpenEvidence(finding.evidence[0]!, finding)}>
|
|
99
|
+
查看证据 · R{finding.evidence[0]!.roundNumber}
|
|
100
|
+
</button>
|
|
101
|
+
)}
|
|
102
|
+
{finding.limitations[0] && <LimitNote>{finding.limitations[0]}</LimitNote>}
|
|
103
|
+
</article>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import type { RadarField, RadarFieldBase } from "@cs2dak/contract";
|
|
5
|
+
import { RADAR_FIELD_BASES } from "@cs2dak/contract";
|
|
6
|
+
import { RadarFieldCanvas } from "./RadarFieldCanvas";
|
|
7
|
+
|
|
8
|
+
const maxSec = 115;
|
|
9
|
+
const rows = Array.from({ length: maxSec }, () => new Int32Array([0]));
|
|
10
|
+
const fields = Object.fromEntries(RADAR_FIELD_BASES.map((base) => [base, rows])) as Record<RadarFieldBase, Int32Array[]>;
|
|
11
|
+
|
|
12
|
+
const field: RadarField = {
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
computeVersion: 1,
|
|
15
|
+
mapName: "de_mirage",
|
|
16
|
+
calibrationVersion: "1",
|
|
17
|
+
triAvailability: "full",
|
|
18
|
+
scope: { kind: "league", team: null, economy: "gun", roundCount: 12, matchIds: ["m"] },
|
|
19
|
+
grid: { cellSize: 128, cells: [[0, 0, 0]] },
|
|
20
|
+
maxSec,
|
|
21
|
+
denomCt: new Int32Array(maxSec),
|
|
22
|
+
denomT: new Int32Array(maxSec),
|
|
23
|
+
fields,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe("RadarFieldCanvas", () => {
|
|
27
|
+
it("renders 4x playback without inventing weak-zone detection", () => {
|
|
28
|
+
const html = renderToStaticMarkup(
|
|
29
|
+
React.createElement(RadarFieldCanvas, {
|
|
30
|
+
field,
|
|
31
|
+
map: { name: "de_mirage", radarImageUrl: "./maps/radars/de_mirage.png" },
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
expect(html).not.toContain("薄弱区");
|
|
35
|
+
expect(html).toContain("4x · 1:55-0:01");
|
|
36
|
+
expect(html).toContain("冷 → 热 = 4:3 屏幕可见低 → 高");
|
|
37
|
+
expect(html).toContain("赛事地图基线");
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RadarFieldCanvas —— 雷达覆盖场渲染(描述性,不上评分)。
|
|
3
|
+
*
|
|
4
|
+
* 吃聚合后的 RadarField(赛事基线或单队),逐秒 scrubber + 模式切换 + 队伍−基线差分。
|
|
5
|
+
* 渲染移植自已验证原型(coverage-render):每格柔化径向 blob,顺序场叠加成热力、
|
|
6
|
+
* 差分场红↑/蓝↓。合成/归一化在 @cs2dak/presentation(react 不跑分析),这里只画。
|
|
7
|
+
*
|
|
8
|
+
* 复用 dak-heatmap-* 设计语言与 worldToRadar / 双层处理,与 HeatmapCanvas 视觉一致。
|
|
9
|
+
*/
|
|
10
|
+
import type { RadarField } from "@cs2dak/contract";
|
|
11
|
+
import { RADAR_FIELD_MODES, radarModeFrame, type RadarFieldMode } from "@cs2dak/presentation";
|
|
12
|
+
import {
|
|
13
|
+
getMapCalibration,
|
|
14
|
+
worldToRadar,
|
|
15
|
+
hasLowerLevel,
|
|
16
|
+
levelAt,
|
|
17
|
+
type MapLevel,
|
|
18
|
+
} from "@cs2dak/maps";
|
|
19
|
+
import { Pause, Play } from "lucide-react";
|
|
20
|
+
import { useEffect, useRef, useState } from "react";
|
|
21
|
+
|
|
22
|
+
const CANVAS_SIZE = 1024;
|
|
23
|
+
const PLAYBACK_INTERVAL_MS = 250;
|
|
24
|
+
const MIN_VISIBLE_MAG = 0.005;
|
|
25
|
+
const MIN_VISIBLE_PRESENCE_MAG = 0.003;
|
|
26
|
+
|
|
27
|
+
type Band = [number, string];
|
|
28
|
+
type BandKind = "screen" | "aim" | "presence" | "sound" | "contested";
|
|
29
|
+
|
|
30
|
+
// 按去 0 后的真实 decile 分档;低频边界是控图的主要信息。
|
|
31
|
+
const BAND_COLORS = [
|
|
32
|
+
"34,47,86",
|
|
33
|
+
"42,67,115",
|
|
34
|
+
"48,91,145",
|
|
35
|
+
"50,118,165",
|
|
36
|
+
"48,145,176",
|
|
37
|
+
"64,170,170",
|
|
38
|
+
"103,188,146",
|
|
39
|
+
"166,195,104",
|
|
40
|
+
"220,174,82",
|
|
41
|
+
"245,128,86",
|
|
42
|
+
] as const;
|
|
43
|
+
|
|
44
|
+
function band(...limits: number[]): Band[] {
|
|
45
|
+
return limits.map((limit, index) => [limit, BAND_COLORS[Math.min(index, BAND_COLORS.length - 1)]!]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const SCREEN_BANDS = band(0.005, 0.011, 0.017, 0.027, 0.04, 0.057, 0.08, 0.114, 0.176, 1.01);
|
|
49
|
+
const AIM_BANDS = band(0.004, 0.008, 0.013, 0.02, 0.03, 0.042, 0.059, 0.084, 0.133, 1.01);
|
|
50
|
+
const CONTESTED_BANDS = band(0.003, 0.006, 0.009, 0.013, 0.02, 0.028, 0.041, 0.057, 0.083, 1.01);
|
|
51
|
+
const PRESENCE_BANDS = band(0.001, 0.002, 0.003, 0.004, 0.006, 0.008, 0.011, 0.016, 0.026, 1.01);
|
|
52
|
+
const SOUND_BANDS = band(0.029, 0.068, 0.137, 0.219, 0.306, 0.392, 0.484, 0.584, 0.722, 1.01);
|
|
53
|
+
const DEFAULT_BANDS: Record<BandKind, Band[]> = {
|
|
54
|
+
screen: SCREEN_BANDS,
|
|
55
|
+
aim: AIM_BANDS,
|
|
56
|
+
presence: PRESENCE_BANDS,
|
|
57
|
+
sound: SOUND_BANDS,
|
|
58
|
+
contested: CONTESTED_BANDS,
|
|
59
|
+
};
|
|
60
|
+
const MAP_BANDS: Partial<Record<string, Partial<Record<BandKind, Band[]>>>> = {
|
|
61
|
+
de_ancient: {
|
|
62
|
+
screen: band(0.007, 0.014, 0.023, 0.035, 0.05, 0.068, 0.087, 0.113, 0.156, 1.01),
|
|
63
|
+
aim: band(0.005, 0.011, 0.018, 0.027, 0.038, 0.051, 0.067, 0.089, 0.123, 1.01),
|
|
64
|
+
presence: band(0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.011, 0.016, 0.025, 1.01),
|
|
65
|
+
sound: band(0.043, 0.091, 0.179, 0.247, 0.315, 0.368, 0.43, 0.514, 0.64, 1.01),
|
|
66
|
+
contested: band(0.004, 0.008, 0.012, 0.017, 0.025, 0.034, 0.048, 0.065, 0.092, 1.01),
|
|
67
|
+
},
|
|
68
|
+
de_anubis: {
|
|
69
|
+
screen: band(0.005, 0.01, 0.016, 0.024, 0.036, 0.052, 0.073, 0.109, 0.182, 1.01),
|
|
70
|
+
aim: band(0.004, 0.008, 0.013, 0.019, 0.028, 0.04, 0.056, 0.083, 0.139, 1.01),
|
|
71
|
+
presence: band(0.002, 0.003, 0.004, 0.005, 0.007, 0.009, 0.012, 0.017, 0.027, 1.01),
|
|
72
|
+
sound: band(0.023, 0.054, 0.115, 0.193, 0.289, 0.382, 0.482, 0.591, 0.742, 1.01),
|
|
73
|
+
contested: band(0.004, 0.006, 0.01, 0.014, 0.02, 0.028, 0.039, 0.055, 0.077, 1.01),
|
|
74
|
+
},
|
|
75
|
+
de_dust2: {
|
|
76
|
+
screen: band(0.007, 0.012, 0.019, 0.029, 0.049, 0.075, 0.103, 0.145, 0.214, 1.01),
|
|
77
|
+
aim: band(0.005, 0.009, 0.014, 0.022, 0.035, 0.052, 0.072, 0.101, 0.159, 1.01),
|
|
78
|
+
presence: band(0.001, 0.002, 0.003, 0.004, 0.005, 0.007, 0.009, 0.014, 0.024, 1.01),
|
|
79
|
+
sound: band(0.019, 0.044, 0.095, 0.169, 0.256, 0.343, 0.436, 0.536, 0.689, 1.01),
|
|
80
|
+
contested: band(0.003, 0.006, 0.01, 0.015, 0.022, 0.033, 0.048, 0.067, 0.093, 1.01),
|
|
81
|
+
},
|
|
82
|
+
de_inferno: {
|
|
83
|
+
screen: band(0.003, 0.006, 0.012, 0.023, 0.035, 0.052, 0.081, 0.121, 0.191, 1.01),
|
|
84
|
+
aim: band(0.002, 0.005, 0.009, 0.017, 0.027, 0.04, 0.059, 0.088, 0.146, 1.01),
|
|
85
|
+
presence: band(0.001, 0.002, 0.003, 0.004, 0.005, 0.007, 0.011, 0.017, 0.03, 1.01),
|
|
86
|
+
sound: band(0.017, 0.044, 0.103, 0.192, 0.272, 0.362, 0.457, 0.567, 0.737, 1.01),
|
|
87
|
+
contested: band(0.002, 0.003, 0.005, 0.007, 0.012, 0.022, 0.036, 0.055, 0.085, 1.01),
|
|
88
|
+
},
|
|
89
|
+
de_mirage: {
|
|
90
|
+
screen: band(0.006, 0.012, 0.019, 0.031, 0.044, 0.06, 0.081, 0.116, 0.182, 1.01),
|
|
91
|
+
aim: band(0.005, 0.009, 0.015, 0.022, 0.033, 0.044, 0.061, 0.088, 0.139, 1.01),
|
|
92
|
+
presence: band(0.001, 0.002, 0.003, 0.004, 0.005, 0.007, 0.01, 0.014, 0.023, 1.01),
|
|
93
|
+
sound: band(0.047, 0.092, 0.188, 0.272, 0.35, 0.421, 0.491, 0.573, 0.697, 1.01),
|
|
94
|
+
contested: band(0.003, 0.006, 0.009, 0.013, 0.019, 0.028, 0.041, 0.057, 0.09, 1.01),
|
|
95
|
+
},
|
|
96
|
+
de_nuke: {
|
|
97
|
+
screen: band(0.004, 0.008, 0.013, 0.02, 0.029, 0.04, 0.053, 0.074, 0.122, 1.01),
|
|
98
|
+
aim: band(0.003, 0.006, 0.009, 0.014, 0.02, 0.028, 0.037, 0.051, 0.083, 1.01),
|
|
99
|
+
presence: band(0.001, 0.002, 0.003, 0.005, 0.006, 0.009, 0.012, 0.017, 0.027, 1.01),
|
|
100
|
+
sound: band(0.022, 0.092, 0.196, 0.311, 0.424, 0.528, 0.624, 0.719, 0.819, 1.01),
|
|
101
|
+
contested: band(0.003, 0.005, 0.008, 0.012, 0.017, 0.024, 0.033, 0.045, 0.064, 1.01),
|
|
102
|
+
},
|
|
103
|
+
de_overpass: {
|
|
104
|
+
screen: band(0.005, 0.011, 0.018, 0.026, 0.038, 0.053, 0.076, 0.11, 0.17, 1.01),
|
|
105
|
+
aim: band(0.004, 0.008, 0.013, 0.02, 0.028, 0.04, 0.058, 0.083, 0.131, 1.01),
|
|
106
|
+
presence: band(0.002, 0.003, 0.004, 0.005, 0.006, 0.008, 0.011, 0.015, 0.024, 1.01),
|
|
107
|
+
sound: band(0.035, 0.066, 0.109, 0.179, 0.264, 0.36, 0.475, 0.593, 0.724, 1.01),
|
|
108
|
+
contested: band(0.003, 0.006, 0.01, 0.015, 0.021, 0.029, 0.039, 0.053, 0.077, 1.01),
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
const DIFF_POS_RGB = "255,90,114"; // --dak-danger
|
|
112
|
+
const DIFF_NEG_RGB = "73,182,255"; // --dak-accent-b
|
|
113
|
+
|
|
114
|
+
function bandKind(mode: RadarFieldMode): BandKind {
|
|
115
|
+
if (mode === "ctAim" || mode === "tAim") return "aim";
|
|
116
|
+
if (mode === "ctPres" || mode === "tPres") return "presence";
|
|
117
|
+
if (mode === "ctSound" || mode === "tSound") return "sound";
|
|
118
|
+
if (mode === "contested") return "contested";
|
|
119
|
+
return "screen";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function bandsForMode(mapName: string, mode: RadarFieldMode): Band[] {
|
|
123
|
+
const kind = bandKind(mode);
|
|
124
|
+
return MAP_BANDS[mapName]?.[kind] ?? DEFAULT_BANDS[kind];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function heatBand(mapName: string, mode: RadarFieldMode, mag: number): string {
|
|
128
|
+
const bands = bandsForMode(mapName, mode);
|
|
129
|
+
for (const [hi, rgb] of bands) if (mag <= hi) return rgb;
|
|
130
|
+
return "255,102,54";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function minVisibleMag(mode: RadarFieldMode): number {
|
|
134
|
+
return mode === "ctPres" || mode === "tPres" ? MIN_VISIBLE_PRESENCE_MAG : MIN_VISIBLE_MAG;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function formatClock(seconds: number): string {
|
|
138
|
+
const clamped = Math.max(0, Math.floor(seconds));
|
|
139
|
+
return `${Math.floor(clamped / 60)}:${String(clamped % 60).padStart(2, "0")}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface RadarFieldCanvasProps {
|
|
143
|
+
field: RadarField;
|
|
144
|
+
/** 非空 = 差分模式(队伍 − 赛事地图基线):防守漏洞 / 进攻防守倾向。 */
|
|
145
|
+
baseline?: RadarField | null;
|
|
146
|
+
map: { name: string; radarImageUrl?: string | null; lowerRadarImageUrl?: string | null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function RadarFieldCanvas({ field, baseline = null, map }: RadarFieldCanvasProps) {
|
|
150
|
+
const [mode, setMode] = useState<RadarFieldMode>("ctVis");
|
|
151
|
+
const [sec, setSec] = useState(20);
|
|
152
|
+
const [playing, setPlaying] = useState(false);
|
|
153
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
154
|
+
|
|
155
|
+
const calibration = getMapCalibration(map.name);
|
|
156
|
+
const dualLevel = !!(calibration && hasLowerLevel(calibration) && map.lowerRadarImageUrl);
|
|
157
|
+
const [level, setLevel] = useState<MapLevel>("upper");
|
|
158
|
+
|
|
159
|
+
const isDiff = !!baseline;
|
|
160
|
+
const maxSec = Math.max(0, field.maxSec - 1);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
setSec((value) => Math.min(value, maxSec));
|
|
164
|
+
}, [maxSec]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (!playing || maxSec <= 0) return undefined;
|
|
168
|
+
const timer = window.setInterval(() => {
|
|
169
|
+
setSec((value) => {
|
|
170
|
+
if (value >= maxSec) {
|
|
171
|
+
setPlaying(false);
|
|
172
|
+
return maxSec;
|
|
173
|
+
}
|
|
174
|
+
return value + 1;
|
|
175
|
+
});
|
|
176
|
+
}, PLAYBACK_INTERVAL_MS);
|
|
177
|
+
return () => window.clearInterval(timer);
|
|
178
|
+
}, [playing, maxSec]);
|
|
179
|
+
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const canvas = canvasRef.current;
|
|
182
|
+
if (!canvas) return;
|
|
183
|
+
canvas.width = CANVAS_SIZE;
|
|
184
|
+
canvas.height = CANVAS_SIZE;
|
|
185
|
+
const ctx = canvas.getContext("2d");
|
|
186
|
+
if (!ctx || !calibration) return;
|
|
187
|
+
|
|
188
|
+
const cal = calibration;
|
|
189
|
+
const frame = radarModeFrame(field, baseline, mode, sec);
|
|
190
|
+
const cells = field.grid.cells;
|
|
191
|
+
const px2canvas = CANVAS_SIZE / cal.radarSize;
|
|
192
|
+
const blobR = (field.grid.cellSize / cal.scale) * 1.35 * px2canvas;
|
|
193
|
+
|
|
194
|
+
ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE);
|
|
195
|
+
ctx.globalCompositeOperation = "source-over";
|
|
196
|
+
|
|
197
|
+
for (let g = 0; g < cells.length; g++) {
|
|
198
|
+
const v = frame.values[g]!;
|
|
199
|
+
const mag = Math.abs(v);
|
|
200
|
+
if (mag < minVisibleMag(mode)) continue;
|
|
201
|
+
const cell = cells[g]!;
|
|
202
|
+
if (dualLevel && levelAt(cell[2], cal) !== level) continue;
|
|
203
|
+
const radar = worldToRadar({ x: cell[0], y: cell[1] }, cal);
|
|
204
|
+
if (radar.outOfBounds) continue;
|
|
205
|
+
const x = radar.x * px2canvas;
|
|
206
|
+
const y = radar.y * px2canvas;
|
|
207
|
+
const t = Math.min(1, mag / frame.cap);
|
|
208
|
+
const op = 0.12 + 0.36 * Math.pow(t, 0.72);
|
|
209
|
+
const rgb = frame.signed ? (v > 0 ? DIFF_POS_RGB : DIFF_NEG_RGB) : heatBand(map.name, mode, mag);
|
|
210
|
+
const grd = ctx.createRadialGradient(x, y, 0, x, y, blobR);
|
|
211
|
+
grd.addColorStop(0, `rgba(${rgb},${op.toFixed(2)})`);
|
|
212
|
+
grd.addColorStop(1, `rgba(${rgb},0)`);
|
|
213
|
+
ctx.fillStyle = grd;
|
|
214
|
+
ctx.beginPath();
|
|
215
|
+
ctx.arc(x, y, blobR, 0, Math.PI * 2);
|
|
216
|
+
ctx.fill();
|
|
217
|
+
}
|
|
218
|
+
ctx.globalCompositeOperation = "source-over";
|
|
219
|
+
}, [field, baseline, mode, sec, level, calibration, dualLevel]);
|
|
220
|
+
|
|
221
|
+
const bgUrl = dualLevel && level === "lower" ? map.lowerRadarImageUrl : map.radarImageUrl;
|
|
222
|
+
const legend = isDiff
|
|
223
|
+
? "红 = 高于赛事基线 · 蓝 = 低于基线"
|
|
224
|
+
: mode === "info-diff"
|
|
225
|
+
? "暖 = T 信息优势 · 冷 = CT 预警"
|
|
226
|
+
: mode === "contested"
|
|
227
|
+
? "双方都看到 = 对拼线 / 交火点"
|
|
228
|
+
: mode === "ctPres" || mode === "tPres"
|
|
229
|
+
? "冷 → 热 = 站人频率低 → 高"
|
|
230
|
+
: mode === "ctAim" || mode === "tAim"
|
|
231
|
+
? "冷 → 热 = 准星覆盖低 → 高"
|
|
232
|
+
: mode === "ctSound" || mode === "tSound"
|
|
233
|
+
? "冷 → 热 = 发声被对面听到概率低 → 高"
|
|
234
|
+
: "冷 → 热 = 4:3 屏幕可见低 → 高";
|
|
235
|
+
const legendText = legend;
|
|
236
|
+
const clock = formatClock(field.maxSec - sec);
|
|
237
|
+
const endClock = formatClock(field.maxSec - maxSec);
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<div className="dak-heatmap-wrap" aria-label={`${map.name} radar field`}>
|
|
241
|
+
<div className="dak-heatmap-filters">
|
|
242
|
+
<div className="dak-heatmap-controls" role="radiogroup" aria-label="覆盖场模式">
|
|
243
|
+
{RADAR_FIELD_MODES.map((opt) => (
|
|
244
|
+
<button
|
|
245
|
+
key={opt.value}
|
|
246
|
+
type="button"
|
|
247
|
+
role="radio"
|
|
248
|
+
aria-checked={mode === opt.value}
|
|
249
|
+
className={mode === opt.value ? "dak-mode dak-mode-active" : "dak-mode"}
|
|
250
|
+
onClick={() => setMode(opt.value)}
|
|
251
|
+
>
|
|
252
|
+
{opt.label}
|
|
253
|
+
</button>
|
|
254
|
+
))}
|
|
255
|
+
{dualLevel && (
|
|
256
|
+
<div className="dak-heatmap-side-filter" role="radiogroup" aria-label="地图层级">
|
|
257
|
+
{(["upper", "lower"] as const).map((next) => (
|
|
258
|
+
<button
|
|
259
|
+
key={next}
|
|
260
|
+
type="button"
|
|
261
|
+
role="radio"
|
|
262
|
+
aria-checked={level === next}
|
|
263
|
+
className={level === next ? "dak-sf-chip dak-sf-chip-active" : "dak-sf-chip"}
|
|
264
|
+
onClick={() => setLevel(next)}
|
|
265
|
+
>
|
|
266
|
+
{next === "upper" ? "上层" : "下层"}
|
|
267
|
+
</button>
|
|
268
|
+
))}
|
|
269
|
+
</div>
|
|
270
|
+
)}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
|
|
274
|
+
<div className="dak-heatmap dak-radar-field-map" style={bgUrl ? { backgroundImage: `url(${bgUrl})` } : undefined}>
|
|
275
|
+
<canvas ref={canvasRef} className="dak-heatmap-canvas" aria-hidden="true" />
|
|
276
|
+
{!calibration && <div className="dak-heatmap-empty">该地图暂无雷达标定</div>}
|
|
277
|
+
<div className="dak-heatmap-legend">
|
|
278
|
+
<span>
|
|
279
|
+
{field.scope.kind === "team" ? field.scope.team : "赛事地图基线"}
|
|
280
|
+
<small> · {field.scope.roundCount} 长枪局{isDiff ? ` vs 赛事基线 ${baseline!.scope.roundCount}` : ""}</small>
|
|
281
|
+
</span>
|
|
282
|
+
<span className="dak-heatmap-legend-scale">
|
|
283
|
+
<i className="dak-legend-radar-field" />
|
|
284
|
+
{legendText}
|
|
285
|
+
</span>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
<div className="dak-heatmap-tuning dak-radar-timebar" aria-label="时间轴">
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
className="dak-play-button dak-radar-play-button"
|
|
293
|
+
aria-label={playing ? "暂停控图播放" : "播放控图变化"}
|
|
294
|
+
onClick={() => {
|
|
295
|
+
if (playing) {
|
|
296
|
+
setPlaying(false);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (sec >= maxSec) setSec(0);
|
|
300
|
+
setPlaying(maxSec > 0);
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
{playing ? <Pause size={18} /> : <Play size={18} />}
|
|
304
|
+
</button>
|
|
305
|
+
<label className="dak-radar-time-slider">
|
|
306
|
+
回合时间 {clock}
|
|
307
|
+
<input
|
|
308
|
+
type="range"
|
|
309
|
+
min={0}
|
|
310
|
+
max={maxSec}
|
|
311
|
+
value={sec}
|
|
312
|
+
onChange={(e) => {
|
|
313
|
+
setPlaying(false);
|
|
314
|
+
setSec(Number(e.target.value));
|
|
315
|
+
}}
|
|
316
|
+
/>
|
|
317
|
+
</label>
|
|
318
|
+
<span className="dak-radar-time-meta">4x · 1:55-{endClock}</span>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
@@ -9,7 +9,7 @@ const model = {
|
|
|
9
9
|
version: "cs2-demo-analysis-kit/leaderboard-0.1",
|
|
10
10
|
weightsVersion: "test",
|
|
11
11
|
matchCount: 1,
|
|
12
|
-
provenance: { cohortVersion: "cs2-demo-analysis-kit/cohort-1.0", sourceSchemaVersion: "cs2-demo-format/
|
|
12
|
+
provenance: { cohortVersion: "cs2-demo-analysis-kit/cohort-1.0", sourceSchemaVersion: "cs2-demo-format/3.0", matches: [] },
|
|
13
13
|
views: [
|
|
14
14
|
{
|
|
15
15
|
key: "core",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
|
+
import { useSortable } from "./DataTable";
|
|
2
3
|
import type {
|
|
3
4
|
LeaderboardFormat,
|
|
4
5
|
LeaderboardMetricKey,
|
|
@@ -34,25 +35,25 @@ function formatMetric(value: number | null | undefined, format: LeaderboardForma
|
|
|
34
35
|
export function SeasonLeaderboard({ model, onPlayerClick }: SeasonLeaderboardProps) {
|
|
35
36
|
const [viewKey, setViewKey] = useState<LeaderboardViewKey>(model.views[0]?.key ?? "core");
|
|
36
37
|
const view = model.views.find((v) => v.key === viewKey) ?? model.views[0];
|
|
37
|
-
const
|
|
38
|
+
const { sortKey, sortDesc, handleSort, resetSort } = useSortable<LeaderboardMetricKey>(view.defaultSort);
|
|
38
39
|
|
|
39
40
|
function selectView(next: LeaderboardViewKey) {
|
|
40
41
|
setViewKey(next);
|
|
41
42
|
const nextView = model.views.find((v) => v.key === next);
|
|
42
|
-
if (nextView)
|
|
43
|
+
if (nextView) resetSort(nextView.defaultSort);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
// 排序在客户端:降序,缺失(null)始终排在最后。
|
|
46
46
|
const rows = useMemo(() => {
|
|
47
|
+
const dir = sortDesc ? 1 : -1;
|
|
47
48
|
return [...model.rows].sort((a, b) => {
|
|
48
49
|
const va = a.metrics[sortKey];
|
|
49
50
|
const vb = b.metrics[sortKey];
|
|
50
51
|
if (va == null && vb == null) return 0;
|
|
51
52
|
if (va == null) return 1;
|
|
52
53
|
if (vb == null) return -1;
|
|
53
|
-
return vb - va;
|
|
54
|
+
return (vb - va) * dir;
|
|
54
55
|
});
|
|
55
|
-
}, [model.rows, sortKey]);
|
|
56
|
+
}, [model.rows, sortKey, sortDesc]);
|
|
56
57
|
|
|
57
58
|
return (
|
|
58
59
|
<div className="dak-leaderboard">
|
|
@@ -80,11 +81,12 @@ export function SeasonLeaderboard({ model, onPlayerClick }: SeasonLeaderboardPro
|
|
|
80
81
|
<th
|
|
81
82
|
key={col.key}
|
|
82
83
|
title={col.description ?? undefined}
|
|
83
|
-
aria-sort={col.key === sortKey ? "descending" : undefined}
|
|
84
|
+
aria-sort={col.key === sortKey ? (sortDesc ? "descending" : "ascending") : undefined}
|
|
84
85
|
className={col.key === sortKey ? "dak-col-sorted" : "dak-col-sortable"}
|
|
85
|
-
onClick={() =>
|
|
86
|
+
onClick={() => handleSort(col.key)}
|
|
86
87
|
>
|
|
87
88
|
{col.label}
|
|
89
|
+
{col.key === sortKey ? (sortDesc ? " ↓" : " ↑") : ""}
|
|
88
90
|
</th>
|
|
89
91
|
))}
|
|
90
92
|
</tr>
|