- isStyleLoaded() 가드를 try/catch 패턴으로 교체 (AIS poll setData 중 렌더링 차단 방지) - Globe 툴팁 buildTooltipRef 패턴 도입 (AIS poll 주기 변경 시 사라짐 방지) - Globe 우클릭 컨텍스트 메뉴 isStyleLoaded 가드 제거 - 항적 가상 선박을 IconLayer에서 ScatterplotLayer(원형)로 변경 - useNativeMapLayers isStyleLoaded 가드 제거 (항적 레이어 셋업 스킵 방지) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
202 lines
6.0 KiB
TypeScript
202 lines
6.0 KiB
TypeScript
import { PathLayer, ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
|
import type { Layer, PickingInfo } from '@deck.gl/core';
|
|
import { DEPTH_DISABLED_PARAMS } from '../../../shared/lib/map/mapConstants';
|
|
import { getShipKindColor } from '../lib/adapters';
|
|
import type { CurrentVesselPosition, ProcessedTrack } from '../model/track.types';
|
|
|
|
export const TRACK_REPLAY_LAYER_IDS = {
|
|
PATH: 'track-replay-path',
|
|
POINTS: 'track-replay-points',
|
|
VIRTUAL_SHIP: 'track-replay-virtual-ship',
|
|
VIRTUAL_LABEL: 'track-replay-virtual-label',
|
|
TRAIL: 'track-replay-trail',
|
|
} as const;
|
|
|
|
interface PathData {
|
|
vesselId: string;
|
|
path: [number, number][];
|
|
color: [number, number, number, number];
|
|
}
|
|
|
|
interface PointData {
|
|
vesselId: string;
|
|
position: [number, number];
|
|
color: [number, number, number, number];
|
|
timestamp: number;
|
|
speed: number;
|
|
index: number;
|
|
}
|
|
|
|
const MAX_POINTS_PER_TRACK = 800;
|
|
|
|
export function createStaticTrackLayers(options: {
|
|
tracks: ProcessedTrack[];
|
|
showPoints: boolean;
|
|
highlightedVesselId?: string | null;
|
|
onPathHover?: (vesselId: string | null) => void;
|
|
}): Layer[] {
|
|
const { tracks, showPoints, highlightedVesselId, onPathHover } = options;
|
|
const layers: Layer[] = [];
|
|
if (!tracks || tracks.length === 0) return layers;
|
|
|
|
const pathData: PathData[] = tracks.map((track) => ({
|
|
vesselId: track.vesselId,
|
|
path: track.geometry,
|
|
color: getShipKindColor(track.shipKindCode),
|
|
}));
|
|
|
|
layers.push(
|
|
new PathLayer<PathData>({
|
|
id: TRACK_REPLAY_LAYER_IDS.PATH,
|
|
data: pathData,
|
|
getPath: (d) => d.path,
|
|
getColor: (d) =>
|
|
highlightedVesselId && highlightedVesselId === d.vesselId
|
|
? [255, 255, 0, 255]
|
|
: [d.color[0], d.color[1], d.color[2], 235],
|
|
getWidth: (d) => (highlightedVesselId && highlightedVesselId === d.vesselId ? 5 : 3),
|
|
widthUnits: 'pixels',
|
|
widthMinPixels: 1,
|
|
widthMaxPixels: 6,
|
|
parameters: DEPTH_DISABLED_PARAMS,
|
|
jointRounded: true,
|
|
capRounded: true,
|
|
pickable: true,
|
|
onHover: (info: PickingInfo<PathData>) => {
|
|
onPathHover?.(info.object?.vesselId ?? null);
|
|
},
|
|
updateTriggers: {
|
|
getColor: [highlightedVesselId],
|
|
getWidth: [highlightedVesselId],
|
|
},
|
|
}),
|
|
);
|
|
|
|
if (showPoints) {
|
|
const pointData: PointData[] = [];
|
|
|
|
for (const track of tracks) {
|
|
const color = getShipKindColor(track.shipKindCode);
|
|
const len = track.geometry.length;
|
|
if (len <= MAX_POINTS_PER_TRACK) {
|
|
for (let i = 0; i < len; i++) {
|
|
pointData.push({
|
|
vesselId: track.vesselId,
|
|
position: track.geometry[i],
|
|
color,
|
|
timestamp: track.timestampsMs[i] || 0,
|
|
speed: track.speeds[i] || 0,
|
|
index: i,
|
|
});
|
|
}
|
|
} else {
|
|
const step = len / MAX_POINTS_PER_TRACK;
|
|
for (let i = 0; i < MAX_POINTS_PER_TRACK; i++) {
|
|
const idx = Math.min(Math.floor(i * step), len - 1);
|
|
pointData.push({
|
|
vesselId: track.vesselId,
|
|
position: track.geometry[idx],
|
|
color,
|
|
timestamp: track.timestampsMs[idx] || 0,
|
|
speed: track.speeds[idx] || 0,
|
|
index: idx,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
layers.push(
|
|
new ScatterplotLayer<PointData>({
|
|
id: TRACK_REPLAY_LAYER_IDS.POINTS,
|
|
data: pointData,
|
|
getPosition: (d) => d.position,
|
|
getFillColor: (d) => d.color,
|
|
getRadius: 3,
|
|
radiusUnits: 'pixels',
|
|
radiusMinPixels: 2,
|
|
radiusMaxPixels: 5,
|
|
parameters: DEPTH_DISABLED_PARAMS,
|
|
pickable: false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
return layers;
|
|
}
|
|
|
|
export function createDynamicTrackLayers(options: {
|
|
currentPositions: CurrentVesselPosition[];
|
|
showVirtualShip: boolean;
|
|
showLabels: boolean;
|
|
onIconHover?: (position: CurrentVesselPosition | null, x: number, y: number) => void;
|
|
onPathHover?: (vesselId: string | null) => void;
|
|
}): Layer[] {
|
|
const { currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover } = options;
|
|
const layers: Layer[] = [];
|
|
|
|
if (!currentPositions || currentPositions.length === 0) return layers;
|
|
|
|
if (showVirtualShip) {
|
|
layers.push(
|
|
new ScatterplotLayer<CurrentVesselPosition>({
|
|
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_SHIP,
|
|
data: currentPositions,
|
|
getPosition: (d) => d.position,
|
|
getFillColor: (d) => {
|
|
const base = getShipKindColor(d.shipKindCode);
|
|
return [base[0], base[1], base[2], 230] as [number, number, number, number];
|
|
},
|
|
getLineColor: [255, 255, 255, 200],
|
|
getRadius: 5,
|
|
radiusUnits: 'pixels',
|
|
radiusMinPixels: 4,
|
|
radiusMaxPixels: 8,
|
|
stroked: true,
|
|
lineWidthMinPixels: 1,
|
|
parameters: DEPTH_DISABLED_PARAMS,
|
|
pickable: true,
|
|
onHover: (info: PickingInfo<CurrentVesselPosition>) => {
|
|
if (info.object) {
|
|
onPathHover?.(info.object.vesselId);
|
|
onIconHover?.(info.object, info.x, info.y);
|
|
} else {
|
|
onPathHover?.(null);
|
|
onIconHover?.(null, 0, 0);
|
|
}
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (showLabels) {
|
|
const labelData = currentPositions.filter((position) => (position.shipName || '').trim().length > 0);
|
|
if (labelData.length > 0) {
|
|
layers.push(
|
|
new TextLayer<CurrentVesselPosition>({
|
|
id: TRACK_REPLAY_LAYER_IDS.VIRTUAL_LABEL,
|
|
data: labelData,
|
|
getPosition: (d) => d.position,
|
|
getText: (d) => d.shipName,
|
|
getColor: [226, 232, 240, 240],
|
|
getSize: 11,
|
|
getTextAnchor: 'start',
|
|
getAlignmentBaseline: 'center',
|
|
getPixelOffset: [14, 0],
|
|
fontFamily: 'Malgun Gothic, Arial, sans-serif',
|
|
fontSettings: { sdf: true },
|
|
outlineColor: [2, 6, 23, 220],
|
|
outlineWidth: 2,
|
|
parameters: DEPTH_DISABLED_PARAMS,
|
|
pickable: false,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
return layers;
|
|
}
|
|
|
|
export function isTrackReplayLayerId(id: unknown): boolean {
|
|
return typeof id === 'string' && id.startsWith('track-replay-');
|
|
}
|