From a8aa916076fb0424d70b326cd9d1672343f72520 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 19:41:15 +0900 Subject: [PATCH] fix(map): align prediction vectors with ship course + improve contrast --- apps/web/src/widgets/map3d/Map3D.tsx | 88 +++++++++++++++++++++------- 1 file changed, 68 insertions(+), 20 deletions(-) diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 16a537a..95f18a6 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -370,6 +370,15 @@ function normalizeAngleDeg(value: number, offset = 0): number { return ((v % 360) + 360) % 360; } +function toValidBearingDeg(value: unknown): number | null { + if (typeof value !== "number" || !Number.isFinite(value)) return null; + // AIS heading uses 511 as "not available". Some feeds may also use 360 as "not available". + if (value === 511) return null; + if (value < 0) return null; + if (value >= 360) return null; + return value; +} + function getDisplayHeading({ cog, heading, @@ -379,8 +388,8 @@ function getDisplayHeading({ heading: number | null | undefined; offset?: number; }) { - const raw = - isFiniteNumber(heading) && heading >= 0 && heading <= 360 && heading !== 511 ? heading : isFiniteNumber(cog) ? cog : 0; + // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned. + const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0; return normalizeAngleDeg(raw, offset); } @@ -1329,16 +1338,18 @@ export function Map3D({ const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusZoom = fleetFocus?.zoom; - const reorderGlobeFeatureLayers = useCallback(() => { - const map = mapRef.current; - if (!map || projectionRef.current !== "globe") return; - if (projectionBusyRef.current) return; + const reorderGlobeFeatureLayers = useCallback(() => { + const map = mapRef.current; + if (!map || projectionRef.current !== "globe") return; + if (projectionBusyRef.current) return; const ordering = [ "zones-fill", "zones-line", "zones-label", + "predict-vectors-outline", "predict-vectors", + "predict-vectors-hl-outline", "predict-vectors-hl", "ships-globe-halo", "ships-globe-outline", @@ -2457,7 +2468,9 @@ export function Map3D({ if (!map) return; const srcId = "predict-vectors-src"; + const outlineId = "predict-vectors-outline"; const lineId = "predict-vectors"; + const hlOutlineId = "predict-vectors-hl-outline"; const hlId = "predict-vectors-hl"; const ensure = () => { @@ -2480,20 +2493,21 @@ export function Map3D({ if (!isTarget && !isSelected && !isPinnedHighlight) continue; const sog = isFiniteNumber(t.sog) ? t.sog : null; - const cog = - isFiniteNumber(t.cog) ? t.cog : isFiniteNumber(t.heading) ? t.heading : null; - if (sog == null || cog == null) continue; + const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading); + if (sog == null || bearing == null) continue; if (sog < 0.2) continue; const distM = sog * metersPerSecondPerKnot * horizonSeconds; if (!Number.isFinite(distM) || distM <= 0) continue; - const to = destinationPointLngLat([t.lon, t.lat], cog, distM); + const to = destinationPointLngLat([t.lon, t.lat], bearing, distM); - const rgb = isTarget + const baseRgb = isTarget ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving : OTHER_AIS_SPEED_RGB.moving; - const alpha = isTarget ? 0.48 : 0.28; + const rgb = lightenColor(baseRgb, isTarget ? 0.55 : 0.62); + const alpha = isTarget ? 0.72 : 0.52; + const alphaHl = isTarget ? 0.92 : 0.84; const hl = isSelected || isPinnedHighlight ? 1 : 0; features.push({ @@ -2504,10 +2518,11 @@ export function Map3D({ mmsi: t.mmsi, minutes: horizonMinutes, sog, - cog, + cog: bearing, target: isTarget ? 1 : 0, hl, color: rgbaCss(rgb, alpha), + colorHl: rgbaCss(rgb, alphaHl), }, }); } @@ -2524,7 +2539,7 @@ export function Map3D({ return; } - const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter?: unknown[]) => { + const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => { if (!map.getLayer(id)) { try { map.addLayer( @@ -2532,7 +2547,7 @@ export function Map3D({ id, type: "line", source: srcId, - ...(filter ? { filter: filter as never } : {}), + filter: filter as never, layout: { visibility, "line-cap": "round", @@ -2548,30 +2563,63 @@ export function Map3D({ } else { try { map.setLayoutProperty(id, "visibility", visibility); + map.setFilter(id, filter as never); + if (paint && typeof paint === "object") { + for (const [key, value] of Object.entries(paint)) { + map.setPaintProperty(id, key as never, value as never); + } + } } catch { // ignore } } }; + const baseFilter = ["==", ["to-number", ["get", "hl"], 0], 0] as unknown as unknown[]; + const hlFilter = ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[]; + + // Outline (halo) for readability over bathymetry + seamark textures. + ensureLayer( + outlineId, + { + "line-color": "rgba(2,6,23,0.86)", + "line-width": 4.8, + "line-opacity": 1, + "line-blur": 0.2, + "line-dasharray": [1.2, 1.8] as never, + } as never, + baseFilter, + ); ensureLayer( lineId, { - "line-color": ["coalesce", ["get", "color"], "rgba(148,163,184,0.3)"] as never, - "line-width": 1.2, + "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.62)"] as never, + "line-width": 2.4, "line-opacity": 1, "line-dasharray": [1.2, 1.8] as never, } as never, + baseFilter, + ); + ensureLayer( + hlOutlineId, + { + "line-color": "rgba(2,6,23,0.92)", + "line-width": 6.4, + "line-opacity": 1, + "line-blur": 0.25, + "line-dasharray": [1.2, 1.8] as never, + } as never, + hlFilter, ); ensureLayer( hlId, { - "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.7)"] as never, - "line-width": 2.2, + "line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never, + "line-width": 3.6, "line-opacity": 1, "line-dasharray": [1.2, 1.8] as never, } as never, - ["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[], + hlFilter, ); reorderGlobeFeatureLayers();