feat(scum): add map trajectory projection
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
import { CarFront, MapPinned, RefreshCw, UserRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GameMapTrajectoryEntityResponse, GameMapTrajectoryResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; value: GameMapTrajectoryResponse };
|
||||
|
||||
export function ScumMapTrajectoryPanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" }); const [hours, setHours] = useState("1"); const [playerIds, setPlayerIds] = useState(""); const [vehicleIds, setVehicleIds] = useState(""); const [detail, setDetail] = useState("");
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); const to = new Date(); const from = new Date(to.valueOf() - Math.max(1, Math.min(24, Number(hours) || 1)) * 3600000); try { setState({ status: "ready", value: await platformApiClient.getGameMapTrajectories(serverInstanceId, { from: from.toISOString(), to: to.toISOString(), playerIds: ids(playerIds), vehicleIds: ids(vehicleIds) }) }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "地图轨迹读取失败。" }); } }, [hours, playerIds, serverInstanceId, vehicleIds]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
async function openPlayer(entity: GameMapTrajectoryEntityResponse) { if (!entity.gamePlayerRecordId) return; try { const profile = await platformApiClient.getGamePlayerProfile(serverInstanceId, entity.gamePlayerRecordId); setDetail(`玩家详情:${profile.player.displayName}(${profile.player.gamePlayerId}),别名 ${profile.aliases.length} 条,会话 ${profile.sessions.length} 条。`); } catch (error) { setDetail(error instanceof Error ? error.message : "玩家详情不可用。"); } }
|
||||
if (state.status === "loading") return <LoadingState label="正在读取 SCUM 安全地图投影…" />; if (state.status === "error") return <ErrorState title="地图轨迹不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const value = state.value; return <section className="console-panel scum-map-panel" aria-label="SCUM 玩家与车辆地图轨迹"><div className="panel-header"><div><h2><MapPinned size={16} /> SCUM 玩家与车辆地图轨迹</h2><p className="provider-id">仅显示插件声明坐标转换后的安全投影;数据来源与采集时间始终可解释。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-row-actions"><label>时间窗<select value={hours} onChange={(event) => setHours(event.target.value)}><option value="1">近 1 小时</option><option value="6">近 6 小时</option><option value="24">近 24 小时</option></select></label><label>玩家记录 ID<input value={playerIds} onChange={(event) => setPlayerIds(event.target.value)} placeholder="逗号分隔" /></label><label>车辆 ID<input value={vehicleIds} onChange={(event) => setVehicleIds(event.target.value)} placeholder="逗号分隔" /></label><button type="button" className="command-button" onClick={() => void load()}>应用筛选</button></div>{value.status === "missing-map" ? <p className="page-status">缺失地图声明:{value.reason}</p> : <><p className="page-status">地图 {value.map?.mapId} · 版本 {value.map?.mapVersion} · 精度 {value.map?.precision};窗口 {formatTime(value.from)} 至 {formatTime(value.to)}</p>{value.status === "empty" ? <p className="page-status">{value.reason}</p> : <TrajectoryMap players={value.players} vehicles={value.vehicles} onPlayer={openPlayer} onVehicle={(entity) => setDetail(`车辆详情上下文:${entity.entityId}。此链接只使用服务器内安全车辆标识,不查询原始存储。`)} />}{value.rideSegments.length > 0 && <div className="console-record-list">{value.rideSegments.map((segment) => <div className="console-record" key={`${segment.gamePlayerRecordId}-${segment.vehicleId}-${segment.startedAt}`}><strong>乘车区段</strong><span>玩家 {segment.gamePlayerRecordId} · 车辆 {segment.vehicleId}</span><small>{formatTime(segment.startedAt)} → {segment.endedAt ? formatTime(segment.endedAt) : "采集窗口内仍在车上"}</small></div>)}</div>}</>}{detail && <p className="page-status">{detail}</p>}</section>;
|
||||
}
|
||||
|
||||
export function trajectoryPath(points: { mapX: number; mapY: number }[]) { return points.map((point, index) => `${index ? "L" : "M"}${point.mapX},${1000 - point.mapY}`).join(" "); }
|
||||
function TrajectoryMap({ players, vehicles, onPlayer, onVehicle }: { players: GameMapTrajectoryEntityResponse[]; vehicles: GameMapTrajectoryEntityResponse[]; onPlayer: (entity: GameMapTrajectoryEntityResponse) => void; onVehicle: (entity: GameMapTrajectoryEntityResponse) => void }) { const entities = useMemo(() => [...players, ...vehicles], [players, vehicles]); return <><div className="scum-map-canvas" role="img" aria-label="已筛选的玩家和车辆地图轨迹"><svg viewBox="0 0 1000 1000" preserveAspectRatio="xMidYMid meet">{entities.map((entity) => <g key={`${entity.kind}-${entity.entityId}`}><path className={entity.kind === "player" ? "scum-map-player-line" : "scum-map-vehicle-line"} d={trajectoryPath(entity.points)} />{entity.points.map((point, index) => <circle key={`${point.occurredAt}-${index}`} className={entity.kind === "player" ? "scum-map-player-point" : "scum-map-vehicle-point"} cx={point.mapX} cy={1000 - point.mapY} r="7"><title>{`${entity.label} · ${formatTime(point.occurredAt)} · ${point.source}`}</title></circle>)}</g>)}</svg></div><div className="console-record-list">{entities.map((entity) => <div className="console-record" key={`${entity.kind}-${entity.entityId}`}><strong>{entity.kind === "player" ? <UserRound size={15} /> : <CarFront size={15} />} {entity.label}</strong><span>{entity.points.length} 个采样点 · 来源 {entity.sources.join(" / ") || "--"}</span><small>最后采集:{formatTime(entity.collectedAt)}</small><button type="button" className="icon-command" onClick={() => entity.kind === "player" ? onPlayer(entity) : onVehicle(entity)}>{entity.kind === "player" ? <UserRound size={14} /> : <CarFront size={14} />}<span>{entity.kind === "player" ? "进入玩家详情" : "进入车辆上下文"}</span></button></div>)}</div></>; }
|
||||
function ids(value: string) { return value.split(",").map((item) => item.trim()).filter(Boolean).slice(0, 20); }
|
||||
function formatTime(value: string | undefined) { if (!value) return "--"; const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
Reference in New Issue
Block a user