@@ -29,16 +29,36 @@ type ScumMapLayer = "terrain" | "topo" | "night";
type CatalogPickTarget = "gift-items" | "produce-item" ;
type CatalogPickTarget = "gift-items" | "produce-item" ;
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory" ;
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory" ;
type PlayerPanelState = { kind : PlayerPanelKind ; playerId : string } ;
type PlayerPanelState = { kind : PlayerPanelKind ; playerId : string } ;
type MapViewState = { zoom : number ; x : number ; y : number } ;
type MapPlaybackState = { playing : boolean ; value : number ; speed : number } ;
type MapTimeWindow = { from : number ; to : number ; label : string } ;
type MapTrackPoint = { key : string ; row : RecordMap ; x : number ; y : number ; time : number ; riding : boolean } ;
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 } ;
const scumMapLayerImages = { terrain : new URL ( "../assets/map/scum-map-terrain-4096.webp" , import . meta . url ) . href , topo : new URL ( "../assets/map/scum-map-topo-4096.webp" , import . meta . url ) . href , night : new URL ( "../assets/map/scum-map-night-4096.webp" , import . meta . url ) . href , zones : new URL ( "../assets/map/scum-map-building-zones-4096.png" , import . meta . url ) . href } as const ;
const scumMapLayerImages = { terrain : new URL ( "../assets/map/scum-map-terrain-4096.webp" , import . meta . url ) . href , topo : new URL ( "../assets/map/scum-map-topo-4096.webp" , import . meta . url ) . href , night : new URL ( "../assets/map/scum-map-night-4096.webp" , import . meta . url ) . href , zones : new URL ( "../assets/map/scum-map-building-zones-4096.png" , import . meta . url ) . href } as const ;
const scumMapBaseLayers : ReadonlyArray < readonly [ ScumMapLayer , string ] > = [ [ "terrain" , "地形" ] , [ "topo" , "等高线" ] , [ "night" , "夜间" ] ] ;
const scumMapBaseLayers : ReadonlyArray < readonly [ ScumMapLayer , string ] > = [ [ "terrain" , "地形" ] , [ "topo" , "等高线" ] , [ "night" , "夜间" ] ] ;
const scumCatalogKinds : ReadonlyArray < readonly [ string , string ] > = [ [ "all" , "全部" ] , [ "item" , "物品" ] , [ "vehicle" , "载具" ] , [ "zombie" , "丧尸" ] , [ "animal" , "动物" ] , [ "armed-npc" , "NPC" ] ] ;
const scumCatalogKinds : ReadonlyArray < readonly [ string , string ] > = [ [ "all" , "全部" ] , [ "item" , "物品" ] , [ "vehicle" , "载具" ] , [ "zombie" , "丧尸" ] , [ "animal" , "动物" ] , [ "armed-npc" , "NPC" ] ] ;
const scumCatalogPickLimit = 48 ;
const scumCatalogPickLimit = 48 ;
const scumMapSize = 4096 ;
const scumMapSize = 4096 ;
const scumSurfaceRefreshMs = 15000 ;
const scumSurfaceRefreshMs = 15000 ;
const scumMapZoomMin = 1 ;
const scumMapZoomMax = 8 ;
const scumMapTrackPointLimit = 400 ;
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 scumMapTimePresets : ReadonlyArray < readonly [ string , string ] > = [ [ "all" , "全部时段" ] , [ "15m" , "最近 15 分钟" ] , [ "1h" , "最近 1 小时" ] , [ "6h" , "最近 6 小时" ] , [ "24h" , "最近 24 小时" ] , [ "7d" , "最近 7 天" ] , [ "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 mapDragState = { active : false , pointerId : - 1 , startX : 0 , startY : 0 , originX : 0 , originY : 0 , moved : false } ;
export type ReactLike = {
export type ReactLike = {
createElement : ( . . . args : any [ ] ) = > any ;
createElement : ( . . . args : any [ ] ) = > any ;
useEffect ? : ( effect : ( ) = > void | ( ( ) = > void ) , deps : readonly unknown [ ] ) = > void ;
useEffect ? : ( effect : ( ) = > void | ( ( ) = > void ) , deps : readonly unknown [ ] ) = > void ;
useRef ? : < T > ( initial : T ) = > { current : T } ;
useState ? : < T > ( initialState : T | ( ( ) = > T ) ) = > [ T , StateSetter < T > ] ;
useState ? : < T > ( initialState : T | ( ( ) = > T ) ) = > [ T , StateSetter < T > ] ;
} ;
} ;
@@ -99,18 +119,19 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [ giftAchievementNumber , setGiftAchievementNumber ] = usePluginState ( react , "0" ) ;
const [ giftAchievementNumber , setGiftAchievementNumber ] = usePluginState ( react , "0" ) ;
const [ deliveryGift , setDeliveryGift ] = usePluginState ( react , "" ) ;
const [ deliveryGift , setDeliveryGift ] = usePluginState ( react , "" ) ;
const [ deliveryPlayer , setDeliveryPlayer ] = usePluginState ( react , "" ) ;
const [ deliveryPlayer , setDeliveryPlayer ] = usePluginState ( react , "" ) ;
const [ mapSearch , setMapSearch ] = usePluginState ( react , "" ) ;
const [ mapLayers , setMapLayers ] = usePluginState < Record < MapLayer , boolean > > ( react , { players : true , vehicles : true , flags : true , regions : true , other : true } ) ;
const [ mapLayers , setMapLayers ] = usePluginState < Record < MapLayer , boolean > > ( react , { players : true , vehicles : true , flags : true , regions : true , other : true } ) ;
const [ selectedMapPoint , setSelectedMapPoint ] = usePluginState ( react , "" ) ;
const [ selectedMapPoint , setSelectedMapPoint ] = usePluginState ( react , "" ) ;
const [ mapCustomEnabled , setMapCustomEnabled ] = usePluginState < boolean | undefined > ( react , undefined ) ;
const [ mapView , setMapView ] = usePluginState < MapViewState > ( react , scumMapDefaultView ) ;
const [ mapCenterX , setMapCenterX ] = usePluginState ( react , "" ) ;
const [ mapTimePreset , setMapTimePreset ] = usePluginState ( react , "all " ) ;
const [ mapCenterY , setMapCenterY ] = usePluginState ( react , "" ) ;
const [ mapTimeFrom , setMapTimeFrom ] = usePluginState ( react , "" ) ;
const [ mapWidthKm , setMapWidthKm ] = usePluginState ( react , "" ) ;
const [ mapTimeTo , setMapTimeTo ] = usePluginState ( react , "" ) ;
const [ mapHeightKm , setMapHeightKm ] = usePluginState ( react , "" ) ;
const [ mapUsers , setMapUsers ] = usePluginState < string [ ] | undefined > ( react , 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 } ;
const [ eventEditorOpen , setEventEditorOpen ] = usePluginState ( react , false ) ;
const [ eventEditorOpen , setEventEditorOpen ] = usePluginState ( react , false ) ;
const [ produceEditorOpen , setProduceEditorOpen ] = usePluginState ( react , false ) ;
const [ produceEditorOpen , setProduceEditorOpen ] = usePluginState ( react , false ) ;
const [ giftEditorOpen , setGiftEditorOpen ] = usePluginState ( react , false ) ;
const [ giftEditorOpen , setGiftEditorOpen ] = usePluginState ( react , false ) ;
const [ mapSettingsOpen , setMapSettingsOpen ] = usePluginState ( react , false ) ;
const [ mapBaseLayer , setMapBaseLayer ] = usePluginState < ScumMapLayer | undefined > ( react , undefined ) ;
const [ mapBaseLayer , setMapBaseLayer ] = usePluginState < ScumMapLayer | undefined > ( react , undefined ) ;
const [ mapZonesOverlay , setMapZonesOverlay ] = usePluginState < boolean | undefined > ( react , undefined ) ;
const [ mapZonesOverlay , setMapZonesOverlay ] = usePluginState < boolean | undefined > ( react , undefined ) ;
const [ catalogSearch , setCatalogSearch ] = usePluginState ( react , "" ) ;
const [ catalogSearch , setCatalogSearch ] = usePluginState ( react , "" ) ;
@@ -141,6 +162,23 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const interval = setInterval ( refresh , scumSurfaceRefreshMs ) ;
const interval = setInterval ( refresh , scumSurfaceRefreshMs ) ;
return ( ) = > { disposed = true ; generation += 1 ; clearInterval ( interval ) ; } ;
return ( ) = > { disposed = true ; generation += 1 ; clearInterval ( interval ) ; } ;
} , [ input . serverInstanceId , pageKey , input . workspaceActions , playerPanel . kind , refreshSignal ] ) ;
} , [ input . serverInstanceId , pageKey , input . workspaceActions , playerPanel . kind , refreshSignal ] ) ;
if ( react . useEffect ) react . useEffect ( ( ) = > {
const node = mapViewportRef . current ;
if ( pageKey !== "live-map" || ! node || typeof node . addEventListener !== "function" ) return ;
const onWheel = ( event : { deltaY : number ; clientX : number ; clientY : number ; preventDefault : ( ) = > void } ) = > {
event . preventDefault ( ) ;
const rect = node . getBoundingClientRect ( ) ;
setMapView ( ( previous ) = > zoomMapView ( previous , event . deltaY < 0 ? 1.16 : 1 / 1.16 , event . clientX - rect . left - rect . width / 2 , event . clientY - rect . top - rect . height / 2 ) ) ;
} ;
node . addEventListener ( "wheel" , onWheel , { passive : false } ) ;
setMapView ( ( previous ) = > ( previous . zoom === scumMapZoomMin && previous . x === 0 && previous . y === 0 ? defaultMapView ( node ) : previous ) ) ;
return ( ) = > { node . removeEventListener ( "wheel" , onWheel ) ; } ;
} , [ pageKey , state . status , mapViewportRef , setMapView ] ) ;
if ( react . useEffect ) react . useEffect ( ( ) = > {
if ( ! mapPlayback . playing ) return ;
const timer = setInterval ( ( ) = > setMapPlayback ( ( previous ) = > advanceMapPlayback ( previous ) ) , 90 ) ;
return ( ) = > { clearInterval ( timer ) ; } ;
} , [ mapPlayback . playing , setMapPlayback ] ) ;
const data = state . status === "ready" ? state.data : emptySCUMSurfaceData ;
const data = state . status === "ready" ? state.data : emptySCUMSurfaceData ;
return e ( "section" , { className : "console-panel scum-workbench" , "aria-label" : input . pageTitle ? ? surfaceTitle ( pageKey ) } ,
return e ( "section" , { className : "console-panel scum-workbench" , "aria-label" : input . pageTitle ? ? surfaceTitle ( pageKey ) } ,
@@ -153,9 +191,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
eventCommand , setEventCommand , eventClass , setEventClass , eventPlacard , setEventPlacard , eventPercent , setEventPercent , eventDuration , setEventDuration , eventNpc , setEventNpc , eventItem , setEventItem , eventZombie , setEventZombie , eventAnimal , setEventAnimal ,
eventCommand , setEventCommand , eventClass , setEventClass , eventPlacard , setEventPlacard , eventPercent , setEventPercent , eventDuration , setEventDuration , eventNpc , setEventNpc , eventItem , setEventItem , eventZombie , setEventZombie , eventAnimal , setEventAnimal ,
produceEventId , setProduceEventId , produceId , setProduceId , produceTradeGoodsId , setProduceTradeGoodsId , producePercent , setProducePercent , produceValue , setProduceValue , produceRadius , setProduceRadius , produceX , setProduceX , produceY , setProduceY , produceZ , setProduceZ ,
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 ,
giftTab , setGiftTab , giftCode , setGiftCode , giftName , setGiftName , giftItems , setGiftItems , giftCommands , setGiftCommands , giftClass , setGiftClass , giftAudience , setGiftAudience , giftNumber , setGiftNumber , giftAchievement , setGiftAchievement , giftAchievementNumber , setGiftAchievementNumber ,
deliveryGift , setDeliveryGift , deliveryPlayer , setDeliveryPlayer , mapSearch , setMapSearch , mapLayers , setMapLayers , selectedMapPoint , setSelectedMapPoint ,
deliveryGift , setDeliveryGift , deliveryPlayer , setDeliveryPlayer , mapLayers , setMapLayers , selectedMapPoint , setSelectedMapPoint ,
mapCustomEnabled , setMapCustomEnabled , mapCenterX , setMapCenterX , mapCenterY , setMapCenterY , mapWidthKm , setMapWidthKm , mapHeightKm , setMapHeightKm ,
mapView , setMapView , mapViewportRef , mapTimePreset , setMapTimePreset , mapTimeFrom , setMapTimeFrom , mapTimeTo , setMapTimeTo ,
eventEditorOpen , setEventEditorOpen , produceEditorOpen , setProduceEditorOpen , giftEditorOpen , setGiftEditorOpen , mapSettingsOpen , setMapSettingsOpen ,
mapUsers , setMapUsers , mapTrackKey , setMapTrackKey , mapPlayback , setMapPlayback ,
eventEditorOpen , setEventEditorOpen , produceEditorOpen , setProduceEditorOpen , giftEditorOpen , setGiftEditorOpen ,
mapBaseLayer , setMapBaseLayer , mapZonesOverlay , setMapZonesOverlay , catalogSearch , setCatalogSearch , catalogKind , setCatalogKind , catalogOpen , setCatalogOpen ,
mapBaseLayer , setMapBaseLayer , mapZonesOverlay , setMapZonesOverlay , catalogSearch , setCatalogSearch , catalogKind , setCatalogKind , catalogOpen , setCatalogOpen ,
setAction , refresh : ( ) = > setRefreshSignal ( ( value ) = > value + 1 )
setAction , refresh : ( ) = > setRefreshSignal ( ( value ) = > value + 1 )
} ) : null
} ) : null
@@ -178,10 +217,12 @@ type ViewState = {
giftCode : string ; setGiftCode : StateSetter < string > ; giftName : string ; setGiftName : StateSetter < string > ; giftItems : string ; setGiftItems : StateSetter < string > ; giftCommands : string ; setGiftCommands : StateSetter < string > ;
giftCode : string ; setGiftCode : StateSetter < string > ; giftName : string ; setGiftName : StateSetter < string > ; giftItems : string ; setGiftItems : StateSetter < string > ; giftCommands : string ; setGiftCommands : StateSetter < string > ;
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 > ;
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 > ;
deliveryGift : string ; setDeliveryGift : StateSetter < string > ; deliveryPlayer : string ; setDeliveryPlayer : StateSetter < string > ;
mapSearch : string ; setMapSearch : StateSetter < string > ; mapLayers : Record < MapLayer , boolean > ; setMapLayers : StateSetter < Record < MapLayer , boolean > > ;
mapLayers : Record < MapLayer , boolean > ; setMapLayers : StateSetter < Record < MapLayer , boolean > > ;
selectedMapPoint : string ; setSelectedMapPoint : StateSetter < string > ; setAction : StateSetter < ActionState > ; refresh : ( ) = > void ;
selectedMapPoint : string ; setSelectedMapPoint : StateSetter < string > ; setAction : StateSetter < ActionState > ; refresh : ( ) = > void ;
mapCustomEnabled : boolean | undefined ; setMapCustomEnabled : StateSetter < boolean | undefined > ; mapCenterX : string ; setMapCenterX : StateSetter < string > ; mapCenterY : string ; setMapCenterY : StateSetter < string > ; mapWidthKm : string ; setMapWidthKm : StateSetter < string > ; mapHeightKm : string ; setMapHeightKm : StateSetter < string > ;
mapView : MapViewState ; setMapView : StateSetter < MapViewState > ; mapViewportRef : { current : HTMLElement | null } ;
eventEditorOpen : boolean ; setEventEditorOpen : StateSetter < boolean > ; produceEditorOpen : boolean ; setProduceEditorOpen : StateSetter < boolean > ; giftEditorOpen : boolean ; setGiftEditorOpen : StateSetter < boolean > ; mapSettingsOpen : boolean ; setMapSettingsOpen : StateSetter < boolean > ;
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 > ;
eventEditorOpen : boolean ; setEventEditorOpen : StateSetter < boolean > ; produceEditorOpen : boolean ; setProduceEditorOpen : StateSetter < boolean > ; giftEditorOpen : boolean ; setGiftEditorOpen : StateSetter < boolean > ;
mapBaseLayer : ScumMapLayer | undefined ; setMapBaseLayer : StateSetter < ScumMapLayer | undefined > ; mapZonesOverlay : boolean | undefined ; setMapZonesOverlay : StateSetter < boolean | undefined > ;
mapBaseLayer : ScumMapLayer | undefined ; setMapBaseLayer : StateSetter < ScumMapLayer | undefined > ; mapZonesOverlay : boolean | undefined ; setMapZonesOverlay : StateSetter < boolean | undefined > ;
catalogSearch : string ; setCatalogSearch : StateSetter < string > ; catalogKind : string ; setCatalogKind : StateSetter < string > ; catalogOpen : boolean ; setCatalogOpen : StateSetter < boolean > ;
catalogSearch : string ; setCatalogSearch : StateSetter < string > ; catalogKind : string ; setCatalogKind : StateSetter < string > ; catalogOpen : boolean ; setCatalogOpen : StateSetter < boolean > ;
} ;
} ;
@@ -530,42 +571,294 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const points = collectMapPoints ( data ) ;
const points = collectMapPoints ( data ) ;
const settings = data . mapSettings . find ( ( value ) = > textField ( value , "_recordKey" , "id" ) === "current" ) ? ? data . mapSettings [ 0 ] ;
const settings = data . mapSettings . find ( ( value ) = > textField ( value , "_recordKey" , "id" ) === "current" ) ? ? data . mapSettings [ 0 ] ;
const bounds = resolveMapBounds ( settings ) ;
const bounds = resolveMapBounds ( settings ) ;
const customEnabled = view . mapCustomEnabled ? ? Boolean ( settings && boolField ( settings , "customMapEnabled" ) ) ;
const centerX = view . mapCenterX || textField ( settings , "centerX" , "mapX" ) || String ( ( bounds . worldMinX + bounds . worldMaxX ) / 2 ) ;
const centerY = view . mapCenterY || textField ( settings , "centerY" , "mapY" ) || String ( ( bounds . worldMinY + bounds . worldMaxY ) / 2 ) ;
const widthKm = view . mapWidthKm || textField ( settings , "widthKm" , "mapWidth" ) || String ( ( bounds . worldMaxX - bounds . worldMinX ) / 100000 ) ;
const heightKm = view . mapHeightKm || textField ( settings , "heightKm" , "mapHeight" ) || String ( ( bounds . worldMaxY - bounds . worldMinY ) / 100000 ) ;
const baseLayer = view . mapBaseLayer ? ? mapLayerSetting ( settings ) ;
const baseLayer = view . mapBaseLayer ? ? mapLayerSetting ( settings ) ;
const zonesOverlay = view . mapZonesOverlay ? ? Boolean ( settings && boolField ( settings , "buildingZones" ) ) ;
const zonesOverlay = view . mapZonesOverlay ? ? Boolean ( settings && boolField ( settings , "buildingZones" ) ) ;
const search = view . mapSearch . trim ( ) . toLowerCase ( ) ;
const window = mapTimeWindow ( view ) ;
const visible = points . filter ( ( point ) = > view . mapLayers [ layerOf ( point ) ] && matchesText ( point , search , "name" , "label" , "subjectId" , "subjectType" , "layer" ) ) ;
const catalog = mapVehicleClassIndex ( data ) ;
const tracks = mapTracks ( data , bounds , window , view , 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 ;
const visible = points . filter ( ( point ) = > view . mapLayers [ layerOf ( point ) ] ) ;
const selected = visible . find ( ( point , index ) = > idOf ( point , ` point- ${ index } ` ) === view . selectedMapPoint ) ? ? visible [ 0 ] ;
const selected = visible . find ( ( point , index ) = > idOf ( point , ` point- ${ index } ` ) === view . selectedMapPoint ) ? ? visible [ 0 ] ;
const trails = visibleTrajectoryPoints ( data . trajectories , view . mapLayers , search ) . sort ( ( left , right ) = > trajectoryOrder ( right ) - trajectoryOrder ( left ) ) . slice ( 0 , 180 ) . reverse ( ) ;
const selectedTrails = selected ? trajectoryRecordsForPoint ( data . trajectories , selected ) . sort ( ( left , right ) = > trajectoryOrder ( right ) - trajectoryOrder ( left ) ) . slice ( 0 , 8 ) : [ ] ;
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 ) : [ ] ;
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" } ,
return e ( "div" , { className : "console-record-list scum-map-surface " } ,
e ( "div" , { className : "resource-filter-bar scum-filter-bar" } ,
e ( "div" , { className : "resource-filter-bar scum-filter-bar scum-map-bar " } ,
labeledField ( e , "筛选地图点 " , e ( "inpu t" , { value : view.mapSearch , "aria-label" : "筛选地图点" , placeholder : "名称 / 类型 / ID ", onChange : ( event : InputEvent ) = > view . setMapSearch ( inputValue ( event ) ) } ) ) ,
labeledField ( e , "时间 " , e ( "selec t" , { value : view.mapTimePreset , "aria-label" : "轨迹时间范围 " , onChange : ( event : InputEvent ) = > view . setMapTimePreset ( inputValue ( event ) ) } , scumMapTimePresets . map ( ( [ key , label ] ) = > e ( "option" , { key , value : key } , label ) ) ) ) ,
e ( "div" , { className : "s cum-layer-group" , role : "group" , "aria-label" : "地图图层开关" } , ( [ "players" , "vehicles" , "flags" , "regions" , "other" ] as MapLayer [ ] ) . map ( ( layer ) = > e ( "labe l", { key : layer , className : "scum-layer-toggle" } , e ( "input" , { type : "checkbox" , checked : view.mapLayers [ layer ] , onChange : ( event : InputEvent ) = > view . setMapLayers ( ( previous ) = > ( { . . . previous , [ layer ] : Boolean ( event . target ? . checked ) } ) ) } ) , layerLabel ( layer ) ) ) ) ,
view . mapTimePreset === "custom" ? labeledField ( e , "起" , e ( "input" , { type : "datetime-loca l", value : view.mapTimeFrom , "aria-label" : "轨迹起始时间" , onChange : ( event : InputEvent ) = > view . setMapTimeFrom ( inputValue ( event ) ) } ) ) : null ,
e ( "div " , { className : "scum-layer-group" , role : "group" , "aria-label" : "地图底图" } ,
view . mapTimePreset === "custom" ? labeledField ( e , "止" , e ( "input " , { type : "datetime-local" , value : view.mapTimeTo , "aria-label" : "轨迹结束时间" , onChange : ( event : InputEvent ) = > view . setMapTimeTo ( inputValue ( event ) ) } ) ) : null ,
scumMapBaseLayers . map ( ( [ key , label ] ) = > e ( "button" , { key , type : "button" , className : baseLayer === key ? "icon-command is-active" : "icon-command" , "aria-pressed" : baseLayer === key , onClick : ( ) = > view . setMapBaseLayer ( key ) } , label ) ) ,
mapUserMenu ( e , view , mapTrackUsers ( data , window ) ) ,
e ( "label" , { className : "scum-layer-toggle" } , e ( "input" , { type : "checkbox" , checked : zonesOverlay , onChange : ( event : InputEvent ) = > view . setMapZonesOverlay ( Boolean ( event . target ? . checked ) ) } ) , "建筑区" ) ) ,
mapLayerMenu ( e , view , zonesOverlay , ( next ) = > { persistMapLayers ( view , actions , baseLayer , next ) ; } ) ,
e ( "span " , { className : "page-status scum-map-summary" } , ` ${ visible . length } / ${ points . length } 个可见点 ` )
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 } ` )
) ,
) ,
e ( "details " , { className : "console-module scum-editor scum-map-settings" , open : view.mapSettingsOpen , onToggle : ( event : InputEvent ) = > view . setMapSettingsOpen ( detailOpen ( event ) ) } , e ( "summary" , null , e ( "strong" , null , "地图范围与图层" ) , e ( "span" , { className : "page-status" } , ` ${ mapLayerLabel ( baseLayer ) } ${ zonesOverlay ? " + 建筑区" : "" } · ${ customEnabled ? "自定义范围" : "SCUM 默认范围" } ` ) ) ,
e ( "div " , { className : "map-viewport" , "aria-label" : "SCUM 地图视图" , ref : view.mapViewportRef ,
e ( "div" , { className : "console-row-actions" } ,
onPointerDown : ( event : PointerEventLike ) = > { if ( event . button && event . button !== 0 ) return ; mapDragState . active = true ; mapDragState . pointerId = event . pointerId ? ? - 1 ; mapDragState . startX = event . clientX ? ? 0 ; mapDragState . startY = event . clientY ? ? 0 ; mapDragState . originX = view . mapView . x ; mapDragState . originY = view . mapView . y ; mapDragState . moved = false ; event . currentTarget ? . setPointerCapture ? . ( event . pointerId ) ; } ,
e ( "label" , null , e ( "input" , { type : "checkbox" , checked : customEnabled , "aria-label" : "启用自定义地图" , onChange : ( event : InputEvent ) = > view . setMapCustomEnabled ( Boolean ( event . target ? . checked ) ) } ) , "启用自定义地图" ) ,
onPointerMove : ( event : PointerEventLike ) = > { if ( ! mapDragState . active || ( event . pointerId ? ? - 1 ) !== mapDragState . pointerId ) return ; const deltaX = ( event . clientX ? ? 0 ) - mapDragState . startX ; const deltaY = ( event . clientY ? ? 0 ) - mapDragState . startY ; if ( Math . abs ( deltaX ) + Math . abs ( deltaY ) > 3 ) mapDragState . moved = true ; const originX = mapDragState . originX ; const originY = mapDragState . originY ; view . setMapView ( ( previous ) = > ( { . . . previous , x : originX + deltaX , y : originY + deltaY } ) ) ; } ,
labeledField ( e , "中心 X" , e ( "input" , { value : centerX , "aria-label" : "地图中心 X" , type : "number" , onChange : ( event : InputEvent ) = > view . setMapCenterX ( inputValue ( event ) ) } ) ) ,
onPointerUp : ( event : PointerEventLike ) = > { if ( ( event . pointerId ? ? - 1 ) !== mapDragState . pointerId ) return ; mapDragState . active = false ; event . currentTarget ? . releasePointerCapture ? . ( event . pointerId ) ; } ,
labeledField ( e , "中心 Y" , e ( "input" , { value : centerY , "aria-label" : "地图中心 Y" , type : "number" , onChange : ( event : InputEvent ) = > view . setMapCenterY ( inputValue ( event ) ) } ) ) ,
onPointerCancel : ( ) = > { mapDragState . active = false ; } ,
labeledField ( e , "宽度 (公里)" , e ( "input" , { value : widthKm , "aria-label" : "地图宽度公里" , type : "number" , onChange : ( event : InputEvent ) = > view . setMapWidthKm ( inputValue ( event ) ) } ) ) ,
onDoubleClick : ( ) = > { view . setMapView ( ( previous ) = > zoomMapView ( previous , 1.6 , 0 , 0 ) ) ; } } ,
labeledField ( e , "高度 (公里)" , e ( "input" , { value : heightKm , "aria-label" : "地图高度公里" , type : "number" , onChange : ( event : InputEvent ) = > view . setMapHeightKm ( inputValue ( event ) ) } ) ) ,
e ( "div" , { className : "map-scene" , style : { transform : ` translate(-50%, -50%) translate( ${ view . mapView . x } px, ${ view . mapView . y } px) scale( ${ zoom } ) ` , "--map-inverse" : String ( 1 / zoom ) } } ,
e ( "button " , { type : "button " , className : "p rimary-command" , disabled : ! actions ? . pluginData , onClick : ( ) = > runAction ( view . setAction , "正在保存地图范围…" , async ( ) = > { await saveMapSettings ( actions ? ? { } , { customMapEnabled : customEnabled , centerX : numberInput ( centerX , 0 ) , centerY : numberInput ( centerY , 0 ) , widthKm : numberInput ( widthKm , 15.24 ) , heightKm : numberInput ( heightKm , 15.24 ) , baseLayer , buildingZones : zonesOverlay } ) ; view . refresh ( ) ; return "地图范围已保存。" ; } ) } , "保存地图范围" ) )
e ( "div " , { className : "map-scene-image " , "a ria-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 ) )
) ,
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 ) ) } , "+ " ) ,
e ( "button" , { type : "button" , className : "icon-command" , "aria-label" : "地图缩小" , onClick : ( ) = > view . setMapView ( ( previous ) = > zoomMapView ( previous , 0.8 , 0 , 0 ) ) } , "- " ) ,
e ( "button" , { type : "button" , className : "icon-command" , "aria-label" : "地图复位" , onClick : ( ) = > view . setMapView ( defaultMapView ( view . mapViewportRef . current ) ) } , "复位" ) ,
e ( "button" , { type : "button" , className : "icon-command" , "aria-label" : "适应轨迹范围" , disabled : ! tracks . length , onClick : ( ) = > view . setMapView ( fitMapView ( mapTrackPointList ( tracks ) , view . mapViewportRef . current , 0.8 ) ) } , "适应轨迹" ) ,
e ( "span" , { className : "map-zoom-badge" } , ` ${ zoom . toFixed ( 2 ) } × ` )
)
) ,
) ,
e ( "div" , { className : "map-surface-board" , "aria-label" : "SCUM 地图图层" , style : mapBoardStyle ( baseLayer , zonesOverlay ) } , mapGridOverlay ( e ) , trails . map ( ( point , index ) = > e ( "span" , { key : ` trail- ${ index } - ${ idOf ( point , "sample" ) } ` , className : ` map-trajectory-dot map-layer- ${ layerOf ( point ) } ` , title : ` ${ pointTitle ( point ) } ${ dateField ( point , "sampledAt" , "observedAt" ) } ` , style : mapPointStyle ( point , bounds ) } ) ) , visible . map ( ( point , index ) = > { const ride = layerOf ( point ) === "players" ? textField ( point , "riddenVehicleId" , "gameVehicleId" ) : "" ; return e ( "button" , { key : idOf ( point , ` point- ${ index } ` ) , 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 : ( ) = > view . setSelectedMapPoint ( idOf ( point , ` point- ${ index } ` ) ) } , vehicleIconFor ( point ) ? e ( "img" , { src : vehicleIconFor ( point ) , alt : "" } ) : "" ) ; } ) ) ,
mapPlaybackBar ( e , view , span , tracks . length > 0 ) ,
mapTrackList ( e , view , tracks , catalog ) ,
selected ? mapPointStrip ( e , selected , selectedTrails , selectedLocks ) : null
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 ) ;
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 : "" } ) : "" ) ;
}
function mapUserMenu ( e : ReactLike [ "createElement" ] , view : ViewState , users : Array < { key : string ; label : string ; count : number } > ) {
const selected = view . mapUsers ;
const summary = selected === undefined ? "全部" : selected . length ? ` ${ selected . length } / ${ users . length } ` : "未选" ;
return e ( "details" , { className : "scum-map-menu" , "aria-label" : "轨迹用户筛选" } ,
e ( "summary" , { className : "icon-command" } , ` 用户 ${ summary } ` ) ,
e ( "div" , { className : "scum-map-menu-body" } ,
e ( "div" , { className : "console-row-actions" } ,
e ( "button" , { type : "button" , className : "icon-command" , onClick : ( ) = > view . setMapUsers ( users . map ( ( user ) = > user . key ) ) } , "全选" ) ,
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" } , "当前时间范围内没有用户轨迹。" )
)
) ;
}
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 ;
return e ( "details" , { className : "scum-map-menu" , "aria-label" : "地图图层筛选" } ,
e ( "summary" , { className : "icon-command" } , ` 图层 ${ enabled } / ${ layers . length } ${ zonesOverlay ? " + 建筑区" : "" } ` ) ,
e ( "div" , { className : "scum-map-menu-body" } ,
layers . map ( ( layer ) = > e ( "label" , { key : layer , className : "scum-layer-toggle" , title : layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined } , e ( "input" , { type : "checkbox" , checked : view.mapLayers [ layer ] , onChange : ( event : InputEvent ) = > view . setMapLayers ( ( previous ) = > ( { . . . previous , [ layer ] : Boolean ( event . target ? . checked ) } ) ) } ) , layerLabel ( layer ) ) ) ,
e ( "label" , { key : "zones" , className : "scum-layer-toggle" } , e ( "input" , { type : "checkbox" , checked : zonesOverlay , onChange : ( event : InputEvent ) = > onZones ( Boolean ( event . target ? . checked ) ) } ) , "建筑区" ) ,
e ( "p" , { className : "page-status" } , "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。" )
)
) ;
}
function persistMapLayers ( view : ViewState , actions : SCUMWorkspaceActions | undefined , baseLayer : ScumMapLayer , zones : boolean ) {
if ( ! actions ? . pluginData ) return ;
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 ;
return e ( "div" , { className : "map-playback-bar" } ,
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 ( "input" , { type : "range" , min : "0" , max : "1000" , step : "1" , value : String ( Math . round ( view . mapPlayback . value * 1000 ) ) , "aria-label" : "轨迹回放进度" , disabled : ! enabled , onChange : ( event : InputEvent ) = > view . setMapPlayback ( ( previous ) = > ( { . . . previous , playing : false , value : Math.min ( 1 , Math . max ( 0 , Number ( inputValue ( event ) ) / 1000 ) ) } ) ) } ) ,
e ( "span" , { className : "page-status" } , Number . isFinite ( cursor ) ? ` 回放 ${ timeLabel ( cursor ) } ` : "回放 全部" ) ,
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 ` ) ) ) ) ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! enabled , onClick : ( ) = > view . setMapPlayback ( scumMapPlaybackIdle ) } , "回到最新" )
) ;
}
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 ) ) {
const points = segment . points . filter ( ( point ) = > point . time <= cursor ) ;
if ( points . length < 2 ) continue ;
shapes . push ( e ( "polyline" , { key : ` ${ segment . key } : ${ points . length } ` , points : points.map ( ( point ) = > ` ${ point . x . toFixed ( 1 ) } , ${ point . y . toFixed ( 1 ) } ` ) . join ( " " ) , fill : "none" , stroke : track.color , strokeWidth : segment.mode === "ride" ? stroke * 1.2 : stroke , strokeLinecap : "round" , strokeLinejoin : "round" , strokeDasharray : segment.mode === "walk" ? ` ${ stroke * 2.4 } ${ stroke * 1.6 } ` : undefined , opacity : segment.mode === "ride" ? 0.95 : 0.75 } ) ) ;
}
return shapes ;
}
function mapTrackMarkers ( e : ReactLike [ "createElement" ] , tracks : MapTrack [ ] , cursor : number , catalog : Map < string , string > ) {
const markers : unknown [ ] = [ ] ;
for ( const track of tracks ) {
const points = track . points . filter ( ( point ) = > point . time <= cursor ) ;
if ( ! points . length ) 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 ) } ` } , "起" ) ) ;
if ( last !== first ) markers . push ( e ( "span" , { key : ` ${ track . key } :end ` , className : "map-track-endpoint map-track-end" , style : mapSceneStyle ( last ) , title : ` ${ track . label } 最新 ${ timeLabel ( last . time ) } ` } , "终" ) ) ;
let placed = 0 ;
for ( const segment of mapTrackSegments ( track ) ) {
if ( placed >= 4 || segment . mode !== "ride" || segment . points . length < 2 ) continue ;
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 = 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 : "" } ) : "车" ) ) ;
}
}
return markers ;
}
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 } ;
}
function mapTracks ( data : SCUMSurfaceData , bounds : RecordMap , window : MapTimeWindow , view : ViewState , catalog : Map < string , string > ) : MapTrack [ ] {
const grouped = new Map < string , MapTrack > ( ) ;
for ( const row of data . trajectories ) {
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" ) ;
if ( ! identity ) continue ;
if ( kind === "player" && view . mapUsers !== undefined && ! view . mapUsers . includes ( 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 : [ ] } ;
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 ) ;
track . to = Math . max ( track . to , time ) ;
grouped . set ( key , track ) ;
}
const tracks = [ . . . grouped . values ( ) ] ;
for ( const track of tracks ) {
track . points . sort ( ( left , right ) = > left . time - right . time ) ;
if ( track . points . length > scumMapTrackPointLimit ) track . points = track . points . slice ( - scumMapTrackPointLimit ) ;
track . walk = track . points . filter ( ( point ) = > ! point . riding ) . length ;
track . ride = track . points . length - track . walk ;
track . vehicles = track . vehicles . map ( ( code ) = > catalog . get ( code ) ? ? code ) ;
}
return tracks . sort ( ( left , right ) = > right . to - left . to ) . slice ( 0 , scumMapTrackLimit ) ;
}
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" ) ;
if ( ! identity ) continue ;
const time = trajectoryOrder ( row ) ;
if ( time < window . from || time > window . to ) continue ;
const current = users . get ( identity ) ? ? { key : identity , label : textField ( row , "displayName" , "name" ) || identity , count : 0 } ;
current . count += 1 ;
users . set ( identity , current ) ;
}
return [ . . . users . values ( ) ] . sort ( ( left , right ) = > right . count - left . count || left . label . localeCompare ( right . label ) ) ;
}
function toggleMapUser ( selected : string [ ] | undefined , key : string , checked : boolean , users : Array < { key : string } > ) : string [ ] {
const base = new Set ( selected ? ? users . map ( ( user ) = > user . key ) ) ;
if ( checked ) base . add ( key ) ;
else base . delete ( key ) ;
return [ . . . base ] ;
}
function mapTrackSegments ( track : MapTrack ) : MapTrackSegment [ ] {
const segments : MapTrackSegment [ ] = [ ] ;
let current : MapTrackPoint [ ] = [ ] ;
let mode : "walk" | "ride" = track . kind === "vehicle" ? "ride" : "walk" ;
for ( const point of track . points ) {
const nextMode : "walk" | "ride" = track . kind === "vehicle" || point . riding ? "ride" : "walk" ;
const previous = current [ current . length - 1 ] ;
const broken = previous ? nextMode !== mode || point . time - previous . time > scumMapSegmentGapMs || Math . hypot ( point . x - previous . x , point . y - previous . y ) > scumMapSegmentJump : false ;
if ( broken ) { segments . push ( { key : ` ${ track . key } :segment- ${ segments . length } ` , mode , points : current } ) ; current = [ ] ; }
mode = nextMode ;
current . push ( point ) ;
}
if ( current . length ) segments . push ( { key : ` ${ track . key } :segment- ${ segments . length } ` , mode , points : current } ) ;
return segments ;
}
function mapTrackPointList ( tracks : MapTrack [ ] ) : MapTrackPoint [ ] { return tracks . reduce < MapTrackPoint [ ] > ( ( all , track ) = > all . concat ( track . points ) , [ ] ) ; }
function mapVehicleClassIndex ( data : SCUMSurfaceData ) : Map < string , string > {
const index = new Map < string , string > ( ) ;
for ( const vehicle of data . vehicles ) {
const code = textField ( vehicle , "className" , "vehicleClass" , "entityClass" , "vehicleType" ) ;
if ( ! code ) continue ;
for ( const key of [ textField ( vehicle , "gameVehicleId" ) , textField ( vehicle , "vehicleId" ) , textField ( vehicle , "id" ) , textField ( vehicle , "label" ) ] . filter ( Boolean ) ) index . set ( key , code ) ;
}
return index ;
}
function mapRideCode ( row : RecordMap ) : string { return textField ( row , "className" , "vehicleClass" , "entityClass" , "vehicleType" , "riddenVehicleId" , "gameVehicleId" , "vehicleId" ) ; }
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 ] ;
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 ) ;
return value >= 1 ? { . . . previous , playing : false , value : 1 } : { . . . previous , value } ;
}
function zoomMapView ( previous : MapViewState , factor : number , anchorX : number , anchorY : number ) : MapViewState {
const zoom = clampMapZoom ( previous . zoom * factor ) ;
if ( zoom === previous . zoom ) return previous ;
const ratio = zoom / previous . zoom ;
return { zoom , x : anchorX - ( anchorX - previous . x ) * ratio , y : anchorY - ( anchorY - previous . y ) * ratio } ;
}
function clampMapZoom ( value : number ) : number { return Math . min ( scumMapZoomMax , Math . max ( scumMapZoomMin , Number . isFinite ( value ) ? value : scumMapZoomMin ) ) ; }
function defaultMapView ( node : HTMLElement | null ) : MapViewState {
const width = Number ( node ? . clientWidth ) || 0 ;
const height = Number ( node ? . clientHeight ) || 0 ;
return width > height && height > 0 ? { zoom : clampMapZoom ( width / height ) , x : 0 , y : 0 } : scumMapDefaultView ;
}
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 ;
for ( const point of points ) { minX = Math . min ( minX , point . x ) ; maxX = Math . max ( maxX , point . x ) ; minY = Math . min ( minY , point . y ) ; maxY = Math . max ( maxY , point . y ) ; }
const span = Math . max ( 48 , maxX - minX , maxY - minY ) ;
const zoom = clampMapZoom ( scumMapSize * padding / span ) ;
const size = Math . max ( 240 , Number ( node ? . clientHeight ) || 640 ) ;
return { zoom , x : - ( ( minX + maxX ) / 2 / scumMapSize - 0.5 ) * size * zoom , y : - ( ( minY + maxY ) / 2 / scumMapSize - 0.5 ) * size * zoom } ;
}
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 timeLabel ( value : number ) : string { if ( ! Number . isFinite ( value ) ) return "不限" ; const date = new Date ( value ) ; return Number . isNaN ( date . getTime ( ) ) ? "unknown" : date . toLocaleString ( ) ; }
function mapPointStrip ( e : ReactLike [ "createElement" ] , point : RecordMap , trails : RecordMap [ ] , locks : RecordMap [ ] ) {
function mapPointStrip ( e : ReactLike [ "createElement" ] , point : RecordMap , trails : RecordMap [ ] , locks : RecordMap [ ] ) {
const icon = layerOf ( point ) === "vehicles" ? vehicleIconFor ( point ) : "" ;
const icon = layerOf ( point ) === "vehicles" ? vehicleIconFor ( point ) : "" ;
return e ( "article" , { className : "console-module map-point-strip" } ,
return e ( "article" , { className : "console-module map-point-strip" } ,
@@ -683,13 +976,18 @@ function layerOf(point: RecordMap): MapLayer { const value = textField(point, "l
function layerLabel ( layer : MapLayer ) : string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他" ; }
function layerLabel ( layer : MapLayer ) : string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他" ; }
function pointTitle ( point : RecordMap ) : string { return textField ( point , "name" , "label" , "subjectName" ) || textField ( point , "subjectType" , "type" ) || textField ( point , "subjectId" , "id" ) || "地图点" ; }
function pointTitle ( point : RecordMap ) : string { return textField ( point , "name" , "label" , "subjectName" ) || textField ( point , "subjectType" , "type" ) || textField ( point , "subjectId" , "id" ) || "地图点" ; }
function mapPointIdentity ( point : RecordMap ) : string { const subject = textField ( point , "subjectId" , "gamePlayerId" , "vehicleId" , "flagId" , "regionId" ) ; if ( subject ) return ` ${ layerOf ( point ) } : ${ subject } ` ; const record = textField ( point , "id" , "_recordKey" ) ; if ( record ) return ` ${ layerOf ( point ) } : ${ record } ` ; return ` ${ layerOf ( point ) } : ${ pointTitle ( point ) . toLowerCase ( ) } : ${ numField ( point , "x" , "locationX" ) } : ${ numField ( point , "y" , "locationY" ) } ` ; }
function mapPointIdentity ( point : RecordMap ) : string { const subject = textField ( point , "subjectId" , "gamePlayerId" , "vehicleId" , "flagId" , "regionId" ) ; if ( subject ) return ` ${ layerOf ( point ) } : ${ subject } ` ; const record = textField ( point , "id" , "_recordKey" ) ; if ( record ) return ` ${ layerOf ( point ) } : ${ record } ` ; return ` ${ layerOf ( point ) } : ${ pointTitle ( point ) . toLowerCase ( ) } : ${ numField ( point , "x" , "locationX" ) } : ${ numField ( point , "y" , "locationY" ) } ` ; }
export function mapPointStyle ( point : RecordMap , bounds : RecordMap ) : Record < string , string > {
export function mapScenePoint ( point : RecordMap , bounds : RecordMap ) : { x : number ; y : number } {
const x = Number ( field ( point , "x" , "locationX" ) ? ? 0 ) ; const y = Number ( field ( point , "y" , "locationY" ) ? ? 0 ) ;
const x = Number ( field ( point , "x" , "locationX" ) ? ? 0 ) ; const y = Number ( field ( point , "y" , "locationY" ) ? ? 0 ) ;
const minX = Number ( field ( bounds , "worldMinX" ) ) ; const minY = Number ( field ( bounds , "worldMinY" ) ) ; const maxX = Number ( field ( bounds , "worldMaxX" ) ) ; const maxY = Number ( field ( bounds , "worldMaxY" ) ) ;
const minX = Number ( field ( bounds , "worldMinX" ) ) ; const minY = Number ( field ( bounds , "worldMinY" ) ) ; const maxX = Number ( field ( bounds , "worldMaxX" ) ) ; const maxY = Number ( field ( bounds , "worldMaxY" ) ) ;
const mapX = Number . isFinite ( minX ) && Number . isFinite ( maxX ) && maxX > minX ? scumMapSize - ( x - minX ) * scumMapSize / ( maxX - minX ) : scumMapSize / 2 ;
const sceneX = Number . isFinite ( minX ) && Number . isFinite ( maxX ) && maxX > minX ? scumMapSize - ( x - minX ) * scumMapSize / ( maxX - minX ) : scumMapSize / 2 ;
const mapY = Number . isFinite ( minY ) && Number . isFinite ( maxY ) && maxY > minY ? scumMapSize - ( y - minY ) * scumMapSize / ( maxY - minY ) : scumMapSize / 2 ;
const sceneY = Number . isFinite ( minY ) && Number . isFinite ( maxY ) && maxY > minY ? scumMapSize - ( y - minY ) * scumMapSize / ( maxY - minY ) : scumMapSize / 2 ;
const left = mapX / scumMapSize * 100 ;
return { x : sceneX , y : sceneY } ;
const top = mapY / scumMapSize * 100 ;
}
export function mapPointStyle ( point : RecordMap , bounds : RecordMap ) : Record < string , string > {
const scene = mapScenePoint ( point , bounds ) ;
const left = scene . x / scumMapSize * 100 ;
const top = scene . y / scumMapSize * 100 ;
return { left : ` ${ Math . max ( 1 , Math . min ( 99 , left ) ) } % ` , top : ` ${ Math . max ( 1 , Math . min ( 99 , top ) ) } % ` } ;
return { left : ` ${ Math . max ( 1 , Math . min ( 99 , left ) ) } % ` , top : ` ${ Math . max ( 1 , Math . min ( 99 , top ) ) } % ` } ;
}
}
@@ -698,7 +996,6 @@ function mapGridOverlay(e: ReactLike["createElement"]) {
return e ( "div" , { className : "map-grid-overlay" , "aria-hidden" : "true" } , breaks . map ( ( value ) = > e ( "span" , { key : ` v- ${ value } ` , className : "map-grid-line map-grid-line-v" , style : { left : ` ${ value } % ` } } ) ) , breaks . map ( ( value ) = > e ( "span" , { key : ` h- ${ value } ` , className : "map-grid-line map-grid-line-h" , style : { top : ` ${ value } % ` } } ) ) , cols . map ( ( label , index ) = > e ( "span" , { key : ` c- ${ label } ` , className : "map-grid-label map-grid-col-label" , style : { left : ` ${ ( index + 0.5 ) * 20 } % ` } } , label ) ) , rows . map ( ( label , index ) = > e ( "span" , { key : ` r- ${ label } ` , className : "map-grid-label map-grid-row-label" , style : { top : ` ${ ( index + 0.5 ) * 20 } % ` } } , label ) ) ) ;
return e ( "div" , { className : "map-grid-overlay" , "aria-hidden" : "true" } , breaks . map ( ( value ) = > e ( "span" , { key : ` v- ${ value } ` , className : "map-grid-line map-grid-line-v" , style : { left : ` ${ value } % ` } } ) ) , breaks . map ( ( value ) = > e ( "span" , { key : ` h- ${ value } ` , className : "map-grid-line map-grid-line-h" , style : { top : ` ${ value } % ` } } ) ) , cols . map ( ( label , index ) = > e ( "span" , { key : ` c- ${ label } ` , className : "map-grid-label map-grid-col-label" , style : { left : ` ${ ( index + 0.5 ) * 20 } % ` } } , label ) ) , rows . map ( ( label , index ) = > e ( "span" , { key : ` r- ${ label } ` , className : "map-grid-label map-grid-row-label" , style : { top : ` ${ ( index + 0.5 ) * 20 } % ` } } , label ) ) ) ;
}
}
function visibleTrajectoryPoints ( rows : RecordMap [ ] , layers : Record < MapLayer , boolean > , search : string ) : RecordMap [ ] { return rows . filter ( ( row ) = > ( layerOf ( row ) === "players" || layerOf ( row ) === "vehicles" ) && layers [ layerOf ( row ) ] && hasCoordinates ( positionOf ( row ) ) && matchesText ( row , search , "displayName" , "label" , "subjectId" , "steamId" , "vehicleId" , "subjectType" ) ) ; }
function trajectoryRecordsForPoint ( rows : RecordMap [ ] , point : RecordMap ) : RecordMap [ ] { const ids = new Set ( [ textField ( point , "subjectId" ) , textField ( point , "steamId" ) , textField ( point , "gamePlayerId" ) , textField ( point , "vehicleId" ) , textField ( point , "id" ) ] . filter ( Boolean ) ) ; const layer = layerOf ( point ) ; return rows . filter ( ( row ) = > layerOf ( row ) === layer && trajectoryIdentity ( row ) . some ( ( identity ) = > ids . has ( identity ) ) ) ; }
function trajectoryRecordsForPoint ( rows : RecordMap [ ] , point : RecordMap ) : RecordMap [ ] { const ids = new Set ( [ textField ( point , "subjectId" ) , textField ( point , "steamId" ) , textField ( point , "gamePlayerId" ) , textField ( point , "vehicleId" ) , textField ( point , "id" ) ] . filter ( Boolean ) ) ; const layer = layerOf ( point ) ; return rows . filter ( ( row ) = > layerOf ( row ) === layer && trajectoryIdentity ( row ) . some ( ( identity ) = > ids . has ( identity ) ) ) ; }
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 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 trajectoryIdentity ( row : RecordMap ) : string [ ] { return [ textField ( row , "subjectId" ) , textField ( row , "steamId" ) , textField ( row , "gamePlayerId" ) , textField ( row , "vehicleId" ) , textField ( row , "id" ) ] . filter ( Boolean ) ; }