@cs2dak/react 0.2.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.
@@ -0,0 +1,351 @@
1
+ /**
2
+ * Canvas-based heatmap renderer.
3
+ *
4
+ * Two-pass algorithm adapted from simpleheat (BSD-2-Clause) and CS Demo Manager:
5
+ * 1. Stamp blurred grayscale circles at each event point — dense areas accumulate
6
+ * higher alpha values.
7
+ * 2. Colorize: map each pixel's accumulated alpha through a gradient LUT.
8
+ *
9
+ * This replaces the previous DOM-span approach which could not render true density.
10
+ */
11
+ import type { DemoViewModel, GrenadeType, HeatmapPoint, Side, TeamKey } from "@cs2dak/contract";
12
+ import { getMapCalibration, worldToRadar } from "@cs2dak/maps";
13
+ import { useEffect, useMemo, useRef, useState } from "react";
14
+
15
+ // ── HeatmapRenderer (adapted from simpleheat / CS Demo Manager, BSD-2-Clause) ─
16
+
17
+ class HeatmapRenderer {
18
+ private ctx: CanvasRenderingContext2D;
19
+ private stamp: HTMLCanvasElement;
20
+ private gradient = new Uint8ClampedArray(256 * 4);
21
+ private radius = 0;
22
+
23
+ constructor(canvas: HTMLCanvasElement) {
24
+ this.ctx = canvas.getContext("2d", { willReadFrequently: true }) as CanvasRenderingContext2D;
25
+ this.stamp = document.createElement("canvas");
26
+ }
27
+
28
+ setRadius(r: number, blur = 15) {
29
+ const ctx = this.stamp.getContext("2d") as CanvasRenderingContext2D;
30
+ this.radius = r + blur;
31
+ this.stamp.width = this.radius * 2;
32
+ this.stamp.height = this.radius * 2;
33
+ ctx.clearRect(0, 0, this.stamp.width, this.stamp.height);
34
+ // Draw the circle off-canvas; only its shadow falls within bounds.
35
+ ctx.shadowOffsetX = ctx.shadowOffsetY = this.radius * 2;
36
+ ctx.shadowBlur = blur;
37
+ ctx.shadowColor = "black";
38
+ ctx.beginPath();
39
+ ctx.arc(-this.radius, -this.radius, r, 0, Math.PI * 2, true);
40
+ ctx.closePath();
41
+ ctx.fill();
42
+ }
43
+
44
+ setGradient(stops: Record<number, string>) {
45
+ const canvas = document.createElement("canvas");
46
+ canvas.width = 1;
47
+ canvas.height = 256;
48
+ const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
49
+ const grad = ctx.createLinearGradient(0, 0, 0, 256);
50
+ for (const [stop, color] of Object.entries(stops)) {
51
+ grad.addColorStop(Number(stop), color);
52
+ }
53
+ ctx.fillStyle = grad;
54
+ ctx.fillRect(0, 0, 1, 256);
55
+ this.gradient = ctx.getImageData(0, 0, 1, 256).data;
56
+ }
57
+
58
+ draw(points: Array<[number, number]>, stampAlpha: number, finalAlpha: number) {
59
+ const { ctx } = this;
60
+ const { width, height } = ctx.canvas;
61
+ ctx.clearRect(0, 0, width, height);
62
+
63
+ for (const [x, y] of points) {
64
+ ctx.globalAlpha = stampAlpha;
65
+ ctx.drawImage(this.stamp, x - this.radius, y - this.radius);
66
+ }
67
+
68
+ if (width > 0 && height > 0) {
69
+ const image = ctx.getImageData(0, 0, width, height);
70
+ this.colorize(image.data, finalAlpha);
71
+ ctx.putImageData(image, 0, 0);
72
+ }
73
+ }
74
+
75
+ private colorize(pixels: Uint8ClampedArray, alpha: number) {
76
+ const { gradient } = this;
77
+ for (let i = 0; i < pixels.length; i += 4) {
78
+ const j = pixels[i + 3] * 4;
79
+ if (j) {
80
+ pixels[i] = gradient[j];
81
+ pixels[i + 1] = gradient[j + 1];
82
+ pixels[i + 2] = gradient[j + 2];
83
+ pixels[i + 3] = pixels[i + 3] * alpha;
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ // ── Constants ─────────────────────────────────────────────────────────────────
90
+
91
+ const CANVAS_SIZE = 1024;
92
+
93
+ // Classic heat-map gradient, matching CS Demo Manager's default palette.
94
+ const HEAT_GRADIENT: Record<number, string> = {
95
+ 0.4: "blue",
96
+ 0.6: "cyan",
97
+ 0.7: "lime",
98
+ 0.8: "yellow",
99
+ 1.0: "red",
100
+ };
101
+
102
+ type HeatmapMode = HeatmapPoint["kind"];
103
+
104
+ const MODE_LABELS: Record<HeatmapMode, string> = {
105
+ death: "死亡位置",
106
+ kill: "击杀位置",
107
+ grenade: "道具落点",
108
+ };
109
+
110
+ const LEGEND_COLORS: Record<HeatmapMode, string> = {
111
+ death: "var(--dak-danger)",
112
+ kill: "var(--dak-ok)",
113
+ grenade: "var(--dak-warn)",
114
+ };
115
+
116
+ // ── Grenade filter ────────────────────────────────────────────────────────────
117
+
118
+ // Exclude "incendiary" from the user-facing filter — it is grouped with "molotov"
119
+ // (CT side fires incendiary, T side fires molotov; they are the same weapon class).
120
+ type GrenadeFilter = "all" | Exclude<GrenadeType, "incendiary">;
121
+
122
+ const GRENADE_FILTER_OPTIONS: Array<{ value: GrenadeFilter; label: string }> = [
123
+ { value: "all", label: "全部" },
124
+ { value: "flashbang", label: "闪光" },
125
+ { value: "smoke", label: "烟雾" },
126
+ { value: "hegrenade", label: "手雷" },
127
+ { value: "molotov", label: "燃烧" },
128
+ { value: "decoy", label: "诱饵" },
129
+ ];
130
+
131
+ function matchesGrenadeFilter(grenadeType: GrenadeType | null, filter: GrenadeFilter): boolean {
132
+ if (filter === "all") return true;
133
+ if (filter === "molotov") return grenadeType === "molotov" || grenadeType === "incendiary";
134
+ return grenadeType === filter;
135
+ }
136
+
137
+ // ── Component ─────────────────────────────────────────────────────────────────
138
+
139
+ export interface HeatmapCanvasProps {
140
+ map: DemoViewModel["map"];
141
+ points: HeatmapPoint[];
142
+ /** Player roster for per-player filtering. When omitted the filter UI is hidden. */
143
+ players?: Array<{ steamId64: string; name: string; teamKey: TeamKey }>;
144
+ mode?: HeatmapMode;
145
+ onModeChange?: (mode: HeatmapMode) => void;
146
+ }
147
+
148
+ export function HeatmapCanvas({ map, points, players, mode: controlledMode, onModeChange }: HeatmapCanvasProps) {
149
+ const [internalMode, setInternalMode] = useState<HeatmapMode>("death");
150
+ const [radius, setRadius] = useState(25);
151
+ const [blur, setBlur] = useState(15);
152
+ const [opacity, setOpacity] = useState(0.72);
153
+ const [grenadeFilter, setGrenadeFilter] = useState<GrenadeFilter>("all");
154
+ const [sideFilter, setSideFilter] = useState<Side | "all">("all");
155
+ // Empty set = all players shown.
156
+ const [selectedSteamIds, setSelectedSteamIds] = useState<ReadonlySet<string>>(new Set());
157
+
158
+ const mode = controlledMode ?? internalMode;
159
+ const setMode = (next: HeatmapMode) => {
160
+ setInternalMode(next);
161
+ onModeChange?.(next);
162
+ };
163
+
164
+ const togglePlayer = (steamId64: string) => {
165
+ setSelectedSteamIds((prev) => {
166
+ const next = new Set(prev);
167
+ if (next.has(steamId64)) next.delete(steamId64);
168
+ else next.add(steamId64);
169
+ return next;
170
+ });
171
+ };
172
+
173
+ const canvasRef = useRef<HTMLCanvasElement>(null);
174
+ const calibration = getMapCalibration(map.name);
175
+
176
+ const counts = useMemo(() => ({
177
+ death: points.filter((p) => p.kind === "death").length,
178
+ kill: points.filter((p) => p.kind === "kill").length,
179
+ grenade: points.filter((p) => p.kind === "grenade").length,
180
+ }), [points]);
181
+
182
+ const scaledPoints = useMemo<Array<[number, number]>>(() => {
183
+ if (!calibration) return [];
184
+ const cal = calibration;
185
+ return points
186
+ .filter((p) => {
187
+ if (p.x === 0 && p.y === 0) return false;
188
+ if (p.kind !== mode) return false;
189
+ if (sideFilter !== "all" && p.side !== sideFilter) return false;
190
+ if (mode === "grenade" && !matchesGrenadeFilter(p.grenadeType, grenadeFilter)) return false;
191
+ if (selectedSteamIds.size > 0 && p.steamId64 && !selectedSteamIds.has(p.steamId64)) return false;
192
+ return true;
193
+ })
194
+ .flatMap((p) => {
195
+ const radar = worldToRadar(p, cal);
196
+ if (radar.outOfBounds) return [];
197
+ return [[
198
+ (radar.x / cal.radarSize) * CANVAS_SIZE,
199
+ (radar.y / cal.radarSize) * CANVAS_SIZE,
200
+ ] as [number, number]];
201
+ });
202
+ }, [points, mode, sideFilter, grenadeFilter, selectedSteamIds, calibration]);
203
+
204
+ // Per-point stamp alpha following CS Demo Manager's approach: target ~10 overlapping
205
+ // events to fully saturate a spot. Floor at 0.1 so isolated events are faintly visible.
206
+ const stampAlpha = useMemo(() => {
207
+ const n = scaledPoints.length;
208
+ return Math.min(1.0, Math.max(0.1, 10 / Math.max(1, n)));
209
+ }, [scaledPoints.length]);
210
+
211
+ useEffect(() => {
212
+ const canvas = canvasRef.current;
213
+ if (!canvas) return;
214
+
215
+ // Always set dimensions; resetting width also clears the canvas.
216
+ canvas.width = CANVAS_SIZE;
217
+ canvas.height = CANVAS_SIZE;
218
+
219
+ if (!calibration || scaledPoints.length === 0) return;
220
+
221
+ const renderer = new HeatmapRenderer(canvas);
222
+ renderer.setGradient(HEAT_GRADIENT);
223
+ renderer.setRadius(radius, blur);
224
+ renderer.draw(scaledPoints, stampAlpha, opacity);
225
+ }, [scaledPoints, stampAlpha, radius, blur, opacity, calibration]);
226
+
227
+ const hasData = scaledPoints.length > 0;
228
+
229
+ return (
230
+ <div className="dak-heatmap-wrap" aria-label={`${map.name} heatmap`}>
231
+ {/* ── Filter toolbar (outside the radar image) ── */}
232
+ <div className="dak-heatmap-filters">
233
+ <div className="dak-heatmap-controls" role="radiogroup" aria-label="热力图类型">
234
+ {(Object.keys(MODE_LABELS) as HeatmapMode[]).map((kind) => (
235
+ <button
236
+ key={kind}
237
+ type="button"
238
+ role="radio"
239
+ aria-checked={mode === kind}
240
+ className={mode === kind ? "dak-mode dak-mode-active" : "dak-mode"}
241
+ onClick={() => setMode(kind)}
242
+ >
243
+ {MODE_LABELS[kind]} <span>{counts[kind]}</span>
244
+ </button>
245
+ ))}
246
+ <div className="dak-heatmap-side-filter" role="radiogroup" aria-label="阵营">
247
+ {(["all", "ct", "t"] as const).map((s) => (
248
+ <button
249
+ key={s}
250
+ type="button"
251
+ role="radio"
252
+ aria-checked={sideFilter === s}
253
+ className={sideFilter === s ? "dak-sf-chip dak-sf-chip-active" : "dak-sf-chip"}
254
+ onClick={() => setSideFilter(s)}
255
+ >
256
+ {s === "all" ? "全部" : s.toUpperCase()}
257
+ </button>
258
+ ))}
259
+ </div>
260
+ </div>
261
+
262
+ {mode === "grenade" && (
263
+ <div className="dak-heatmap-grenade-filter" role="radiogroup" aria-label="道具类型">
264
+ {GRENADE_FILTER_OPTIONS.map(({ value, label }) => (
265
+ <button
266
+ key={value}
267
+ type="button"
268
+ role="radio"
269
+ aria-checked={grenadeFilter === value}
270
+ className={grenadeFilter === value ? "dak-gf-chip dak-gf-chip-active" : "dak-gf-chip"}
271
+ onClick={() => setGrenadeFilter(value)}
272
+ >
273
+ {label}
274
+ </button>
275
+ ))}
276
+ </div>
277
+ )}
278
+
279
+ {players && players.length > 0 && (
280
+ <div className="dak-heatmap-player-filter" aria-label="按选手筛选">
281
+ <button
282
+ type="button"
283
+ className={selectedSteamIds.size === 0 ? "dak-pf-chip dak-pf-all dak-pf-chip-active" : "dak-pf-chip dak-pf-all"}
284
+ onClick={() => setSelectedSteamIds(new Set())}
285
+ >
286
+ 全部选手
287
+ </button>
288
+ {players.map((p) => (
289
+ <button
290
+ key={p.steamId64}
291
+ type="button"
292
+ className={[
293
+ "dak-pf-chip",
294
+ `dak-pf-chip-${p.teamKey}`,
295
+ selectedSteamIds.has(p.steamId64) ? "dak-pf-chip-active" : "",
296
+ ].join(" ").trim()}
297
+ onClick={() => togglePlayer(p.steamId64)}
298
+ >
299
+ {p.name}
300
+ </button>
301
+ ))}
302
+ </div>
303
+ )}
304
+ </div>
305
+
306
+ {/* ── Radar image + canvas ── */}
307
+ <div
308
+ className="dak-heatmap"
309
+ style={map.radarImageUrl ? { backgroundImage: `url(${map.radarImageUrl})` } : undefined}
310
+ >
311
+ {!map.radarImageUrl && <SchematicRadar mapName={map.name} />}
312
+ <canvas ref={canvasRef} className="dak-heatmap-canvas" aria-hidden="true" />
313
+ {!calibration && <div className="dak-heatmap-empty">该地图暂无雷达标定</div>}
314
+ {calibration && !hasData && <div className="dak-heatmap-empty">当前筛选条件下暂无位置数据</div>}
315
+ <div className="dak-heatmap-legend">
316
+ <span>
317
+ <i style={{ background: LEGEND_COLORS[mode] }} />
318
+ {MODE_LABELS[mode]}
319
+ {scaledPoints.length > 0 && <small> · {scaledPoints.length} 个事件</small>}
320
+ </span>
321
+ <span className="dak-heatmap-legend-scale">
322
+ <i className="dak-legend-cold" />冷
323
+ <i className="dak-legend-warm" />热
324
+ </span>
325
+ </div>
326
+ </div>
327
+
328
+ {/* ── Tuning sliders (outside the radar image) ── */}
329
+ <div className="dak-heatmap-tuning" aria-label="热力图参数">
330
+ <label>半径 <input type="range" min={10} max={60} value={radius} onChange={(e) => setRadius(Number(e.target.value))} /></label>
331
+ <label>虚化 <input type="range" min={2} max={30} value={blur} onChange={(e) => setBlur(Number(e.target.value))} /></label>
332
+ <label>强度 <input type="range" min={20} max={100} value={Math.round(opacity * 100)} onChange={(e) => setOpacity(Number(e.target.value) / 100)} /></label>
333
+ </div>
334
+ </div>
335
+ );
336
+ }
337
+
338
+ function SchematicRadar({ mapName }: { mapName: string }) {
339
+ return (
340
+ <div className="dak-radar-schematic" aria-hidden="true">
341
+ <div className="dak-radar-grid" />
342
+ <span className="dak-site dak-site-a">A</span>
343
+ <span className="dak-site dak-site-b">B</span>
344
+ <span className="dak-radar-label dak-radar-label-mid">MID</span>
345
+ <span className="dak-radar-label dak-radar-label-name">{mapName}</span>
346
+ <span className="dak-lane dak-lane-main" />
347
+ <span className="dak-lane dak-lane-side" />
348
+ <span className="dak-lane dak-lane-mid" />
349
+ </div>
350
+ );
351
+ }
@@ -0,0 +1,42 @@
1
+ import { displayWeaponName } from "@cs2dak/core";
2
+ import type { WorkspaceKillEvent } from "@cs2dak/contract";
3
+
4
+ export interface KillFeedProps {
5
+ kills: WorkspaceKillEvent[];
6
+ currentTick: number;
7
+ /** Ticks per second — used to compute the fade window. */
8
+ tickrate: number | null;
9
+ }
10
+
11
+ const WINDOW_SECONDS = 8;
12
+
13
+ export function KillFeed({ kills, currentTick, tickrate }: KillFeedProps) {
14
+ const windowTicks = WINDOW_SECONDS * (tickrate ?? 64);
15
+ const visible = kills
16
+ .filter((k) => k.tick <= currentTick && k.tick > currentTick - windowTicks)
17
+ .slice(-5)
18
+ .reverse();
19
+
20
+ if (visible.length === 0) return null;
21
+
22
+ return (
23
+ <div className="dak-killfeed" aria-label="Kill feed">
24
+ {visible.map((k) => (
25
+ <div key={k.id} className="dak-killfeed-row">
26
+ <span className={`dak-killfeed-name dak-killfeed-name-${k.killerTeamKey ?? "none"}`}>
27
+ {k.killerName ?? "环境"}
28
+ </span>
29
+ <span className="dak-killfeed-weapon">{displayWeaponName(k.weapon)}</span>
30
+ <span className="dak-killfeed-victim">{k.victimName}</span>
31
+ <span className="dak-killfeed-badges">
32
+ {k.headshot && <b className="dak-kf-badge dak-kf-hs">HS</b>}
33
+ {k.tradeKill && <b className="dak-kf-badge dak-kf-tr">TR</b>}
34
+ {k.throughSmoke && <b className="dak-kf-badge dak-kf-smk">SMK</b>}
35
+ {k.noScope && <b className="dak-kf-badge dak-kf-ns">NS</b>}
36
+ {k.flashAssist && <b className="dak-kf-badge dak-kf-fa">FA</b>}
37
+ </span>
38
+ </div>
39
+ ))}
40
+ </div>
41
+ );
42
+ }
@@ -0,0 +1,110 @@
1
+ import React from "react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { renderToStaticMarkup } from "react-dom/server";
4
+ import type { MatchWorkspaceModel } from "@cs2dak/contract";
5
+ import { MatchWorkspace } from "./MatchWorkspace";
6
+ import { RoundTimeline } from "./RoundTimeline";
7
+
8
+ const model: MatchWorkspaceModel = {
9
+ version: "cs2-demo-analysis-kit/workspace-0.1",
10
+ sourceSchemaVersion: "cs2-demo-format/2.0",
11
+ title: "Team A vs Team B",
12
+ subtitle: "de_ancient · 10 名选手 · 21 回合",
13
+ scoreline: "13:8",
14
+ mapName: "de_ancient",
15
+ teams: {
16
+ teamA: { name: "Team A", score: 13 },
17
+ teamB: { name: "Team B", score: 8 }
18
+ },
19
+ tabs: [
20
+ { key: "overview", label: "总览" },
21
+ { key: "rounds", label: "回合" },
22
+ { key: "players", label: "选手" },
23
+ { key: "economy", label: "经济" },
24
+ { key: "map", label: "地图" },
25
+ { key: "replay", label: "回放" }
26
+ ],
27
+ overview: {
28
+ kpis: [
29
+ { key: "topRR", label: "最高 V2 RR", value: "1.234", detail: "Player A" }
30
+ ],
31
+ story: ["Team A 与 Team B 在 de_ancient 打成 13:8。"]
32
+ },
33
+ scoreboard: [],
34
+ rounds: [],
35
+ players: [],
36
+ economy: [],
37
+ map: {
38
+ view: { name: "de_ancient", radarImageUrl: "/maps/radars/de_ancient.png", calibrated: true },
39
+ modes: [],
40
+ points: [],
41
+ status: { hasRadar: true, hasPositionData: false, message: "该导出包暂无可展示的位置数据" }
42
+ },
43
+ replay: {
44
+ available: true,
45
+ sampleRate: 8,
46
+ tickrate: 64,
47
+ rounds: [
48
+ {
49
+ roundNumber: 1,
50
+ startTick: 100,
51
+ tickStep: 8,
52
+ frameCount: 1,
53
+ kills: [],
54
+ players: [
55
+ {
56
+ steamId64: "76561198000000001",
57
+ name: "Player A",
58
+ teamKey: "teamA",
59
+ side: "ct",
60
+ frames: [
61
+ { tick: 100, x: 1, y: 2, z: 3, yaw: 90, hp: 100, weapon: "ak47", alive: true, flashed: false, hasDefuseKit: true }
62
+ ]
63
+ }
64
+ ]
65
+ }
66
+ ],
67
+ capabilities: { hasDefuseKit: true, hasBombPosition: false }
68
+ },
69
+ adminQa: {
70
+ ok: true,
71
+ summary: { issueCount: 0, errorCount: 0, warningCount: 0 },
72
+ issues: []
73
+ }
74
+ };
75
+
76
+ describe("MatchWorkspace", () => {
77
+ it("renders user-facing match workspace modules without exposing QA as a main tab", () => {
78
+ const html = renderToStaticMarkup(React.createElement(MatchWorkspace, { model }));
79
+
80
+ expect(html).toContain("Team A vs Team B");
81
+ expect(html).toContain("最高 V2 RR");
82
+ expect(html).toContain("回放");
83
+ expect(html).toContain("8 Hz");
84
+ expect(html).toContain("拆弹器");
85
+ expect(html).not.toContain("QA</span>");
86
+ expect(html).not.toContain("pr1maly");
87
+ expect(html).not.toContain("AWPy");
88
+ expect(html).not.toContain("CS Demo Manager");
89
+ });
90
+
91
+ it("renders truncated timelines with an explicit expand control", () => {
92
+ const events = Array.from({ length: 3 }, (_, index) => ({
93
+ id: `event-${index}`,
94
+ roundNumber: 1,
95
+ tick: 100 + index,
96
+ timeSeconds: index,
97
+ clockPhase: "round" as const,
98
+ clockSeconds: index,
99
+ clockLabel: `1:${index}`,
100
+ type: "kill" as const,
101
+ label: `event ${index}`,
102
+ teamKey: null
103
+ }));
104
+
105
+ const html = renderToStaticMarkup(React.createElement(RoundTimeline, { events, initialLimit: 2 }));
106
+
107
+ expect(html).toContain("展开剩余 1 条事件");
108
+ expect(html).toContain("<button");
109
+ });
110
+ });