feat: v0.11.0 — map interactions per PLAN.md: expanded airport popup (approx local time + deterministic generated weather), animated plane markers from /api/state polls with status popups, active contract route lines (pending dashed vs in-flight solid); pure libs lib/weather.ts, lib/localTime.ts, lib/mapData.ts
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { approxLocalTime, approxZoneForLon } from './localTime';
|
||||
|
||||
describe('approxZoneForLon', () => {
|
||||
it('maps longitude to the Etc/GMT±N zone with the POSIX sign flip', () => {
|
||||
// UTC+1 (lon +15) is "Etc/GMT-1"; UTC−2 (lon −30) is "Etc/GMT2".
|
||||
expect(approxZoneForLon(15)).toBe('Etc/GMT-1');
|
||||
expect(approxZoneForLon(-30)).toBe('Etc/GMT2');
|
||||
expect(approxZoneForLon(0)).toBe('Etc/GMT');
|
||||
});
|
||||
|
||||
it('rounds to the nearest 15° band and clamps at ±12', () => {
|
||||
expect(approxZoneForLon(10)).toBe('Etc/GMT-1'); // Frankfurt ≈ UTC+1
|
||||
expect(approxZoneForLon(-6.2)).toBe('Etc/GMT'); // Lisbon: round(-0.41) = 0 → plain Etc/GMT (UTC)
|
||||
expect(approxZoneForLon(179)).toBe('Etc/GMT-12');
|
||||
expect(approxZoneForLon(-179)).toBe('Etc/GMT12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approxLocalTime', () => {
|
||||
const noonUtc = Date.UTC(2026, 8, 17, 12, 0); // 2026-09-17 12:00 UTC
|
||||
|
||||
it('formats HH:MM in the approximate zone (deterministic)', () => {
|
||||
expect(approxLocalTime(15, noonUtc)).toBe('13:00'); // east of Greenwich → UTC+1
|
||||
expect(approxLocalTime(-30, noonUtc)).toBe('10:00'); // west of Greenwich → UTC−2
|
||||
expect(approxLocalTime(0, noonUtc)).toBe('12:00'); // UTC
|
||||
});
|
||||
|
||||
it('wraps across midnight', () => {
|
||||
const lateUtc = Date.UTC(2026, 8, 17, 23, 30);
|
||||
expect(approxLocalTime(45, lateUtc)).toBe('02:30'); // UTC+3 → next day
|
||||
const earlyUtc = Date.UTC(2026, 8, 17, 1, 30);
|
||||
expect(approxLocalTime(-45, earlyUtc)).toBe('22:30'); // UTC−3 → previous day
|
||||
});
|
||||
|
||||
it('always yields a two-digit HH:MM string', () => {
|
||||
for (const lon of [-170, -6.2, 8.56, 45, 179]) {
|
||||
expect(approxLocalTime(lon, noonUtc)).toMatch(/^\d{2}:\d{2}$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Approximate local wall-clock from longitude (v0.11.0) — display-only airport
|
||||
// popup field. The zone is the fixed-offset band N = round(lon/15), no DST and no
|
||||
// political boundaries — good enough for a game map, labelled approximate in the UI.
|
||||
|
||||
// Etc/GMT±N name for the band (POSIX sign flip: UTC+1 is "Etc/GMT-1"). Exported as
|
||||
// the documented pure mapping; approxLocalTime below does NOT pass it to Intl,
|
||||
// because some runtimes ship a reduced ICU that lacks the positive-offset zones.
|
||||
export function approxZoneForLon(lonDeg: number): string {
|
||||
const n = Math.max(-12, Math.min(12, Math.round(lonDeg / 15)));
|
||||
return `Etc/GMT${n === 0 ? '' : -n}`;
|
||||
}
|
||||
|
||||
// "HH:MM" in the approximate zone at epoch ms. Implemented as a UTC-format of a
|
||||
// shifted timestamp (UTC + N hours) so it works on every ICU build, including
|
||||
// small-icu runtimes missing Etc/GMT+N zones.
|
||||
export function approxLocalTime(lonDeg: number, atMs: number): string {
|
||||
const n = Math.max(-12, Math.min(12, Math.round(lonDeg / 15)));
|
||||
const shifted = new Date(atMs + n * 3_600_000);
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'UTC',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).format(shifted);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { planesGeoJson, routesGeoJson, type AirportCoord } from './mapData';
|
||||
|
||||
const AIRPORTS = new Map<string, AirportCoord>([
|
||||
['EDDF', { lat: 50.03, lon: 8.56 }],
|
||||
['EDDV', { lat: 52.46, lon: 9.68 }],
|
||||
['EHAM', { lat: 52.31, lon: 4.76 }],
|
||||
]);
|
||||
|
||||
function snapshot(overrides: Partial<StateSnapshot> = {}): StateSnapshot {
|
||||
return {
|
||||
account: { cash: 0, reputation: 0 },
|
||||
simState: { clockMs: 0, timeScale: 1, paused: false },
|
||||
planes: [],
|
||||
contracts: [],
|
||||
catalog: [{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, speedKmh: 510 }],
|
||||
fuelPricePerL: 0.8,
|
||||
sellFraction: 0.5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('planesGeoJson', () => {
|
||||
it('returns an empty collection for a null snapshot', () => {
|
||||
expect(planesGeoJson(null)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('emits one Point per plane at its polled lat/lon with popup properties', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'plane-starter', model: 'atr72', status: 'available', fuel: 6300, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
{ id: 'plane-2', model: 'atr72', status: 'flying', fuel: 4000, maxFuel: 6300, locationIcao: 'EDDF', locationName: 'Frankfurt Main Airport', contractId: 'c1', departureSimTime: 1000, lat: 51.2, lon: 9.1 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'Frankfurt Main Airport', destIcao: 'EDDV', destName: 'Hannover Airport', reward: 100, cargoType: 'mail', cargoCount: 5, deadlineSimTime: 9e8, status: 'accepted', planeId: 'plane-2', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(fc.features).toHaveLength(2);
|
||||
const grounded = fc.features[0];
|
||||
expect(grounded.geometry.coordinates).toEqual([8.56, 50.03]); // [lon, lat]
|
||||
expect(grounded.properties.flying).toBe(false);
|
||||
expect(grounded.properties.modelLabel).toBe('ATR 72-600'); // catalog label, not raw model id
|
||||
expect(grounded.properties.fuelText).toBe('6300 / 6300 L');
|
||||
expect(grounded.properties.routeText).toBe('');
|
||||
|
||||
const flying = fc.features[1];
|
||||
expect(flying.geometry.coordinates).toEqual([9.1, 51.2]); // live polled position
|
||||
expect(flying.properties.flying).toBe(true);
|
||||
expect(flying.properties.status).toBe('flying');
|
||||
expect(flying.properties.fuelText).toBe('4000 / 6300 L');
|
||||
expect(flying.properties.routeText).toBe('Frankfurt Main Airport → Hannover Airport');
|
||||
});
|
||||
|
||||
it('falls back to the raw model id when the catalog has no entry', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
catalog: [],
|
||||
planes: [
|
||||
{ id: 'p1', model: 'mystery', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: null, departureSimTime: null, lat: 0, lon: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(fc.features[0].properties.modelLabel).toBe('mystery');
|
||||
});
|
||||
|
||||
it('leaves routeText empty for a flying plane whose contract is missing from the snapshot', () => {
|
||||
const fc = planesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'x', contractId: 'gone', departureSimTime: 0, lat: 0, lon: 0 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(fc.features[0].properties.routeText).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('routesGeoJson', () => {
|
||||
it('returns an empty collection for a null snapshot', () => {
|
||||
expect(routesGeoJson(null, AIRPORTS)).toEqual({ type: 'FeatureCollection', features: [] });
|
||||
});
|
||||
|
||||
it('draws nothing for open/completed/expired contracts', () => {
|
||||
const mk = (status: string) => ({ id: `c-${status}`, originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status, planeId: null, createdSimTime: 0 });
|
||||
const fc = routesGeoJson(
|
||||
snapshot({ contracts: [mk('open'), mk('completed'), mk('expired')] as StateSnapshot['contracts'] }),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepted-but-undispatched contract → pending line origin→dest', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EHAM', destName: 'A', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties).toEqual({ contractId: 'c1', kind: 'pending' });
|
||||
expect(fc.features[0].geometry.coordinates).toEqual([
|
||||
[8.56, 50.03], // EDDF [lon, lat]
|
||||
[4.76, 52.31], // EHAM
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepted + plane flying → inflight line', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'flying', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: 'c1', departureSimTime: 0, lat: 51, lon: 9 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(1);
|
||||
expect(fc.features[0].properties.kind).toBe('inflight');
|
||||
});
|
||||
|
||||
it('accepted + planeId set but plane grounded → still pending (defensive)', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
planes: [
|
||||
{ id: 'p1', model: 'atr72', status: 'available', fuel: 1, maxFuel: 2, locationIcao: 'EDDF', locationName: 'F', contractId: null, departureSimTime: null, lat: 50.03, lon: 8.56 },
|
||||
],
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'EDDF', originName: 'F', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: 'p1', createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features[0].properties.kind).toBe('pending');
|
||||
});
|
||||
|
||||
it('skips a contract whose endpoint is missing from the airport dataset', () => {
|
||||
const fc = routesGeoJson(
|
||||
snapshot({
|
||||
contracts: [
|
||||
{ id: 'c1', originIcao: 'ZZZZ', originName: '?', destIcao: 'EDDV', destName: 'H', reward: 1, cargoType: 'mail', cargoCount: 1, deadlineSimTime: 9e8, status: 'accepted', planeId: null, createdSimTime: 0 },
|
||||
],
|
||||
}),
|
||||
AIRPORTS,
|
||||
);
|
||||
expect(fc.features).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
import type { StateSnapshot } from './game/state';
|
||||
import { formatFuel } from './format';
|
||||
|
||||
// Pure GeoJSON builders for the map's dynamic layers (v0.11.0): one Point feature
|
||||
// per plane, one LineString per accepted/in-flight contract. Kept out of
|
||||
// components/Map.tsx so the classification logic is unit-testable without a DOM.
|
||||
|
||||
export interface PlaneFeatureProps {
|
||||
id: string;
|
||||
modelLabel: string;
|
||||
status: 'available' | 'flying';
|
||||
fuelText: string; // "1234 / 6300 L"
|
||||
routeText: string; // "Origin → Destination" while flying, '' when grounded
|
||||
flying: boolean;
|
||||
}
|
||||
|
||||
export interface RouteFeatureProps {
|
||||
contractId: string;
|
||||
kind: 'pending' | 'inflight';
|
||||
}
|
||||
|
||||
export interface MapPointFeature {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: PlaneFeatureProps;
|
||||
}
|
||||
|
||||
export interface MapLineFeature {
|
||||
type: 'Feature';
|
||||
geometry: { type: 'LineString'; coordinates: [number, number][] };
|
||||
properties: RouteFeatureProps;
|
||||
}
|
||||
|
||||
export interface FeatureCollection<T> {
|
||||
type: 'FeatureCollection';
|
||||
features: T[];
|
||||
}
|
||||
|
||||
const EMPTY_POINTS: FeatureCollection<MapPointFeature> = { type: 'FeatureCollection', features: [] };
|
||||
const EMPTY_LINES: FeatureCollection<MapLineFeature> = { type: 'FeatureCollection', features: [] };
|
||||
|
||||
export function planesGeoJson(state: StateSnapshot | null): FeatureCollection<MapPointFeature> {
|
||||
if (!state) return EMPTY_POINTS;
|
||||
const contractById = new Map(state.contracts.map((c) => [c.id, c]));
|
||||
// Catalog display name ("ATR 72-600") for the popup — raw model id as fallback.
|
||||
const labelByModel = new Map(state.catalog.map((e) => [e.model, e.label]));
|
||||
const features: MapPointFeature[] = state.planes.map((p) => {
|
||||
const flying = p.status === 'flying';
|
||||
const contract = p.contractId ? contractById.get(p.contractId) : undefined;
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||
properties: {
|
||||
id: p.id,
|
||||
modelLabel: labelByModel.get(p.model) ?? p.model,
|
||||
status: p.status,
|
||||
fuelText: formatFuel(p.fuel, p.maxFuel),
|
||||
routeText: flying && contract ? `${contract.originName} → ${contract.destName}` : '',
|
||||
flying,
|
||||
},
|
||||
};
|
||||
});
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
|
||||
// Airport coordinate lookup for route endpoints (from the /api/airports GeoJSON).
|
||||
export interface AirportCoord {
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
// One LineString per contract that is accepted but not yet dispatched ('pending')
|
||||
// or already flying ('inflight' — planeId set and that plane's status is 'flying').
|
||||
// Open/completed/expired contracts draw nothing.
|
||||
export function routesGeoJson(
|
||||
state: StateSnapshot | null,
|
||||
airportsByIcao: ReadonlyMap<string, AirportCoord>,
|
||||
): FeatureCollection<MapLineFeature> {
|
||||
if (!state) return EMPTY_LINES;
|
||||
const planeById = new Map(state.planes.map((p) => [p.id, p]));
|
||||
const features: MapLineFeature[] = [];
|
||||
for (const c of state.contracts) {
|
||||
if (c.status !== 'accepted') continue;
|
||||
const from = airportsByIcao.get(c.originIcao);
|
||||
const to = airportsByIcao.get(c.destIcao);
|
||||
if (!from || !to) continue; // endpoint missing from the dataset — skip, don't guess
|
||||
const plane = c.planeId ? planeById.get(c.planeId) : undefined;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[from.lon, from.lat],
|
||||
[to.lon, to.lat],
|
||||
],
|
||||
},
|
||||
properties: { contractId: c.id, kind: plane?.status === 'flying' ? 'inflight' : 'pending' },
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { weatherFor, type AirportWeather } from './weather';
|
||||
|
||||
const CONDITIONS = ['Clear', 'Cloudy', 'Rain', 'Fog'];
|
||||
|
||||
describe('weatherFor', () => {
|
||||
it('is deterministic: same ICAO → identical weather on every call', () => {
|
||||
const a = weatherFor('EDDF');
|
||||
for (let i = 0; i < 5; i++) expect(weatherFor('EDDF')).toEqual(a);
|
||||
});
|
||||
|
||||
it('different ICACOs mostly produce different weather', () => {
|
||||
const icaos = ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF', 'EKCH', 'LSGG', 'BIAR', 'LFRS', 'EDDV'];
|
||||
const seen = new Set(icaos.map((i) => JSON.stringify(weatherFor(i))));
|
||||
// 10 airports, 4 conditions × many wind/visibility values — collisions are vanishingly unlikely.
|
||||
expect(seen.size).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('keeps every value in a sane range', () => {
|
||||
for (const icao of ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF', 'EKCH', 'LSGG', 'BIAR']) {
|
||||
const w = weatherFor(icao);
|
||||
expect(CONDITIONS).toContain(w.condition);
|
||||
expect(w.windDirDeg).toBeGreaterThanOrEqual(0);
|
||||
expect(w.windDirDeg).toBeLessThan(360);
|
||||
expect(Number.isInteger(w.windDirDeg)).toBe(true);
|
||||
expect(w.windKt).toBeGreaterThanOrEqual(2);
|
||||
expect(w.windKt).toBeLessThanOrEqual(30);
|
||||
expect(w.visibilityM).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('fog and rain reduce visibility relative to clear skies', () => {
|
||||
// Find one airport per condition by scanning a fixed set (deterministic data).
|
||||
const byCondition = new Map<string, AirportWeather>();
|
||||
for (let i = 0; i < 200 && byCondition.size < CONDITIONS.length; i++) {
|
||||
const w = weatherFor(`TEST${i}`);
|
||||
if (!byCondition.has(w.condition)) byCondition.set(w.condition, w);
|
||||
}
|
||||
expect(byCondition.size).toBe(CONDITIONS.length);
|
||||
const fog = byCondition.get('Fog')!;
|
||||
const rain = byCondition.get('Rain')!;
|
||||
const clear = byCondition.get('Clear')!;
|
||||
expect(fog.visibilityM).toBeLessThan(2500); // 0.5–2 km band
|
||||
expect(rain.visibilityM).toBeGreaterThanOrEqual(4000);
|
||||
expect(rain.visibilityM).toBeLessThanOrEqual(10_000);
|
||||
expect(clear.visibilityM).toBeGreaterThanOrEqual(8000);
|
||||
});
|
||||
|
||||
it('dayKey: same day is deterministic, and some airports do change across days', () => {
|
||||
const icaos = ['EDDF', 'EHAM', 'LFPG', 'LEMD', 'LIRF'];
|
||||
for (const icao of icaos) {
|
||||
expect(weatherFor(icao, 3)).toEqual(weatherFor(icao, 3));
|
||||
}
|
||||
let changed = 0;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const a = weatherFor(`TEST${i}`, 1);
|
||||
const b = weatherFor(`TEST${i}`, 2);
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) changed++;
|
||||
}
|
||||
expect(changed).toBeGreaterThan(0); // the dayKey actually feeds the seed
|
||||
});
|
||||
|
||||
it('no dayKey is stable and independent of any later dayKey argument', () => {
|
||||
const w = weatherFor('EDDF');
|
||||
expect(weatherFor('EDDF')).toEqual(w);
|
||||
// A defined dayKey may differ from the default — that is allowed, not asserted.
|
||||
expect(CONDITIONS).toContain(weatherFor('EDDF', 0).condition);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mulberry32 } from './game/seed';
|
||||
|
||||
// Pure deterministic per-airport weather (v0.11.0) — display-only per PLAN.md, no
|
||||
// gameplay effects. The same ICAO always yields the same weather (stable across
|
||||
// polls and sessions); an optional dayKey varies it by sim-day when wanted.
|
||||
|
||||
export type WeatherCondition = 'Clear' | 'Cloudy' | 'Rain' | 'Fog';
|
||||
|
||||
export interface AirportWeather {
|
||||
condition: WeatherCondition;
|
||||
// Meteorological convention: direction the wind blows FROM, degrees true [0, 360).
|
||||
windDirDeg: number;
|
||||
windKt: number;
|
||||
visibilityM: number;
|
||||
}
|
||||
|
||||
// FNV-1a 32-bit — cheap, stable string hash (no Math.random, no Date anywhere).
|
||||
function fnv1a(s: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h ^= s.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
export function weatherFor(icao: string, dayKey?: number): AirportWeather {
|
||||
const seed = (fnv1a(icao) ^ (dayKey === undefined ? 0 : Math.imul(dayKey | 0, 0x9e3779b9))) >>> 0;
|
||||
const rng = mulberry32(seed);
|
||||
|
||||
const c = rng();
|
||||
let condition: WeatherCondition;
|
||||
if (c < 0.45) condition = 'Clear';
|
||||
else if (c < 0.75) condition = 'Cloudy';
|
||||
else if (c < 0.92) condition = 'Rain';
|
||||
else condition = 'Fog';
|
||||
|
||||
const windDirDeg = Math.floor(rng() * 360);
|
||||
const windKt = Math.round(2 + rng() * 28); // 2–30 kt
|
||||
|
||||
let visibilityM: number;
|
||||
if (condition === 'Fog') visibilityM = 500 + Math.floor(rng() * 1500); // 0.5–2 km
|
||||
else if (condition === 'Rain') visibilityM = 4000 + Math.floor(rng() * 6000); // 4–10 km
|
||||
else visibilityM = 8000 + Math.floor(rng() * 32000); // 8–40 km
|
||||
|
||||
return { condition, windDirDeg, windKt, visibilityM };
|
||||
}
|
||||
Reference in New Issue
Block a user