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:
@@ -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) : "";
|
||||
|
||||
Reference in New Issue
Block a user