Files
planet/scripts/mock-ais-ws-server.ts
2026-05-07 18:06:06 +08:00

286 lines
9.4 KiB
TypeScript

import type { ServerWebSocket } from "bun"
type Anchor = { lat: number; lon: number }
type StreamConfig = {
anchor: Anchor
spreadKm: number
rateMs: number
sogKn: number
maxVessels: number
}
type VesselState = {
mmsi: number
name: string
lat: number
lon: number
sog: number
cog: number
heading: number
vesselType: number
vesselTypeName: string
}
type SocketState = {
config: StreamConfig
vessels: VesselState[]
sequence: number
timer: ReturnType<typeof setInterval> | null
}
const REGION_PRESETS: Record<string, Anchor> = {
mediterranean: { lat: 36.2, lon: 14.2 },
shanghai: { lat: 31.1, lon: 121.25 },
east_asia: { lat: 31.1, lon: 121.25 },
north_sea: { lat: 56.2, lon: 3.2 },
norway: { lat: 59.9, lon: 10.7 },
}
const port = Number(Bun.env.MOCK_AIS_WS_PORT || 8787)
const baseMmsi = Number(Bun.env.MOCK_AIS_BASE_MMSI || 999000000)
const region = String(Bun.env.MOCK_AIS_REGION || "mediterranean").toLowerCase()
const defaultAnchor: Anchor = REGION_PRESETS[region] ?? REGION_PRESETS.mediterranean
const defaultIntervalMs = clamp(Number(Bun.env.MOCK_AIS_WS_INTERVAL_MS || 1500), 200, 30_000)
const defaultSpreadKm = clamp(Number(Bun.env.MOCK_AIS_SPREAD_KM || 60), 1, 5_000)
const defaultSogKn = clamp(Number(Bun.env.MOCK_AIS_SOG_KN || 12), 0, 60)
const defaultMaxVessels = clamp(Number(Bun.env.MOCK_AIS_MAX_VESSELS || 12), 1, 200)
const KN_TO_DEG_LAT_PER_SEC = 1 / 60 / 60 // 1 nautical mile = 1/60 degree of latitude; per second
const VESSEL_TYPE_BANK: Array<{ type: number; name: string }> = [
{ type: 70, name: "Cargo" },
{ type: 80, name: "Tanker" },
{ type: 60, name: "Passenger" },
{ type: 30, name: "Fishing" },
{ type: 35, name: "Military" },
]
function clamp(value: number, min: number, max: number): number {
if (!Number.isFinite(value)) return min
return Math.min(Math.max(value, min), max)
}
function defaultConfig(): StreamConfig {
return {
anchor: { ...defaultAnchor },
spreadKm: defaultSpreadKm,
rateMs: defaultIntervalMs,
sogKn: defaultSogKn,
maxVessels: defaultMaxVessels,
}
}
function applyOverrides(base: StreamConfig, overrides: Record<string, unknown>): StreamConfig {
const next: StreamConfig = {
anchor: { ...base.anchor },
spreadKm: base.spreadKm,
rateMs: base.rateMs,
sogKn: base.sogKn,
maxVessels: base.maxVessels,
}
const anchor = overrides?.anchor
if (anchor && typeof anchor === "object") {
const lat = Number((anchor as Record<string, unknown>).lat)
const lon = Number((anchor as Record<string, unknown>).lon)
if (Number.isFinite(lat) && Number.isFinite(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
next.anchor = { lat, lon }
}
}
if (Number.isFinite(Number(overrides.lat)) && Number.isFinite(Number(overrides.lon))) {
const lat = Number(overrides.lat)
const lon = Number(overrides.lon)
if (lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
next.anchor = { lat, lon }
}
}
if (Number.isFinite(Number(overrides.spread_km))) {
next.spreadKm = clamp(Number(overrides.spread_km), 1, 5_000)
}
if (Number.isFinite(Number(overrides.rate_hz)) && Number(overrides.rate_hz) > 0) {
next.rateMs = clamp(1000 / Number(overrides.rate_hz), 100, 30_000)
}
if (Number.isFinite(Number(overrides.rate_ms))) {
next.rateMs = clamp(Number(overrides.rate_ms), 100, 30_000)
}
if (Number.isFinite(Number(overrides.sog_kn))) {
next.sogKn = clamp(Number(overrides.sog_kn), 0, 60)
}
if (Number.isFinite(Number(overrides.max_vessels))) {
next.maxVessels = clamp(Number(overrides.max_vessels), 1, 200)
}
return next
}
function spawnVessel(state: SocketState, index: number): VesselState {
const { anchor, spreadKm, sogKn } = state.config
const angle = Math.random() * Math.PI * 2
const radius = Math.random() * spreadKm
const dLat = (radius * Math.cos(angle)) / 111
const dLon = (radius * Math.sin(angle)) / (111 * Math.cos((anchor.lat * Math.PI) / 180) || 1)
const cog = Math.random() * 360
const profile = VESSEL_TYPE_BANK[index % VESSEL_TYPE_BANK.length]
return {
mmsi: baseMmsi + index + 1,
name: `MOCK VESSEL ${String(index + 1).padStart(3, "0")}`,
lat: clamp(anchor.lat + dLat, -89.999, 89.999),
lon: ((anchor.lon + dLon + 540) % 360) - 180,
sog: clamp(sogKn * (0.6 + Math.random() * 0.6), 0, 60),
cog,
heading: Math.round(cog),
vesselType: profile.type,
vesselTypeName: profile.name,
}
}
function advanceVessel(vessel: VesselState, dtSeconds: number) {
const radians = (vessel.cog * Math.PI) / 180
const speedDegPerSec = vessel.sog * KN_TO_DEG_LAT_PER_SEC
const dLat = speedDegPerSec * Math.cos(radians) * dtSeconds
const dLon = (speedDegPerSec * Math.sin(radians) * dtSeconds) / (Math.cos((vessel.lat * Math.PI) / 180) || 1)
vessel.lat = clamp(vessel.lat + dLat, -89.999, 89.999)
vessel.lon = ((vessel.lon + dLon + 540) % 360) - 180
// small course wander so the path isn't a straight line
vessel.cog = (vessel.cog + (Math.random() - 0.5) * 4 + 360) % 360
vessel.heading = Math.round(vessel.cog)
}
function pickNextVessel(state: SocketState): VesselState {
state.sequence += 1
if (state.vessels.length === 0 || (state.sequence % 4 === 0 && state.vessels.length < state.config.maxVessels)) {
const vessel = spawnVessel(state, state.vessels.length)
state.vessels.push(vessel)
return vessel
}
const vessel = state.vessels[state.sequence % state.vessels.length]
advanceVessel(vessel, state.config.rateMs / 1000)
return vessel
}
function buildPayload(state: SocketState, vessel: VesselState) {
return {
type: "vessel",
sequence: state.sequence,
config: {
anchor: state.config.anchor,
spread_km: state.config.spreadKm,
rate_ms: state.config.rateMs,
sog_kn: state.config.sogKn,
max_vessels: state.config.maxVessels,
},
data: {
mmsi: String(vessel.mmsi),
name: vessel.name,
lat: Number(vessel.lat.toFixed(6)),
lon: Number(vessel.lon.toFixed(6)),
sog: Number(vessel.sog.toFixed(2)),
cog: Number(vessel.cog.toFixed(1)),
heading: vessel.heading,
vessel_type: vessel.vesselType,
vessel_type_name: vessel.vesselTypeName,
source_note: `mock:${region}`,
received_at: new Date().toISOString(),
},
}
}
const sockets = new Map<ServerWebSocket<unknown>, SocketState>()
function startTimer(socket: ServerWebSocket<unknown>, state: SocketState) {
if (state.timer) clearInterval(state.timer)
state.timer = setInterval(() => {
const vessel = pickNextVessel(state)
const payload = buildPayload(state, vessel)
try {
socket.send(JSON.stringify(payload))
} catch {
// socket already closed; cleanup happens in close()
}
}, state.config.rateMs)
}
const server = Bun.serve({
port,
fetch(request, server) {
const url = new URL(request.url)
if (url.pathname !== "/ais") {
return new Response("Mock AIS WS server. Connect to /ais.", { status: 200 })
}
if (server.upgrade(request)) {
return undefined
}
return new Response("WebSocket upgrade failed", { status: 400 })
},
websocket: {
open(socket) {
const state: SocketState = {
config: defaultConfig(),
vessels: [],
sequence: 0,
timer: null,
}
sockets.set(socket, state)
socket.send(
JSON.stringify({
type: "hello",
source: "mock_ais_ws",
config: {
anchor: state.config.anchor,
spread_km: state.config.spreadKm,
rate_ms: state.config.rateMs,
sog_kn: state.config.sogKn,
max_vessels: state.config.maxVessels,
},
subscribe_hint:
"Send { type: 'subscribe', anchor: { lat, lon }, spread_km, rate_hz, sog_kn, max_vessels } to retarget.",
}),
)
startTimer(socket, state)
},
message(socket, message) {
const state = sockets.get(socket)
if (!state) return
let parsed: Record<string, unknown> = {}
try {
parsed = JSON.parse(typeof message === "string" ? message : message.toString())
} catch {
return
}
if (!parsed || typeof parsed !== "object") return
const overrides = (parsed as { type?: unknown }).type === "subscribe" ? parsed : parsed
const before = state.config
state.config = applyOverrides(state.config, overrides)
const anchorChanged =
before.anchor.lat !== state.config.anchor.lat || before.anchor.lon !== state.config.anchor.lon
if (anchorChanged) {
// throw away old vessels so new ones spawn at the new anchor
state.vessels = []
state.sequence = 0
}
console.info("[mock-ais-ws] config update", state.config)
startTimer(socket, state)
socket.send(
JSON.stringify({
type: "subscribe_ack",
config: {
anchor: state.config.anchor,
spread_km: state.config.spreadKm,
rate_ms: state.config.rateMs,
sog_kn: state.config.sogKn,
max_vessels: state.config.maxVessels,
},
}),
)
},
close(socket) {
const state = sockets.get(socket)
if (state?.timer) clearInterval(state.timer)
sockets.delete(socket)
},
},
})
console.info(`[mock-ais-ws] listening on ws://localhost:${server.port}/ais`)
console.info(
`[mock-ais-ws] defaults region=${region} anchor=${JSON.stringify(defaultAnchor)} spread_km=${defaultSpreadKm} rate_ms=${defaultIntervalMs} sog_kn=${defaultSogKn} max_vessels=${defaultMaxVessels}`,
)