@@ -19,7 +19,7 @@ import {
type SCUMSurfaceData ,
type SCUMWorkspaceActions
} from "./page-data.js" ;
import { scumCatalogEntries , scumCatalogEntryFor , scumCatalogIconUrl , scumVehicleIconUrl , type ScumCatalogEntry } from "./scum-catalog.js" ;
import { scumCatalogEntries , scumCatalogEntryFor , scumCatalogIconUrl , scumVehicleEntry , scumVehicleFallbackIconUrl , scumVehicleIconUrl , type ScumCatalogEntry } from "./scum-catalog.js" ;
type StateSetter < T > = ( next : T | ( ( previous : T ) = > T ) ) = > void ;
type InputEvent = { target ? : { value? : string ; checked? : boolean ; open? : boolean } ; stopPropagation ? : ( ) = > void } ;
@@ -30,9 +30,10 @@ type CatalogPickTarget = "gift-items" | "produce-item";
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 } ;
type MapPlaybackState = { playing : boolean ; value : number ; speed : number ; startedAt : number ; startValue : number } ;
type MapTimeWindow = { from : number ; to : number ; label : string } ;
type MapTrackPoint = { key : string ; row : RecordMap ; x : number ; y : number ; time : number ; riding : boolean } ;
type MapTrackPoint = { key : string ; row : RecordMap ; x : number ; y : number ; time : number ; riding : boolean ; rideCode : string } ;
type MapRideSample = { key : string ; code : string ; time : number ; scene : { x : number ; y : number } } ;
type MapTrackSegment = { key : string ; mode : "walk" | "ride" ; points : MapTrackPoint [ ] } ;
type MapTrack = { key : string ; kind : "player" | "vehicle" ; label : string ; color : string ; points : MapTrackPoint [ ] ; walk : number ; ride : number ; from : number ; to : number ; vehicles : string [ ] } ;
type PointerEventLike = { pointerId? : number ; clientX? : number ; clientY? : number ; button? : number ; currentTarget ? : { setPointerCapture ? : ( pointerId? : number ) = > void ; releasePointerCapture ? : ( pointerId? : number ) = > void } ; preventDefault ? : ( ) = > void ; stopPropagation ? : ( ) = > void } ;
@@ -49,13 +50,19 @@ const scumMapTrackLimit = 24;
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 scumMapPlaybackIdle : MapPlaybackState = { playing : false , value : 1 , speed : 1 , startedAt : 0 , startValue : 1 } ;
// Playback runs on wall-clock time so the 1x / 2x / 4x switch is instant and timer jitter cannot drift.
const scumMapPlaybackDurationMs = 30000 ;
const scumMapRideWindowMs = 10 * 1000 ;
const scumMapRideDistance = 1500 ;
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 } ;
// Latest rendered playback frame; the interval effect reads it to keep the follow camera in sync.
let mapPlaybackFrame : { tracks : MapTrack [ ] ; span : { from : number ; to : number } } | null = null ;
export type ReactLike = {
createElement : ( . . . args : any [ ] ) = > any ;
@@ -129,6 +136,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [ deliveryPlayer , setDeliveryPlayer ] = usePluginState ( react , "" ) ;
const [ mapLayers , setMapLayers ] = usePluginState < Record < MapLayer , boolean > > ( react , { players : true , vehicles : true , flags : true , regions : true , other : true } ) ;
const [ selectedMapPoint , setSelectedMapPoint ] = usePluginState ( react , "" ) ;
const [ hoverMapPoint , setHoverMapPoint ] = usePluginState ( react , "" ) ;
const [ mapView , setMapView ] = usePluginState < MapViewState > ( react , scumMapDefaultView ) ;
const [ mapTimePreset , setMapTimePreset ] = usePluginState ( react , pageQuery . from || pageQuery . to ? "custom" : "1h" ) ;
const [ mapTimeFrom , setMapTimeFrom ] = usePluginState ( react , localDateTimeValue ( defaultMapRange . from ) ) ;
@@ -184,9 +192,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
} , [ pageKey , state . status , mapViewportRef , setMapView ] ) ;
if ( react . useEffect ) react . useEffect ( ( ) = > {
if ( ! mapPlayback . playing ) return ;
const timer = setInterval ( ( ) = > setMapPlayback ( ( previous ) = > advanceMapPlayback ( previous ) ) , 90 ) ;
const timer = setInterval ( ( ) = > {
const next = advanceMapPlayback ( mapPlayback , Date . now ( ) ) ;
setMapPlayback ( next ) ;
const camera = mapPlaybackFrame ? playbackFollowView ( mapPlaybackFrame . tracks , next , mapPlaybackFrame . span , mapViewportRef . current ) : undefined ;
if ( camera ) setMapView ( camera ) ;
} , 90 ) ;
return ( ) = > { clearInterval ( timer ) ; } ;
} , [ mapPlayback . playing , setMapPlayback ] ) ;
} , [ mapPlayback . playing , mapPlayback . startedAt , mapPlayback . startValue , mapPlayback . speed , setMapPlayback , setMapView , mapViewportRef ] ) ;
if ( react . useEffect ) react . useEffect ( ( ) = > {
if ( pageKey !== "live-map" ) return ;
if ( ! focusUser ) { setMapUsers ( undefined ) ; applyMapTimePreset ( setMapTimePreset , setMapTimeFrom , setMapTimeTo , "1h" ) ; return ; }
@@ -218,6 +231,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
produceEventId , setProduceEventId , produceId , setProduceId , produceTradeGoodsId , setProduceTradeGoodsId , producePercent , setProducePercent , produceValue , setProduceValue , produceRadius , setProduceRadius , produceX , setProduceX , produceY , setProduceY , produceZ , setProduceZ ,
giftTab , setGiftTab , giftCode , setGiftCode , giftName , setGiftName , giftItems , setGiftItems , giftCommands , setGiftCommands , giftClass , setGiftClass , giftAudience , setGiftAudience , giftNumber , setGiftNumber , giftAchievement , setGiftAchievement , giftAchievementNumber , setGiftAchievementNumber ,
deliveryGift , setDeliveryGift , deliveryPlayer , setDeliveryPlayer , mapLayers , setMapLayers , selectedMapPoint , setSelectedMapPoint ,
hoverMapPoint , setHoverMapPoint ,
mapView , setMapView , mapViewportRef , mapTimePreset , setMapTimePreset , mapTimeFrom , setMapTimeFrom , mapTimeTo , setMapTimeTo ,
mapUsers , setMapUsers , mapTrackKey , setMapTrackKey , mapPlayback , setMapPlayback ,
eventEditorOpen , setEventEditorOpen , produceEditorOpen , setProduceEditorOpen , giftEditorOpen , setGiftEditorOpen ,
@@ -244,7 +258,7 @@ type ViewState = {
giftClass : string ; setGiftClass : StateSetter < string > ; giftAudience : string ; setGiftAudience : StateSetter < string > ; giftNumber : string ; setGiftNumber : StateSetter < string > ; giftAchievement : string ; setGiftAchievement : StateSetter < string > ; giftAchievementNumber : string ; setGiftAchievementNumber : StateSetter < string > ;
deliveryGift : string ; setDeliveryGift : StateSetter < string > ; deliveryPlayer : string ; setDeliveryPlayer : StateSetter < string > ;
mapLayers : Record < MapLayer , boolean > ; setMapLayers : StateSetter < Record < MapLayer , boolean > > ;
selectedMapPoint : string ; setSelectedMapPoint : StateSetter < string > ; setAction : StateSetter < ActionState > ; refresh : ( ) = > void ;
selectedMapPoint : string ; setSelectedMapPoint : StateSetter < string > ; hoverMapPoint : string ; setHoverMapPoint : StateSetter < string > ; setAction : StateSetter < ActionState > ; refresh : ( ) = > void ;
mapView : MapViewState ; setMapView : StateSetter < MapViewState > ; mapViewportRef : { current : HTMLElement | null } ;
mapTimePreset : string ; setMapTimePreset : StateSetter < string > ; mapTimeFrom : string ; setMapTimeFrom : StateSetter < string > ; mapTimeTo : string ; setMapTimeTo : StateSetter < string > ;
mapUsers : string [ ] | undefined ; setMapUsers : StateSetter < string [ ] | undefined > ; mapTrackKey : string ; setMapTrackKey : StateSetter < string > ; mapPlayback : MapPlaybackState ; setMapPlayback : StateSetter < MapPlaybackState > ;
@@ -619,9 +633,11 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
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 ;
mapPlaybackFrame = { tracks , span } ;
const cursor = playbackCursor ( span , view . mapPlayback ) ;
const zoom = view . mapView . zoom ;
const visible = points . filter ( ( point ) = > view . mapLayers [ layerOf ( point ) ] ) ;
const hovered = visible . find ( ( point , index ) = > idOf ( point , ` point- ${ index } ` ) === view . hoverMapPoint ) ;
const selected = visible . find ( ( point , index ) = > idOf ( point , ` point- ${ index } ` ) === view . selectedMapPoint ) ? ? visible [ 0 ] ;
const selectedTrails = selected ? trajectoryRecordsForPoint ( data . trajectories , selected ) . sort ( ( left , right ) = > trajectoryOrder ( right ) - trajectoryOrder ( left ) ) . slice ( 0 , 8 ) : [ ] ;
const selectedLocks = selected && layerOf ( selected ) === "vehicles" ? vehicleLockRecordsForPoint ( data . vehicleLocks , selected ) . sort ( ( left , right ) = > rowTime ( right , [ "lockedAt" , "createdAt" ] ) - rowTime ( left , [ "lockedAt" , "createdAt" ] ) ) . slice ( 0 , 5 ) : [ ] ;
@@ -645,8 +661,9 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
e ( "div" , { className : "map-scene-image" , "aria-label" : "SCUM 地图图层" , style : mapBoardStyle ( baseLayer , zonesOverlay ) } ) ,
mapGridOverlay ( e ) ,
e ( "svg" , { className : "map-track-layer" , viewBox : ` 0 0 ${ scumMapSize } ${ scumMapSize } ` , preserveAspectRatio : "none" , "aria-hidden" : "true" } , mapTrackShapes ( e , tracks , cursor , 18 / zoom ) ) ,
mapTrackMarkers ( e , tracks , cursor , catalog ) ,
visible . map ( ( point , index ) = > mapPointMarker ( e , point , index , bounds , view ) )
mapTrackMarkers ( e , tracks , cursor , catalog , view . mapPlayback . playing ) ,
visible . map ( ( point , index ) = > mapPointMarker ( e , point , index , bounds , view , data ) ) ,
hovered ? mapHoverCard ( e , hovered , data , mapPointStyle ( hovered , bounds ) ) : null
) ,
e ( "div" , { className : "map-view-controls" } ,
e ( "button" , { type : "button" , className : "icon-command" , "aria-label" : "地图放大" , onClick : ( ) = > view . setMapView ( ( previous ) = > zoomMapView ( previous , 1.25 , 0 , 0 ) ) } , "+ " ) ,
@@ -656,17 +673,91 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
e ( "span" , { className : "map-zoom-badge" } , ` ${ zoom . toFixed ( 2 ) } × ` )
)
) ,
mapPlaybackBar ( e , view , span , tracks . length > 0 ) ,
mapPlaybackBar ( e , view , span , tracks , tracks . length > 0 ) ,
mapTrackLegend ( e , view , tracks , playerLabels ) ,
selected ? mapPointStrip ( e , selected , selectedTrails , selectedLocks ) : null
) ;
}
function mapPointMarker ( e : ReactLike [ "createElement" ] , point : RecordMap , index : number , bounds : RecordMap , view : ViewState ) {
const ride = layerOf ( point ) === "players" ? textField ( point , "riddenVehicleId" , "gameVehicleId" ) : "" ;
const icon = vehicleIconFor ( point ) ;
function mapPointMarker ( e : ReactLike [ "createElement" ] , point : RecordMap , index : number , bounds : RecordMap , view : ViewState , data : SCUMSurfaceData ) {
const layer = layerOf ( point ) ;
const ride = layer === "players" ? textField ( point , "riddenVehicleId" , "gameVehicleId" ) : "" ;
const icon = layer === "vehicles" || ride ? vehicleIconFor ( point ) : "" ;
const key = idOf ( point , ` point- ${ 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 : "" } ) : "" ) ;
const open = ( ) = > view . setHoverMapPoint ( key ) ;
const close = ( ) = > view . setHoverMapPoint ( ( current ) = > ( current === key ? "" : current ) ) ;
return e ( "button" , { key , type : "button" , className : ` map-surface-dot map-layer- ${ layer } ${ ride ? " map-surface-dot-riding" : "" } ` , title : ` ${ pointTitle ( point ) } ${ coords ( point ) } ${ ride ? ` · 乘坐 ${ ride } ` : "" } ` , "aria-label" : pointTitle ( point ) , style : mapPointStyle ( point , bounds ) , onPointerEnter : open , onPointerLeave : close , onFocus : open , onBlur : close , onClick : ( ) = > { if ( ! mapDragState . moved ) view . setSelectedMapPoint ( key ) ; } } ,
icon ? e ( "img" , { src : icon , alt : "" , loading : "lazy" , decoding : "async" } ) : ""
) ;
}
// Hover card: the map is unreadable without knowing whose dot it is and which vehicle it belongs to.
// Every row is driven by data the platform already returns, so a missing field drops the row instead
// of printing a placeholder.
function mapHoverCard ( e : ReactLike [ "createElement" ] , point : RecordMap , data : SCUMSurfaceData , pointStyle : Record < string , string > ) {
const layer = layerOf ( point ) ;
const rows : Array < [ string , string ] > = [ ] ;
const push = ( label : string , value : string ) = > { if ( value ) rows . push ( [ label , value ] ) ; } ;
const pushDate = ( label : string , . . . keys : string [ ] ) = > { if ( textField ( point , . . . keys ) ) push ( label , dateField ( point , . . . keys ) ) ; } ;
if ( layer === "players" ) {
push ( "用户" , textField ( point , "displayName" , "name" , "label" ) || pointTitle ( point ) ) ;
push ( "SteamID" , textField ( point , "steamId" , "gamePlayerId" , "subjectId" ) ) ;
push ( "队伍" , mapSquadLabel ( data , textField ( point , "squadId" , "squadName" ) ) ) ;
push ( "状态" , boolField ( point , "online" ) ? "在线" : "离线" ) ;
pushDate ( "最后出现" , "lastSeenAt" , "lastActivityAt" , "updatedAt" ) ;
} else if ( layer === "vehicles" ) {
push ( "载具" , vehicleLabelFor ( point ) ) ;
push ( "类名" , textField ( point , "className" , "vehicleClass" , "entityClass" , "vehicleType" ) ) ;
push ( "状态" , textField ( point , "status" , "state" ) || ( boolField ( point , "exists" , "existsInGame" ) ? "存在" : "" ) ) ;
push ( "耐久" , vehicleDurabilityLabel ( point ) ) ;
push ( "车锁" , vehicleLockLabel ( point , data ) ) ;
pushDate ( "最后观测" , "vehicleObservedAt" , "lastObservedAt" , "observedAt" , "updatedAt" ) ;
} else if ( layer === "flags" ) {
push ( "领地旗" , textField ( point , "name" , "label" , "flagId" , "id" ) || "领地旗" ) ;
push ( "归属" , mapOwnerLabel ( data , point ) ) ;
push ( "队伍" , mapSquadLabel ( data , textField ( point , "ownerSquadId" , "squadId" ) ) ) ;
push ( "置信度" , textField ( point , "ownershipConfidence" ) ) ;
} else {
push ( "名称" , pointTitle ( point ) ) ;
push ( "类型" , textField ( point , "type" , "subjectType" , "layer" ) ) ;
push ( "来源" , textField ( point , "source" ) ) ;
}
if ( hasCoordinates ( point ) ) push ( "坐标" , coords ( point ) ) ;
return e ( "div" , { className : ` map-hover-card map-hover- ${ layer } ` , role : "tooltip" , style : pointStyle } ,
e ( "strong" , { className : "map-hover-title" } , pointTitle ( point ) ) ,
rows . map ( ( [ label , value ] ) = > e ( "span" , { key : label , className : "map-hover-row" } , e ( "small" , null , label ) , e ( "b" , null , value ) ) )
) ;
}
function mapSquadLabel ( data : SCUMSurfaceData , value : string ) : string {
if ( ! value ) return "" ;
const squad = data . squads . find ( ( row ) = > textField ( row , "squadId" , "id" , "_recordKey" ) === value ) ;
return squad ? ` ${ textField ( squad , "name" , "label" ) || value } ( ${ value } ) ` : value ;
}
function mapOwnerLabel ( data : SCUMSurfaceData , point : RecordMap ) : string {
const steamId = textField ( point , "ownerSteamId" , "steamId" , "ownerProfileId" , "ownerId" ) ;
if ( ! steamId ) return "" ;
const owner = data . players . find ( ( player ) = > [ textField ( player , "steamId" ) , textField ( player , "gamePlayerId" ) , textField ( player , "id" ) ] . includes ( steamId ) ) ;
return owner ? ` ${ textField ( owner , "displayName" , "name" ) || steamId } ( ${ steamId } ) ` : steamId ;
}
function vehicleLockLabel ( point : RecordMap , data : SCUMSurfaceData ) : string {
const locks = vehicleLockRecordsForPoint ( data . vehicleLocks , point ) . sort ( ( left , right ) = > rowTime ( right , [ "lockedAt" , "createdAt" ] ) - rowTime ( left , [ "lockedAt" , "createdAt" ] ) ) ;
const latest = locks [ 0 ] ;
if ( latest ) {
const steamId = textField ( latest , "steamId" , "scumUserId" ) ;
const owner = data . players . find ( ( player ) = > [ textField ( player , "steamId" ) , textField ( player , "gamePlayerId" ) , textField ( player , "id" ) , textField ( player , "scumUserId" ) ] . includes ( steamId ) ) ;
const who = owner ? textField ( owner , "displayName" , "name" ) || steamId : steamId ;
return ` ${ who || "未知玩家" } 上锁 · ${ dateField ( latest , "lockedAt" , "createdAt" ) } ` ;
}
if ( boolField ( point , "locked" ) ) return "已上锁(未记录上锁人)" ;
return textField ( point , "lockState" ) || "未上锁" ;
}
function vehicleDurabilityLabel ( point : RecordMap ) : string {
const value = textField ( point , "durability" , "vehicleDurability" , "health" , "vehicleHealth" ) ;
return value ? ` ${ value } ${ textField ( point , "durabilityMax" , "maxHealth" ) ? ` / ${ textField ( point , "durabilityMax" , "maxHealth" ) } ` : "" } ` : "" ;
}
function mapUserMenu ( e : ReactLike [ "createElement" ] , view : ViewState , users : Array < { key : string ; label : string ; count : number } > , labels : Map < string , string > ) {
@@ -718,18 +809,26 @@ function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undef
runAction ( view . setAction , "正在保存地图图层…" , async ( ) = > { await saveMapSettings ( actions , { baseLayer , buildingZones : zones } ) ; view . refresh ( ) ; return "地图图层已保存。" ; } ) ;
}
function mapPlaybackBar ( e : ReactLike [ "createElement" ] , view : ViewState , span : { from : number ; to : number } , enabled : boolean ) {
const cursor = view . mapPlayback . value >= 1 ? Infinity : span . from + ( span . to - span . from ) * view . mapPlayback . value ;
function mapPlaybackBar ( e : ReactLike [ "createElement" ] , view : ViewState , span : { from : number ; to : number } , tracks : MapTrack [ ] , enabled : boolean ) {
const cursor = playbackCursor ( span , view . mapPlayback ) ;
const playing = view . mapPlayback . playing ;
const start = ( ) = > {
const value = view . mapPlayback . value >= 1 ? 0 : view.mapPlayback.value ;
const next = rebaseMapPlayback ( view . mapPlayback , { playing : true , value } ) ;
view . setMapPlayback ( next ) ;
if ( value === 0 ) { const camera = playbackStartView ( tracks , view . mapViewportRef . current ) ; if ( camera ) view . setMapView ( camera ) ; }
} ;
return e ( "div" , { className : "map-playback-bar" } ,
e ( "details" , { className : "scum-map-menu" , "aria-label" : "轨迹回放" } ,
e ( "summary" , { className : "icon-command" } , view . mapPlayback . playing ? "回放 播放中" : "回放" ) ,
e ( "summary" , { className : "icon-command" } , playing ? "回放 播放中" : "回放" ) ,
e ( "div" , { className : "scum-map-menu-body" } ,
e ( "p" , { className : "page-status" } , Number . isFinite ( cursor ) ? ` 回放位置 ${ timeLabel ( cursor ) } ` : "回放位置 全部" ) ,
e ( "div" , { className : "console-row-actions" } ,
e ( "button" , { type : "button" , className : view.mapPlayback.playing ? "primary-command" : "icon-command" , disabled : ! enabled , "aria-label" : view . mapPlayback . playing ? "暂停轨迹回放" : "播放轨迹回放" , onClick : ( ) = > view . setMapPlayback ( ( previous ) = > ( { . . . previous , playing : ! previous . playing , value : ! previous . playing && previous . value >= 1 ? 0 : previous.value } ) ) } , view . mapPlayback . playing ? "暂停" : "回放 " ) ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! enabled , onClick : ( ) = > view . setMapPlayback ( scumMapPlaybackIdle ) } , "回到最新" )
e ( "button" , { type : "button" , className : playing ? "primary-command" : "icon-command" , disabled : ! enabled , "aria-label" : playing ? "暂停轨迹回放" : "播放轨迹回放" , onClick : ( ) = > { if ( playing ) view . setMapPlayback ( rebaseMapPlayback ( view . mapPlayback , { playing : false } ) ) ; else start ( ) ; } } , playing ? "暂停" : "开始 " ) ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! view . mapPlayback . playing && view . mapPlayback . value >= 1 , onClick : ( ) = > view . setMapPlayback ( scumMapPlaybackIdle ) } , "回到最新" )
) ,
labeledField ( e , "速度" , e ( "select" , { value : String ( view . mapPlayback . speed ) , "aria-label" : "轨迹回放速度" , onChange : ( event : InputEvent ) = > view . setMapPlayback ( ( previous ) = > ( { . . . previous , speed : Number ( inputValue ( event ) ) || 1 } ) ) } , [ 1 , 2 , 4 ] . map ( ( speed ) = > e ( "option" , { key : speed , value : String ( speed ) } , ` ${ speed } x ` ) ) ) )
labeledField ( e , "速度" , e ( "select" , { value : String ( view . mapPlayback . speed ) , "aria-label" : "轨迹回放速度" , onChange : ( event : InputEvent ) = > view . setMapPlayback ( ( previous ) = > rebaseMapPlayback ( previous , { speed : Number ( inputValue ( event ) ) || 1 } ) ) } , [ 1 , 2 , 4 ] . map ( ( speed ) = > e ( "option" , { key : speed , value : String ( speed ) } , ` ${ speed } x ` ) ) ) ) ,
e ( "p" , { className : "page-status" } , tracks . filter ( ( track ) = > track . kind === "player" ) . length === 1 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。" )
)
)
) ;
@@ -745,11 +844,16 @@ function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], curso
return shapes ;
}
function mapTrackMarkers ( e : ReactLike [ "createElement" ] , tracks : MapTrack [ ] , cursor : number , catalog : Map < string , string > ) {
function mapTrackMarkers ( e : ReactLike [ "createElement" ] , tracks : MapTrack [ ] , cursor : number , catalog : Map < string , string > , playing : boolean ) {
const markers : unknown [ ] = [ ] ;
for ( const track of tracks ) {
const points = track . points . filter ( ( point ) = > point . time <= cursor ) ;
if ( ! points . length ) continue ;
if ( Number . isFinite ( cursor ) ) {
const position = mapTrackPositionAt ( track , cursor ) ;
if ( position ) markers . push ( mapLiveMarker ( e , track , position , catalog , playing ) ) ;
continue ;
}
const first = points [ 0 ] ;
const last = points [ points . length - 1 ] ;
markers . push ( e ( "span" , { key : ` ${ track . key } :start ` , className : ` map-track-endpoint map-track-start map-layer- ${ track . kind === "player" ? "players" : "vehicles" } ` , style : mapSceneStyle ( first ) , title : ` ${ track . label } 起点 ${ timeLabel ( first . time ) } ` } , "起" ) ) ;
@@ -760,15 +864,28 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs
const visible = segment . points . filter ( ( point ) = > point . time <= cursor ) ;
if ( ! visible . length ) continue ;
const middle = visible [ Math . floor ( visible . length / 2 ) ] ;
const code = mapRideCode ( middle . row ) || track . label ;
const icon = trajectoryVehicleIcon ( middle . row ) || scumVehicleIconUrl ( catalog . get ( code ) ? ? code ) ;
const code = middle . rideCode || mapRideCode ( middle . row ) || track . label ;
const icon = trajectoryVehicleIcon ( middle . row ) || vehicleIconByCode ( 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 : "" } ) : "车" ) ) ;
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 : "" , loading : "lazy" , decoding : "async" } ) : "车" ) ) ;
}
}
return markers ;
}
// The moving marker is what makes playback readable: the selected users stay enlarged, and a ridden
// vehicle swaps the dot for the vehicle icon the way a navigation app draws a car instead of a pin.
function mapLiveMarker ( e : ReactLike [ "createElement" ] , track : MapTrack , point : MapTrackPoint , catalog : Map < string , string > , playing : boolean ) {
const rideCode = point . rideCode ;
const icon = rideCode ? vehicleIconByCode ( catalog . get ( rideCode ) ? ? rideCode ) : "" ;
const label = rideCode ? ` ${ track . label } · ${ scumVehicleEntry ( rideCode ) ? . name || catalog . get ( rideCode ) || "载具" } ` : track . label ;
const className = [ "map-track-live" , playing ? "is-playing" : "" , rideCode ? "map-track-live-riding" : "" , track . kind === "vehicle" ? "map-track-live-vehicle" : "" ] . filter ( Boolean ) . join ( " " ) ;
return e ( "span" , { key : ` ${ track . key } :live ` , className , style : { . . . mapSceneStyle ( point ) , "--track-color" : track . color } , title : ` ${ label } ${ timeLabel ( point . time ) } ` } ,
icon ? e ( "img" , { className : "map-track-live-icon" , src : icon , alt : "" , loading : "lazy" , decoding : "async" } ) : e ( "span" , { className : "map-track-live-dot" } ) ,
e ( "strong" , { className : "map-track-live-label" } , track . label )
) ;
}
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 {
@@ -806,10 +923,11 @@ function applyMapTimePreset(setPreset: StateSetter<string>, setFrom: StateSetter
function mapTracks ( data : SCUMSurfaceData , bounds : RecordMap , window : MapTimeWindow , users : string [ ] | undefined , catalog : Map < string , string > ) : MapTrack [ ] {
const grouped = new Map < string , MapTrack > ( ) ;
const windowRows = data . trajectories . filter ( ( row ) = > { const time = trajectoryOrder ( row ) ; return time >= window . from && time <= window . to ; } ) ;
const rideSamples = mapRideSamples ( data , bounds ) ;
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 ) ;
const vehicle = playerRide ( row , rideSamples , bounds ) ? . key ? ? "" ;
if ( vehicle ) riddenVehicles . add ( vehicle ) ;
}
for ( const row of windowRows ) {
@@ -821,10 +939,10 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
if ( kind === "player" ? users !== undefined && ! users . includes ( identity ) : users !== undefined && ! riddenVehicles . has ( identity ) ) continue ;
const time = trajectoryOrder ( row ) ;
const scene = mapScenePoint ( row , bounds ) ;
const rideCode = mapRideCode ( row ) ;
const rideCode = kind === "player" ? playerRideCode ( row , rideSamples , bounds ) : 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 : 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 ) } ) ;
track . points . push ( { key : ` ${ key } : ${ track . points . length } ` , row , x : scene.x , y : scene.y , time , riding : Boolean ( rideCode ) , rideCode } ) ;
if ( rideCode && ! track . vehicles . includes ( rideCode ) ) track . vehicles . push ( rideCode ) ;
track . from = Math . min ( track . from , time ) ;
track . to = Math . max ( track . to , time ) ;
@@ -844,6 +962,19 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind
function mapPlayerTrackIdentity ( row : RecordMap ) : string { return textField ( row , "steamId" , "subjectId" , "gamePlayerId" , "displayName" , "id" ) ; }
function mapRiddenVehicleId ( row : RecordMap ) : string { return textField ( row , "riddenVehicleId" , "gameVehicleId" , "vehicleId" ) ; }
// Explicit ridden_vehicle_id wins; otherwise the vehicle sample from the same poll window is used.
function playerRide ( row : RecordMap , samples : MapRideSample [ ] , bounds : RecordMap ) : MapRideSample | undefined {
const explicit = mapRiddenVehicleId ( row ) ;
if ( explicit ) return { key : explicit , code : mapRideCode ( row ) , time : trajectoryOrder ( row ) , scene : mapScenePoint ( row , bounds ) } ;
return mapRiddenSample ( samples , row , bounds ) ;
}
function playerRideCode ( row : RecordMap , samples : MapRideSample [ ] , bounds : RecordMap ) : string {
const ride = playerRide ( row , samples , bounds ) ;
if ( ! ride ) return "" ;
return ride . code || "vehicle" ;
}
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 ) {
@@ -896,16 +1027,101 @@ function mapVehicleClassIndex(data: SCUMSurfaceData): Map<string, string> {
function mapRideCode ( row : RecordMap ) : string { return textField ( row , "className" , "vehicleClass" , "entityClass" , "vehicleType" , "riddenVehicleId" , "gameVehicleId" , "vehicleId" ) ; }
// Playback covers the recorded samples, not the whole filter window: a 24h window with two hours of
// data would otherwise spend most of the run showing an empty map.
function mapPlaybackSpan ( window : MapTimeWindow , tracks : MapTrack [ ] ) : { from : number ; to : number } {
const times = tracks . length ? [ Math . min ( . . . tracks . map ( ( track ) = > track . from ) ) , Math . max ( . . . tracks . map ( ( track ) = > track . to ) ) ] : [ 0 , 0 ] ;
if ( times [ 1 ] > times [ 0 ] ) return { from : times [ 0 ] , to : times [ 1 ] } ;
return { from : Number . isFinite ( window . from ) ? window . from : times [ 0 ] , to : Number.isFinite ( window . to ) ? window . to : times [ 1 ] } ;
}
function advanceMapPlayback ( previous : MapPlaybackState ) : MapPlaybackState {
const value = previous . value + 0.012 * ( previous . speed || 1 ) ;
export function advanceMapPlayback ( previous : MapPlaybackState , now = Date . now ( ) ) : MapPlaybackState {
if ( ! previous . playing ) return previous ;
const elapsed = Math . max ( 0 , now - previous . startedAt ) ;
const value = previous . startValue + elapsed / scumMapPlaybackDurationMs * ( previous . speed || 1 ) ;
return value >= 1 ? { . . . previous , playing : false , value : 1 } : { . . . previous , value } ;
}
function rebaseMapPlayback ( previous : MapPlaybackState , patch : Partial < MapPlaybackState > , now = Date . now ( ) ) : MapPlaybackState {
const current = advanceMapPlayback ( previous , now ) ;
return { . . . current , . . . patch , startedAt : now , startValue : patch.value ? ? current . value } ;
}
function playbackCursor ( span : { from : number ; to : number } , playback : MapPlaybackState ) : number {
return playback . value >= 1 ? Infinity : span . from + ( span . to - span . from ) * playback . value ;
}
// A single selected user fills the viewport at the maximum zoom and is followed; several users are
// framed together, so two neighbouring players still reach the maximum zoom while two players on
// opposite corners fall back to the minimum.
function playbackStartView ( tracks : MapTrack [ ] , node : HTMLElement | null ) : MapViewState | undefined {
const players = tracks . filter ( ( track ) = > track . kind === "player" && track . points . length ) ;
if ( players . length === 1 ) return focusMapView ( players [ 0 ] . points [ 0 ] , node , scumMapZoomMax ) ;
if ( players . length > 1 ) return fitMapView ( mapTrackPointList ( players ) , node , 0.8 ) ;
return undefined ;
}
function playbackFollowView ( tracks : MapTrack [ ] , playback : MapPlaybackState , span : { from : number ; to : number } , node : HTMLElement | null ) : MapViewState | undefined {
if ( ! playback . playing ) return undefined ;
const players = tracks . filter ( ( track ) = > track . kind === "player" && track . points . length ) ;
if ( players . length !== 1 ) return undefined ;
const position = mapTrackPositionAt ( players [ 0 ] , playbackCursor ( span , playback ) ) ;
return position ? focusMapView ( position , node , scumMapZoomMax ) : undefined ;
}
// The player trajectory table only carries ridden_vehicle_id when the collector fills it. Until then
// every player sample is matched against the vehicle samples recorded in the same poll window, so the
// surrounding vehicle still shows up as a ride marker instead of a walk segment.
function mapRideSamples ( data : SCUMSurfaceData , bounds : RecordMap ) : MapRideSample [ ] {
const samples : MapRideSample [ ] = [ ] ;
for ( const row of data . trajectories ) {
if ( layerOf ( row ) !== "vehicles" ) continue ;
const position = positionOf ( row ) ;
if ( ! hasCoordinates ( position ) ) continue ;
const time = trajectoryOrder ( row ) ;
const key = textField ( row , "gameVehicleId" , "vehicleId" , "subjectId" , "id" ) ;
if ( ! key ) continue ;
samples . push ( { key , code : mapRideCode ( row ) , time , scene : mapScenePoint ( row , bounds ) } ) ;
}
return samples . sort ( ( left , right ) = > left . time - right . time ) ;
}
function mapRiddenSample ( samples : MapRideSample [ ] , row : RecordMap , bounds : RecordMap ) : MapRideSample | undefined {
const position = positionOf ( row ) ;
if ( ! hasCoordinates ( position ) ) return undefined ;
const time = trajectoryOrder ( row ) ;
const scene = mapScenePoint ( row , bounds ) ;
let best : MapRideSample | undefined ;
let bestDistance = Infinity ;
for ( const sample of samples ) {
const delta = Math . abs ( sample . time - time ) ;
if ( delta > scumMapRideWindowMs ) continue ;
const distance = Math . hypot ( sample . scene . x - scene . x , sample . scene . y - scene . y ) ;
if ( distance > scumMapRideDistance || distance >= bestDistance ) continue ;
best = sample ;
bestDistance = distance ;
}
return best ;
}
function mapTrackPositionAt ( track : MapTrack , cursor : number ) : MapTrackPoint | undefined {
const points = track . points ;
if ( ! points . length ) return undefined ;
if ( ! Number . isFinite ( cursor ) ) return points [ points . length - 1 ] ;
if ( cursor < points [ 0 ] . time ) return undefined ;
for ( let index = 1 ; index < points . length ; index += 1 ) {
const previous = points [ index - 1 ] ;
const next = points [ index ] ;
if ( cursor > next . time ) continue ;
const gap = next . time - previous . time ;
if ( gap <= 0 || gap > scumMapSegmentGapMs ) return { . . . previous , time : cursor } ;
const ratio = ( cursor - previous . time ) / gap ;
const riding = ratio < 0.5 ? previous.riding : next.riding ;
return { key : ` ${ track . key } :live ` , row : riding ? next.row : previous.row , x : previous.x + ( next . x - previous . x ) * ratio , y : previous.y + ( next . y - previous . y ) * ratio , time : cursor , riding , rideCode : riding ? ( next . rideCode || previous . rideCode ) : "" } ;
}
return points [ points . length - 1 ] ;
}
function zoomMapView ( previous : MapViewState , factor : number , anchorX : number , anchorY : number ) : MapViewState {
const zoom = clampMapZoom ( previous . zoom * factor ) ;
if ( zoom === previous . zoom ) return previous ;
@@ -921,6 +1137,12 @@ function defaultMapView(node: HTMLElement | null): MapViewState {
return width > height && height > 0 ? { zoom : clampMapZoom ( width / height ) , x : 0 , y : 0 } : scumMapDefaultView ;
}
function focusMapView ( point : MapTrackPoint , node : HTMLElement | null , zoom : number ) : MapViewState {
const size = Math . max ( 240 , Number ( node ? . clientHeight ) || 640 ) ;
const clamped = clampMapZoom ( zoom ) ;
return { zoom : clamped , x : - ( point . x / scumMapSize - 0.5 ) * size * clamped , y : - ( point . y / scumMapSize - 0.5 ) * size * clamped } ;
}
function fitMapView ( points : MapTrackPoint [ ] , node : HTMLElement | null , padding : number ) : MapViewState {
if ( ! points . length ) return scumMapDefaultView ;
let minX = Infinity ; let minY = Infinity ; let maxX = - Infinity ; let maxY = - Infinity ;
@@ -1005,7 +1227,7 @@ function giftCatalogIcons(gift: RecordMap): string[] {
function trajectoryVehicleIcon ( row : RecordMap ) : string {
const code = textField ( row , "className" , "vehicleClass" , "entityClass" , "vehicleType" , "riddenVehicleId" , "gameVehicleId" , "vehicleId" ) ;
return code ? scumVehicleIconUrl ( code ) : "" ;
return code ? scumVehicleIconUrl ( code ) || scumVehicleFallbackIconUrl ( code ) : "" ;
}
function catalogPicker ( e : ReactLike [ "createElement" ] , view : ViewState , target : CatalogPickTarget ) {
@@ -1086,9 +1308,35 @@ function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordM
function vehicleLockRecordsForPoint ( rows : RecordMap [ ] , point : RecordMap ) : RecordMap [ ] { const ids = new Set ( [ textField ( point , "id" ) , textField ( point , "scumVehicleId" ) , textField ( point , "vehicleId" ) , textField ( point , "gameVehicleId" ) , textField ( point , "entityId" ) , textField ( point , "subjectId" ) ] . filter ( Boolean ) ) ; return rows . filter ( ( row ) = > [ textField ( row , "scumVehicleId" ) , textField ( row , "vehicleId" ) , textField ( row , "gameVehicleId" ) ] . some ( ( identity ) = > ids . has ( identity ) ) ) ; }
function trajectoryIdentity ( row : RecordMap ) : string [ ] { return [ textField ( row , "subjectId" ) , textField ( row , "steamId" ) , textField ( row , "gamePlayerId" ) , textField ( row , "vehicleId" ) , textField ( row , "id" ) ] . filter ( Boolean ) ; }
function trajectoryOrder ( row : RecordMap ) : number { const stamp = Date . parse ( textField ( row , "sampledAt" , "observedAt" , "createdAt" ) ) ; return Number . isNaN ( stamp ) ? 0 : stamp ; }
function vehicleIconFor ( point : RecordMap ) : string { const explicit = textField ( point , "imagePath" , "image_path" ) ; if ( explicit ) return explicit . startsWith ( "/" ) ? explicit : ` / ${ explicit } ` ; return scumVehicleIconUrl ( normalizedVehicleClass ( textField ( point , "className" , "vehicleClass" , "entityClass" , "vehicleType" ) ) ) ; }
// The machine-side collector reports its own dump paths (for example "/original/Rager_ES.webp");
// those files are not part of the plugin bundle, so only URLs the bundle really serves may reach an <img>.
export function bundledVehicleImageUrl ( value : string ) : string {
const url = value . trim ( ) ;
if ( ! url || /^https?:\/\//i . test ( url ) ) return "" ;
if ( /^(?:data|blob):/i . test ( url ) ) return url ;
return /^\/(?:@fs|assets|src|node_modules)\// . test ( url ) ? url : "" ;
}
function vehicleIconFor ( point : RecordMap ) : string {
const explicit = bundledVehicleImageUrl ( textField ( point , "imagePath" , "image_path" ) ) ;
if ( explicit ) return explicit ;
return vehicleIconByCode ( textField ( point , "className" , "vehicleClass" , "entityClass" , "vehicleType" ) ) ;
}
function vehicleIconByCode ( code : string ) : string {
if ( ! code . trim ( ) ) return "" ;
const normalized = normalizedVehicleClass ( code ) ;
return scumVehicleIconUrl ( normalized ) || scumVehicleFallbackIconUrl ( normalized ) ;
}
function vehicleLabelFor ( point : RecordMap ) : string {
const code = textField ( point , "className" , "vehicleClass" , "entityClass" , "vehicleType" , "riddenVehicleId" , "gameVehicleId" ) ;
return scumVehicleEntry ( code ) ? . name || textField ( point , "displayName" , "label" , "name" ) || code || "未知载具" ;
}
function vehicleClassKey ( value : string ) : string { return normalizedVehicleClass ( value . replace ( /^#spawnvehicle\s+/i , "" ) ) ; }
function normalizedVehicleClass ( value : string ) : string { return value . replace ( /_C$/i , "" ) . split ( "." ) . pop ( ) ? . trim ( ) ? ? value . trim ( ) ; }
// Live classes arrive as "<name>_ES" (spawner suffix) or "<name>_C" (blueprint suffix); the catalog
// keys the bare name, so every lookup path strips both before comparing.
function normalizedVehicleClass ( value : string ) : string { return ( value . split ( "." ) . pop ( ) ? ? value ) . replace ( /_(?:ES|C)$/i , "" ) . trim ( ) ; }
function shortHash ( value : string ) : string { return value ? ` ${ value . slice ( 0 , 10 ) } … ${ value . slice ( - 6 ) } ` : "" ; }
function runAction ( setAction : StateSetter < ActionState > , pending : string , task : ( ) = > Promise < string > ) { setAction ( { status : "pending" , message : pending } ) ; void task ( ) . then ( ( message ) = > setAction ( { status : "ok" , message } ) ) . catch ( ( error ) = > setAction ( { status : "error" , message : errorMessage ( error , "操作失败。" ) } ) ) ; }