@@ -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 ) : "" ;