gc-wing/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
htlee 7bca216c53 fix(map): Globe 렌더링 안정화 및 툴팁 유지 개선
- 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>
2026-02-17 16:38:51 +09:00

264 lines
9.1 KiB
TypeScript
Raw Blame 히스토리

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, type MutableRefObject } from 'react';
import maplibregl, {
type GeoJSONSource,
type GeoJSONSourceSpecification,
type LayerSpecification,
} from 'maplibre-gl';
import type { ZoneId } from '../../../entities/zone/model/meta';
import { ZONE_META } from '../../../entities/zone/model/meta';
import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { BaseMapId, MapProjectionId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe tessellation에서 vertex 65535 초과를 방지하기 위해 좌표 수를 줄임.
* 수역 폴리곤(해안선 디테일 2100+ vertex)이 globe에서 ~33x로 폭증하므로
* ring당 최대 maxPts개로 서브샘플링. 라벨 centroid에는 영향 없음.
* 4수역 × 300pts × 33x ≈ 39,600 vertices (< 65535 limit). */
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
const MAX_PTS = 300;
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
if (ring.length <= MAX_PTS) return ring;
const step = Math.ceil(ring.length / MAX_PTS);
const out: GeoJSON.Position[] = [ring[0]];
for (let i = step; i < ring.length - 1; i += step) out.push(ring[i]);
out.push(ring[0]); // close ring
return out;
};
return {
...zones,
features: zones.features.map((f) => ({
...f,
geometry: {
...f.geometry,
coordinates: f.geometry.coordinates.map((polygon) =>
polygon.map((ring) => subsample(ring)),
),
},
})),
};
}
export function useZonesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
projectionBusyRef: MutableRefObject<boolean>,
reorderGlobeFeatureLayers: () => void,
opts: {
zones: ZonesGeoJson | null;
overlays: MapToggleState;
projection: MapProjectionId;
baseMap: BaseMapId;
hoveredZoneId: string | null;
mapSyncEpoch: number;
},
) {
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
const simplifiedZones = useMemo(
() => (zones ? simplifyZonesForGlobe(zones) : null),
[zones],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const srcId = 'zones-src';
const fillId = 'zones-fill';
const lineId = 'zones-line';
const labelId = 'zones-label';
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneColorExpr.push(k, ZONE_META[k].color);
}
zoneColorExpr.push('#3B82F6');
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
zoneLabelExpr.push(k, ZONE_META[k].name);
}
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
const ensure = () => {
// 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
// globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
const sourceData = projection === 'globe' ? simplifiedZones : zones;
if (sourceData) {
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(sourceData);
} catch { /* ignore — source may not exist yet */ }
}
const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none';
guardedSetVisibility(map, fillId, visibility);
guardedSetVisibility(map, lineId, visibility);
guardedSetVisibility(map, labelId, visibility);
if (projectionBusyRef.current) return;
if (!zones) return;
if (!map.isStyleLoaded()) return;
try {
// 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
if (!map.getSource(srcId)) {
const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
}
const style = map.getStyle();
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
| { id?: string }
| undefined;
const before = map.getLayer('deck-globe')
? 'deck-globe'
: map.getLayer('ships')
? 'ships'
: map.getLayer('seamark')
? 'seamark'
: firstSymbol?.id;
const zoneMatchExpr =
hoveredZoneId !== null
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
: false;
const zoneLineWidthExpr = hoveredZoneId
? ([
'interpolate',
['linear'],
['zoom'],
4,
['case', zoneMatchExpr, 1.6, 0.8],
10,
['case', zoneMatchExpr, 2.0, 1.4],
14,
['case', zoneMatchExpr, 2.8, 2.1],
] as unknown as never)
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
if (map.getLayer(fillId)) {
try {
map.setPaintProperty(
fillId,
'fill-opacity',
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
);
} catch {
// ignore
}
}
if (map.getLayer(lineId)) {
try {
map.setPaintProperty(
lineId,
'line-color',
hoveredZoneId
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
: (zoneColorExpr as never),
);
} catch {
// ignore
}
try {
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
} catch {
// ignore
}
try {
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
} catch {
// ignore
}
}
if (!map.getLayer(fillId)) {
map.addLayer(
{
id: fillId,
type: 'fill',
source: srcId,
paint: {
'fill-color': zoneColorExpr as never,
'fill-opacity': hoveredZoneId
? ([
'case',
zoneMatchExpr,
0.24,
0.1,
] as unknown as number)
: 0.12,
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
if (!map.getLayer(lineId)) {
map.addLayer(
{
id: lineId,
type: 'line',
source: srcId,
paint: {
'line-color': hoveredZoneId
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
: (zoneColorExpr as never),
'line-opacity': hoveredZoneId
? (['case', zoneMatchExpr, 1, 0.85] as never)
: 0.85,
'line-width': zoneLineWidthExpr,
},
layout: { visibility },
} as unknown as LayerSpecification,
before,
);
}
if (!map.getLayer(labelId)) {
map.addLayer(
{
id: labelId,
type: 'symbol',
source: srcId,
layout: {
visibility,
'symbol-placement': 'point',
'text-field': zoneLabelExpr as never,
'text-size': 11,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-anchor': 'top',
'text-offset': [0, 0.35],
'text-allow-overlap': false,
'text-ignore-placement': false,
},
paint: {
'text-color': '#dbeafe',
'text-halo-color': 'rgba(2,6,23,0.85)',
'text-halo-width': 1.2,
'text-halo-blur': 0.8,
},
} as unknown as LayerSpecification,
undefined,
);
}
} catch (e) {
console.warn('Zones layer setup failed:', e);
} finally {
reorderGlobeFeatureLayers();
kickRepaint(map);
}
};
const stop = onMapStyleReady(map, ensure);
return () => {
stop();
};
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
}