gc-wing/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts
htlee d0f67ae803 feat(encMap): gcnautical 타일 서버 기반 ENC 전자해도 + UI 개선
## ENC 베이스맵 (features/encMap/)
- gcnautical 타일 서버 연동 (nautical.json 49개 레이어, 73개 S-52 스프라이트)
- 설정 패널: 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계
- 배경색 밝기 기반 선박 라벨 색상 자동 전환 (labelColor.ts)
- useMapStyleSettings에 ENC 가드 추가 (스타일 간섭 방지)
- useBaseMapToggle 초기 로드 스킵 (useMapInit과 중복 setStyle 방지)

## 선박 표시 개선
- Globe 원형 halo/outline 제거 — 아이콘 본체만 표시
- Globe 아이콘 스케일 1.3배, 줌아웃 최소 크기 보장 (minzoom 2)
- SDF icon-halo로 테두리 적용 (성능 영향 없음)
- 기타 AIS 투명도 상향 (0.28→0.6 ~ 1.0)
- 선박명 영문 우선 표시 (shipNameRoman > shipNameCn)

## 오버레이 제어 수정
- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이도 비활성
- Globe pair/fc/fleet 레이어: || active 제거 → 토글 우선
- 강조 링/알람 링: shipData→shipLayerData (클러스터링 연동)

## 기본값 변경
- 경고 필터 5개: 초기 false
- 연결선/범위/선단: 초기 false
- 사진 파란 원 아이콘: Globe+Mercator 모두 제거

## 폰트 정리
- Open Sans 폴백 전면 제거 → Noto Sans 단독
- ENC 스타일 fetch 시 text-font 패치

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:19:28 +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'],
'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]);
}