fix(map): align prediction vectors with ship course + improve contrast

This commit is contained in:
htlee 2026-02-15 19:41:15 +09:00
부모 11aff67a04
커밋 a8aa916076

파일 보기

@ -370,6 +370,15 @@ function normalizeAngleDeg(value: number, offset = 0): number {
return ((v % 360) + 360) % 360; 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({ function getDisplayHeading({
cog, cog,
heading, heading,
@ -379,8 +388,8 @@ function getDisplayHeading({
heading: number | null | undefined; heading: number | null | undefined;
offset?: number; offset?: number;
}) { }) {
const raw = // Use COG (0=N, 90=E...) as the primary bearing so ship icons + prediction vectors stay aligned.
isFiniteNumber(heading) && heading >= 0 && heading <= 360 && heading !== 511 ? heading : isFiniteNumber(cog) ? cog : 0; const raw = toValidBearingDeg(cog) ?? toValidBearingDeg(heading) ?? 0;
return normalizeAngleDeg(raw, offset); return normalizeAngleDeg(raw, offset);
} }
@ -1329,16 +1338,18 @@ export function Map3D({
const fleetFocusLat = fleetFocus?.center?.[1]; const fleetFocusLat = fleetFocus?.center?.[1];
const fleetFocusZoom = fleetFocus?.zoom; const fleetFocusZoom = fleetFocus?.zoom;
const reorderGlobeFeatureLayers = useCallback(() => { const reorderGlobeFeatureLayers = useCallback(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || projectionRef.current !== "globe") return; if (!map || projectionRef.current !== "globe") return;
if (projectionBusyRef.current) return; if (projectionBusyRef.current) return;
const ordering = [ const ordering = [
"zones-fill", "zones-fill",
"zones-line", "zones-line",
"zones-label", "zones-label",
"predict-vectors-outline",
"predict-vectors", "predict-vectors",
"predict-vectors-hl-outline",
"predict-vectors-hl", "predict-vectors-hl",
"ships-globe-halo", "ships-globe-halo",
"ships-globe-outline", "ships-globe-outline",
@ -2457,7 +2468,9 @@ export function Map3D({
if (!map) return; if (!map) return;
const srcId = "predict-vectors-src"; const srcId = "predict-vectors-src";
const outlineId = "predict-vectors-outline";
const lineId = "predict-vectors"; const lineId = "predict-vectors";
const hlOutlineId = "predict-vectors-hl-outline";
const hlId = "predict-vectors-hl"; const hlId = "predict-vectors-hl";
const ensure = () => { const ensure = () => {
@ -2480,20 +2493,21 @@ export function Map3D({
if (!isTarget && !isSelected && !isPinnedHighlight) continue; if (!isTarget && !isSelected && !isPinnedHighlight) continue;
const sog = isFiniteNumber(t.sog) ? t.sog : null; const sog = isFiniteNumber(t.sog) ? t.sog : null;
const cog = const bearing = toValidBearingDeg(t.cog) ?? toValidBearingDeg(t.heading);
isFiniteNumber(t.cog) ? t.cog : isFiniteNumber(t.heading) ? t.heading : null; if (sog == null || bearing == null) continue;
if (sog == null || cog == null) continue;
if (sog < 0.2) continue; if (sog < 0.2) continue;
const distM = sog * metersPerSecondPerKnot * horizonSeconds; const distM = sog * metersPerSecondPerKnot * horizonSeconds;
if (!Number.isFinite(distM) || distM <= 0) continue; 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 ? LEGACY_CODE_COLORS_RGB[legacy?.shipCode ?? ""] ?? OTHER_AIS_SPEED_RGB.moving
: 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; const hl = isSelected || isPinnedHighlight ? 1 : 0;
features.push({ features.push({
@ -2504,10 +2518,11 @@ export function Map3D({
mmsi: t.mmsi, mmsi: t.mmsi,
minutes: horizonMinutes, minutes: horizonMinutes,
sog, sog,
cog, cog: bearing,
target: isTarget ? 1 : 0, target: isTarget ? 1 : 0,
hl, hl,
color: rgbaCss(rgb, alpha), color: rgbaCss(rgb, alpha),
colorHl: rgbaCss(rgb, alphaHl),
}, },
}); });
} }
@ -2524,7 +2539,7 @@ export function Map3D({
return; return;
} }
const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter?: unknown[]) => { const ensureLayer = (id: string, paint: LayerSpecification["paint"], filter: unknown[]) => {
if (!map.getLayer(id)) { if (!map.getLayer(id)) {
try { try {
map.addLayer( map.addLayer(
@ -2532,7 +2547,7 @@ export function Map3D({
id, id,
type: "line", type: "line",
source: srcId, source: srcId,
...(filter ? { filter: filter as never } : {}), filter: filter as never,
layout: { layout: {
visibility, visibility,
"line-cap": "round", "line-cap": "round",
@ -2548,30 +2563,63 @@ export function Map3D({
} else { } else {
try { try {
map.setLayoutProperty(id, "visibility", visibility); 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 { } catch {
// ignore // 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( ensureLayer(
lineId, lineId,
{ {
"line-color": ["coalesce", ["get", "color"], "rgba(148,163,184,0.3)"] as never, "line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.62)"] as never,
"line-width": 1.2, "line-width": 2.4,
"line-opacity": 1, "line-opacity": 1,
"line-dasharray": [1.2, 1.8] as never, "line-dasharray": [1.2, 1.8] as never,
} 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( ensureLayer(
hlId, hlId,
{ {
"line-color": ["coalesce", ["get", "color"], "rgba(226,232,240,0.7)"] as never, "line-color": ["coalesce", ["get", "colorHl"], ["get", "color"], "rgba(241,245,249,0.92)"] as never,
"line-width": 2.2, "line-width": 3.6,
"line-opacity": 1, "line-opacity": 1,
"line-dasharray": [1.2, 1.8] as never, "line-dasharray": [1.2, 1.8] as never,
} as never, } as never,
["==", ["to-number", ["get", "hl"], 0], 1] as unknown as unknown[], hlFilter,
); );
reorderGlobeFeatureLayers(); reorderGlobeFeatureLayers();