286 lines
9.4 KiB
TypeScript
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}`,
|
|
)
|