Send user trajectories from the user list into a focused live map

- Open 「用户轨迹」 straight on the live map instead of a drawer, carrying the
  server, the focused user and a start/end window through the plugin page hash.
- Default that window to one hour before the user's last seen time and keep the
  plugin page query available to bundles through the host context.
- Replace the time range preset gate with always visible start/end datetime
  fields plus a quick-range select, so the user list rebuilds from the window.
- Drop the bulky trajectory list and draw one compact colour legend under the
  map, keeping a stable colour per identity and restricting vehicle tracks to
  the vehicles the selected users actually rode.
This commit is contained in:
npc0-hue
2026-09-16 19:35:31 +08:00
parent 6488f6b31d
commit bc45188be9
13 changed files with 220 additions and 74 deletions
@@ -27,7 +27,7 @@ type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
type ScumMapLayer = "terrain" | "topo" | "night";
type CatalogPickTarget = "gift-items" | "produce-item";
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory";
type PlayerPanelKind = "closed" | "gifts" | "items" | "history";
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
type MapViewState = { zoom: number; x: number; y: number };
type MapPlaybackState = { playing: boolean; value: number; speed: number };
@@ -50,9 +50,11 @@ const scumMapSegmentGapMs = 15 * 60 * 1000;
const scumMapSegmentJump = 52000;
const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 };
const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 };
const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]];
const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["custom", "自定义起止"]];
const scumMapPresetMs: Record<string, number> = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 };
const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"];
const scumMapTrackColorCache = new Map<string, string>();
const scumMapDefaultWindowMs = 60 * 60 * 1000;
const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false };
export type ReactLike = {
@@ -65,7 +67,9 @@ export type ReactLike = {
export type SCUMPageContext = {
pageKey?: string;
pageTitle?: string;
pluginId?: string;
serverInstanceId?: string;
pageQuery?: Record<string, string>;
permissions: string[];
availability: { available: boolean; reason?: string };
featureAvailability?: Array<{ key: string; available: boolean; reason?: string }>;
@@ -77,6 +81,10 @@ type ActionState = { status: "idle" | "pending" | "ok" | "error"; message?: stri
export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) {
const e = react.createElement;
const pageQuery = input.pageQuery ?? {};
const focusUser = pageQuery.focus ?? "";
const focusKey = [input.pageKey ?? "players", focusUser, pageQuery.from ?? "", pageQuery.to ?? ""].join("|");
const defaultMapRange = mapQueryRange(pageQuery.from, pageQuery.to);
const [state, setState] = usePluginState<DataState>(react, { status: "loading" });
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, "");
@@ -122,10 +130,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView);
const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all");
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, "");
const [mapTimeTo, setMapTimeTo] = usePluginState(react, "");
const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, undefined);
const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h");
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from));
const [mapTimeTo, setMapTimeTo] = usePluginState(react, localDateTimeValue(defaultMapRange.to));
const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, focusUser ? [focusUser] : undefined);
const [mapTrackKey, setMapTrackKey] = usePluginState(react, "");
const [mapPlayback, setMapPlayback] = usePluginState<MapPlaybackState>(react, scumMapPlaybackIdle);
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null };
@@ -179,6 +187,24 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90);
return () => { clearInterval(timer); };
}, [mapPlayback.playing, setMapPlayback]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map") return;
if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; }
const range = mapQueryRange(pageQuery.from, pageQuery.to);
setMapUsers([focusUser]);
setMapTimePreset("custom");
setMapTimeFrom(localDateTimeValue(range.from));
setMapTimeTo(localDateTimeValue(range.to));
}, [pageKey, focusKey]);
if (react.useEffect) react.useEffect(() => {
if (pageKey !== "live-map" || !focusUser || state.status !== "ready") return;
const node = mapViewportRef.current;
if (!node) return;
const settings = state.data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? state.data.mapSettings[0];
const range = mapQueryRange(pageQuery.from, pageQuery.to);
const points = mapTrackPointList(mapTracks(state.data, resolveMapBounds(settings), { from: range.from, to: range.to, label: "" }, [focusUser], mapVehicleClassIndex(state.data)));
if (points.length) setMapView(fitMapView(points, node, 0.8));
}, [pageKey, focusKey, state.status]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
@@ -274,28 +300,46 @@ function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index:
e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")),
e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)),
e("td", null, userDateField(player, "lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open))
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, input, open))
);
}
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) {
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, open: (kind: PlayerPanelKind) => void) {
return e("details", { className: "inline-action-control" },
e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"),
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" },
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹")
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => openPlayerTrajectoryOnMap(player, input) }, "用户轨迹")
)
);
}
function openPlayerTrajectoryOnMap(player: RecordMap, input: SCUMPageContext) {
if (!input.serverInstanceId || typeof window === "undefined") return;
const identity = textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey");
if (!identity) return;
const to = Date.now();
const from = (playerLastSeenAt(player) ?? to) - scumMapDefaultWindowMs;
const query = new URLSearchParams({ serverInstanceId: input.serverInstanceId, focus: identity, from: new Date(from).toISOString(), to: new Date(to).toISOString() });
window.location.hash = `#/plugin-pages/${encodeURIComponent(input.pluginId ?? "game.scum")}/live-map?${query.toString()}`;
}
function playerLastSeenAt(player: RecordMap): number | undefined {
for (const key of ["lastSeenAt", "lastLoginTime", "lastLoginAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt"]) {
const stamp = Date.parse(textField(player, key));
if (!Number.isNaN(stamp)) return stamp;
}
return undefined;
}
function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const kind = view.playerPanel.kind;
const name = textField(player, "displayName", "playerName", "name") || playerKey(player);
const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" });
const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player) : playerTrajectoryPanel(e, player, data);
const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹";
const content = kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : playerHistoryPanel(e, player);
const title = kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : "登录历史";
return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close },
e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() },
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
@@ -343,11 +387,6 @@ function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap) {
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录状态来自平台 scum_user 表,由 Run 推送的登录事实维护。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: `history-${index}`, className: "console-row" }, e("span", null, row.label), e("strong", null, dateField(row as RecordMap, "at")), e("strong", null, row.meta))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
}
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.trajectories, player).filter((row) => layerOf(row) === "players" && hasCoordinates(positionOf(row))).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 120);
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹来自平台 scum_user_trajectory 表;乘车状态使用轨迹记录声明的载具。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => { const ride = textField(row, "riddenVehicleId", "gameVehicleId"); return e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "sampledAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", { className: "scum-trajectory-vehicle" }, trajectoryVehicleIcon(row) ? e("img", { className: "scum-catalog-icon", src: trajectoryVehicleIcon(row), alt: "" }) : null, ride ? `乘坐 ${ride}` : textField(row, "source") || "平台轨迹表")); }) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
}
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); }
function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); }
@@ -575,7 +614,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones"));
const window = mapTimeWindow(view);
const catalog = mapVehicleClassIndex(data);
const tracks = mapTracks(data, bounds, window, view, catalog);
const users = mapTrackUsers(data, window);
const playerLabels = new Map<string, string>();
for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); }
const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog);
const span = mapPlaybackSpan(window, tracks);
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
const zoom = view.mapView.zoom;
@@ -585,10 +627,10 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : [];
return e("div", { className: "console-record-list scum-map-surface" },
e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" },
labeledField(e, "时间", e("select", { value: view.mapTimePreset, "aria-label": "轨迹时间范围", onChange: (event: InputEvent) => view.setMapTimePreset(inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))),
view.mapTimePreset === "custom" ? labeledField(e, "", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹始时间", onChange: (event: InputEvent) => view.setMapTimeFrom(inputValue(event)) })) : null,
view.mapTimePreset === "custom" ? labeledField(e, "", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => view.setMapTimeTo(inputValue(event)) })) : null,
mapUserMenu(e, view, mapTrackUsers(data, window)),
labeledField(e, "快捷", e("select", { value: view.mapTimePreset, "aria-label": "轨迹快捷时间", onChange: (event: InputEvent) => applyMapTimePreset(view.setMapTimePreset, view.setMapTimeFrom, view.setMapTimeTo, inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))),
labeledField(e, "开始", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹始时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeFrom(inputValue(event)); } })),
labeledField(e, "结束", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => { view.setMapTimePreset("custom"); view.setMapTimeTo(inputValue(event)); } })),
mapUserMenu(e, view, users, playerLabels),
mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }),
labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))),
e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`)
@@ -615,7 +657,7 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
)
),
mapPlaybackBar(e, view, span, tracks.length > 0),
mapTrackList(e, view, tracks, catalog),
mapTrackLegend(e, view, tracks, playerLabels),
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
);
}
@@ -627,9 +669,10 @@ function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index:
return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : "");
}
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>) {
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map<string, string>) {
const selected = view.mapUsers;
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${users.length}` : "未选";
const rows = [...users, ...(selected ?? []).filter((key) => !users.some((user) => user.key === key)).map((key) => ({ key, label: labels.get(key) || key, count: 0 }))];
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${rows.length}` : "未选";
return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" },
e("summary", { className: "icon-command" }, `用户 ${summary}`),
e("div", { className: "scum-map-menu-body" },
@@ -638,11 +681,25 @@ function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Arra
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"),
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限")
),
users.length ? users.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。")
e("p", { className: "page-status" }, "列出所选起止时间内有轨迹的用户,颜色与地图轨迹一致。"),
rows.length ? rows.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), e("span", { className: "map-track-swatch", style: { background: mapTrackColor(user.key) } }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。")
)
);
}
function mapTrackLegend(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], labels: Map<string, string>) {
const selected = view.mapUsers;
const empty = selected && selected.length ? `${selected.map((key) => labels.get(key) || key).join("、")} 在当前时间范围内没有轨迹记录,可放宽快捷时间范围。` : "当前时间范围内没有轨迹。";
return e("div", { className: "map-track-legend", role: "group", "aria-label": "轨迹颜色图例", title: "轨迹来自平台 scum_user_trajectory 与 scum_vehicle_trajectory 表;虚线为步行,实线为乘车。" },
e("span", { className: "page-status" }, tracks.length ? `${tracks.length} 条轨迹 · 虚线 步行 · 实线 乘车 · 点击色块可定位` : empty),
tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "map-track-chip is-selected" : "map-track-chip", title: `${track.label} ${timeLabel(track.from)}${timeLabel(track.to)}`, onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
e("span", { className: "map-track-swatch", style: { background: track.color } }),
e("strong", null, track.label),
e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)
))
);
}
function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) {
const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[];
const enabled = layers.filter((layer) => view.mapLayers[layer]).length;
@@ -672,17 +729,6 @@ function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: {
);
}
function mapTrackList(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], catalog: Map<string, string>) {
return e("article", { className: "console-module scum-map-tracks" },
e("div", { className: "panel-header" }, e("h2", null, "轨迹列表"), e("span", { className: "page-status" }, `${tracks.length} 条 · 虚线 步行 · 实线 乘车 · 色块对应用户`)),
tracks.length ? e("div", { className: "console-row-list" }, tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "console-row console-row-button is-selected" : "console-row console-row-button", onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
e("span", null, e("span", { className: "map-track-swatch", style: { background: track.color } }), e("strong", null, track.label), e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)),
e("strong", null, `${timeLabel(track.from)}${timeLabel(track.to)}`),
e("span", { className: "scum-catalog-icons" }, track.vehicles.slice(0, 3).map((code, index) => { const icon = scumVehicleIconUrl(catalog.get(code) ?? code); return icon ? e("img", { key: `${code}-${index}`, className: "scum-catalog-icon", src: icon, alt: "" }) : null; }))
))) : e("p", { className: "page-status" }, "当前时间范围内的轨迹记录为空。")
);
}
function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) {
const shapes: unknown[] = [];
for (const track of tracks) for (const segment of mapTrackSegments(track)) {
@@ -709,7 +755,7 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
if (!visible.length) continue;
const middle = visible[Math.floor(visible.length / 2)];
const code = mapRideCode(middle.row) || track.label;
const icon = scumVehicleIconUrl(catalog.get(code) ?? code);
const icon = trajectoryVehicleIcon(middle.row) || scumVehicleIconUrl(catalog.get(code) ?? code);
placed += 1;
markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "" }) : "车"));
}
@@ -720,35 +766,58 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
function mapSceneStyle(point: MapTrackPoint): Record<string, string> { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; }
function mapTimeWindow(view: ViewState): MapTimeWindow {
if (view.mapTimePreset === "custom") {
const from = Date.parse(view.mapTimeFrom);
const to = Date.parse(view.mapTimeTo);
const safeFrom = Number.isNaN(from) ? -Infinity : from;
const safeTo = Number.isNaN(to) ? Infinity : to;
return { from: safeFrom, to: safeTo, label: `${timeLabel(safeFrom)} ${timeLabel(safeTo)}` };
}
const span = scumMapPresetMs[view.mapTimePreset];
if (!span) return { from: -Infinity, to: Infinity, label: "全部时段" };
const to = Date.now();
const label = scumMapTimePresets.find(([key]) => key === view.mapTimePreset)?.[1] ?? "最近";
return { from: to - span, to, label };
const from = parseTimeInput(view.mapTimeFrom);
const to = parseTimeInput(view.mapTimeTo);
if (from === undefined && to === undefined) return { from: -Infinity, to: Infinity, label: "全部时段" };
const lower = from ?? -Infinity;
const upper = to ?? Infinity;
return { from: lower, to: upper, label: `${shortTimeLabel(lower)} ${shortTimeLabel(upper)}` };
}
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, view: ViewState, catalog: Map<string, string>): MapTrack[] {
function parseTimeInput(value: string | undefined): number | undefined {
const stamp = Date.parse(value ?? "");
return Number.isNaN(stamp) ? undefined : stamp;
}
function mapQueryRange(from: string | undefined, to: string | undefined): { from: number; to: number } {
const upper = parseTimeInput(to) ?? Date.now();
return { from: parseTimeInput(from) ?? upper - scumMapDefaultWindowMs, to: upper };
}
function localDateTimeValue(value: number): string {
const date = new Date(value);
const pad = (part: number) => String(part).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function applyMapTimePreset(setPreset: StateSetter<string>, setFrom: StateSetter<string>, setTo: StateSetter<string>, preset: string) {
setPreset(preset);
const span = scumMapPresetMs[preset];
if (span) { const now = Date.now(); setFrom(localDateTimeValue(now - span)); setTo(localDateTimeValue(now)); return; }
if (preset === "all") { setFrom(""); setTo(""); }
}
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map<string, string>): MapTrack[] {
const grouped = new Map<string, MapTrack>();
for (const row of data.trajectories) {
const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; });
const riddenVehicles = new Set<string>();
if (users !== undefined) for (const row of windowRows) {
if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue;
const vehicle = mapRiddenVehicleId(row);
if (vehicle) riddenVehicles.add(vehicle);
}
for (const row of windowRows) {
const position = positionOf(row);
if (!hasCoordinates(position)) continue;
const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player";
const identity = kind === "player" ? textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id") : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
const identity = kind === "player" ? mapPlayerTrackIdentity(row) : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
if (!identity) continue;
if (kind === "player" && view.mapUsers !== undefined && !view.mapUsers.includes(identity)) continue;
if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue;
const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue;
const scene = mapScenePoint(row, bounds);
const rideCode = mapRideCode(row);
const key = `${kind}:${identity}`;
const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: scumMapTrackColors[stableIndex(identity, scumMapTrackColors.length)], points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] };
const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: mapTrackColor(identity), points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] };
track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) });
if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode);
track.from = Math.min(track.from, time);
@@ -766,11 +835,14 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit);
}
function mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); }
function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "riddenVehicleId", "gameVehicleId", "vehicleId"); }
function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> {
const users = new Map<string, { key: string; label: string; count: number }>();
for (const row of data.trajectories) {
if (layerOf(row) === "vehicles") continue;
const identity = textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id");
const identity = mapPlayerTrackIdentity(row);
if (!identity) continue;
const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue;
@@ -855,9 +927,17 @@ function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding:
function mapBaseLayerValue(value: string): ScumMapLayer { return value === "topo" || value === "night" ? value : "terrain"; }
function stableIndex(value: string, length: number): number { let hash = 0; for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) % 1000003; return hash % length; }
function mapTrackColor(identity: string): string {
const cached = scumMapTrackColorCache.get(identity);
if (cached) return cached;
const index = scumMapTrackColorCache.size;
const color = index < scumMapTrackColors.length ? scumMapTrackColors[index] : `hsl(${(index * 137) % 360} 72% 62%)`;
scumMapTrackColorCache.set(identity, color);
return color;
}
function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); }
function shortTimeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); if (Number.isNaN(date.getTime())) return "unknown"; const pad = (part: number) => String(part).padStart(2, "0"); return `${date.getMonth() + 1}/${date.getDate()} ${pad(date.getHours())}:${pad(date.getMinutes())}`; }
function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) {
const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : "";