gc-wing/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.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

185 lines
6.9 KiB
TypeScript

import { useEffect, useRef, type MutableRefObject } from 'react';
import maplibregl from 'maplibre-gl';
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
import type { BaseMapId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
/* ── Depth font size presets ──────────────────────────────────────── */
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
};
/* ── Helpers ──────────────────────────────────────────────────────── */
function darkenHex(hex: string, factor = 0.85): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
}
function lightenHex(hex: string, factor = 1.3): string {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
}
/* ── Apply functions ──────────────────────────────────────────────── */
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
const style = map.getStyle();
if (!style?.layers) return;
for (const layer of style.layers) {
if (layer.type !== 'symbol') continue;
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout?.['text-field']) continue;
if (layer.id.startsWith('bathymetry-labels')) continue;
const textField =
lang === 'local'
? ['get', 'name']
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
try {
map.setLayoutProperty(layer.id, 'text-field', textField);
} catch {
// ignore
}
}
}
function applyLandColor(map: maplibregl.Map, color: string) {
const style = map.getStyle();
if (!style?.layers) return;
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const darkVariant = darkenHex(color, 0.8);
for (const layer of style.layers) {
const id = layer.id;
if (id.startsWith('bathymetry-')) continue;
if (id.startsWith('subcables-')) continue;
if (id.startsWith('zones-')) continue;
if (id.startsWith('ships-')) continue;
if (id.startsWith('pair-')) continue;
if (id.startsWith('fc-')) continue;
if (id.startsWith('fleet-')) continue;
if (id.startsWith('predict-')) continue;
if (id.startsWith('vessel-track-')) continue;
if (id === 'deck-globe') continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
if (isWater) continue;
try {
if (layer.type === 'background') {
map.setPaintProperty(id, 'background-color', color);
} else if (layer.type === 'fill') {
map.setPaintProperty(id, 'fill-color', darkVariant);
}
} catch {
// ignore
}
}
}
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
const style = map.getStyle();
if (!style?.layers) return;
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
const lineColor = darkenHex(fillColor, 0.85);
for (const layer of style.layers) {
const id = layer.id;
if (id.startsWith('bathymetry-')) continue;
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
try {
if (layer.type === 'fill') {
map.setPaintProperty(id, 'fill-color', fillColor);
} else if (layer.type === 'line') {
map.setPaintProperty(id, 'line-color', lineColor);
}
} catch {
// ignore
}
}
}
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length === 0) return;
const expr: unknown[] = ['interpolate', ['linear'], depth];
for (const s of sorted) {
expr.push(s.depth, s.color);
}
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
const shallowest = sorted[sorted.length - 1];
if (shallowest.depth < 0) {
expr.push(0, lightenHex(shallowest.color, 1.8));
}
if (!map.getLayer('bathymetry-fill')) return;
try {
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
} catch {
// ignore
}
}
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
const expr = DEPTH_FONT_SIZE_MAP[size];
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setLayoutProperty(layerId, 'text-size', expr);
} catch {
// ignore
}
}
}
function applyDepthFontColor(map: maplibregl.Map, color: string) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setPaintProperty(layerId, 'text-color', color);
} catch {
// ignore
}
}
}
/* ── Hook ──────────────────────────────────────────────────────────── */
export function useMapStyleSettings(
mapRef: MutableRefObject<maplibregl.Map | null>,
settings: MapStyleSettings | undefined,
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
) {
const settingsRef = useRef(settings);
useEffect(() => {
settingsRef.current = settings;
});
const { baseMap, mapSyncEpoch } = opts;
useEffect(() => {
const map = mapRef.current;
const s = settingsRef.current;
if (!map || !s) return;
// Ocean/ENC 모드는 전용 훅에서 별도 처리
if (baseMap === 'ocean' || baseMap === 'enc') return;
const stop = onMapStyleReady(map, () => {
applyLabelLanguage(map, s.labelLanguage);
applyLandColor(map, s.landColor);
applyWaterBaseColor(map, s.waterBaseColor);
if (baseMap === 'enhanced') {
applyDepthGradient(map, s.depthStops);
applyDepthFontSize(map, s.depthFontSize);
applyDepthFontColor(map, s.depthFontColor);
}
kickRepaint(map);
});
return () => stop();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [settings, baseMap, mapSyncEpoch]);
}