release: bump version to 0.48.0
This commit is contained in:
285
scripts/mock-ais-ws-server.ts
Normal file
285
scripts/mock-ais-ws-server.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
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}`,
|
||||
)
|
||||
Reference in New Issue
Block a user