From d0f67ae803d24064a7f0e12161caf0c5f99a0dcb Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 25 Mar 2026 14:19:28 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(encMap):=20gcnautical=20=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=84=9C=EB=B2=84=20=EA=B8=B0=EB=B0=98=20ENC=20?= =?UTF-8?q?=EC=A0=84=EC=9E=90=ED=95=B4=EB=8F=84=20+=20UI=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../app/styles/components/map-settings.css | 73 +++++++ .../encMap/hooks/useEncMapSettings.ts | 43 +++++ apps/web/src/features/encMap/index.ts | 6 + .../src/features/encMap/lib/encSettings.ts | 61 ++++++ apps/web/src/features/encMap/lib/encStyle.ts | 28 +++ apps/web/src/features/encMap/model/types.ts | 98 ++++++++++ .../encMap/ui/EncMapSettingsPanel.tsx | 138 +++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 23 ++- .../src/pages/dashboard/DashboardSidebar.tsx | 8 +- .../src/pages/dashboard/useDashboardState.ts | 15 +- apps/web/src/widgets/map3d/Map3D.tsx | 4 + .../widgets/map3d/hooks/useBaseMapToggle.ts | 15 +- .../src/widgets/map3d/hooks/useDeckLayers.ts | 14 +- .../map3d/hooks/useGlobeFcFleetOverlay.ts | 6 +- .../map3d/hooks/useGlobePairOverlay.ts | 4 +- .../widgets/map3d/hooks/useGlobeShipLayers.ts | 182 ++++-------------- .../map3d/hooks/useMapStyleSettings.ts | 4 +- .../widgets/map3d/hooks/useShipLabelColor.ts | 55 ++++++ .../widgets/map3d/hooks/useSubcablesLayer.ts | 4 +- .../src/widgets/map3d/hooks/useZonesLayer.ts | 2 +- .../src/widgets/map3d/layers/bathymetry.ts | 25 +-- .../widgets/map3d/lib/deckLayerFactories.ts | 42 +--- apps/web/src/widgets/map3d/lib/labelColor.ts | 59 ++++++ apps/web/src/widgets/map3d/lib/shipUtils.ts | 2 +- apps/web/src/widgets/map3d/lib/tooltips.ts | 2 +- apps/web/src/widgets/map3d/types.ts | 5 +- 26 files changed, 687 insertions(+), 231 deletions(-) create mode 100644 apps/web/src/features/encMap/hooks/useEncMapSettings.ts create mode 100644 apps/web/src/features/encMap/index.ts create mode 100644 apps/web/src/features/encMap/lib/encSettings.ts create mode 100644 apps/web/src/features/encMap/lib/encStyle.ts create mode 100644 apps/web/src/features/encMap/model/types.ts create mode 100644 apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts create mode 100644 apps/web/src/widgets/map3d/lib/labelColor.ts diff --git a/apps/web/src/app/styles/components/map-settings.css b/apps/web/src/app/styles/components/map-settings.css index dc348f8..180fb38 100644 --- a/apps/web/src/app/styles/components/map-settings.css +++ b/apps/web/src/app/styles/components/map-settings.css @@ -141,6 +141,79 @@ border-color: var(--accent); } +/* ── ENC Settings additions ──────────────────────────────────────── */ + +.map-settings-panel .ms-title .ms-reset-btn { + float: right; + font-size: 9px; + padding: 1px 6px; + border-radius: 3px; + border: 1px solid var(--border); + background: var(--card); + color: var(--muted); + cursor: pointer; +} + +.map-settings-panel .ms-title .ms-reset-btn:hover { + color: var(--text); + border-color: var(--accent); +} + +.map-settings-panel .ms-toggle-all { + float: right; +} + +.map-settings-panel .ms-toggle-grid { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; +} + +.map-settings-panel .ms-toggle-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: var(--muted); + cursor: pointer; +} + +.map-settings-panel .ms-toggle-item input[type="checkbox"] { + width: 12px; + height: 12px; + margin: 0; + cursor: pointer; +} + +.map-settings-panel .ms-color-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 2px 0; + font-size: 10px; + color: var(--muted); +} + +.map-settings-panel .ms-color-row input[type="color"] { + width: 28px; + height: 18px; + padding: 0; + border: 1px solid var(--border); + border-radius: 3px; + background: transparent; + cursor: pointer; +} + +.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.map-settings-panel .ms-color-row input[type="color"]::-webkit-color-swatch { + border: none; + border-radius: 2px; +} + /* ── Depth Legend ──────────────────────────────────────────────────── */ .depth-legend { diff --git a/apps/web/src/features/encMap/hooks/useEncMapSettings.ts b/apps/web/src/features/encMap/hooks/useEncMapSettings.ts new file mode 100644 index 0000000..849bd34 --- /dev/null +++ b/apps/web/src/features/encMap/hooks/useEncMapSettings.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import { applyEncVisibility, applyEncColors } from '../lib/encSettings'; +import type { EncMapSettings } from '../model/types'; +import type { BaseMapId } from '../../../widgets/map3d/types'; + +/** + * Applies ENC map settings changes at runtime (no style reload). + */ +export function useEncMapSettings( + mapRef: MutableRefObject, + baseMap: BaseMapId, + settings: EncMapSettings, +) { + const prevRef = useRef(settings); + + useEffect(() => { + if (baseMap !== 'enc') return; + const map = mapRef.current; + if (!map) return; + + const prev = prevRef.current; + prevRef.current = settings; + + const toggleKeys = [ + 'showBuoys', 'showBeacons', 'showLights', 'showDangers', 'showLandmarks', + 'showSoundings', 'showPilot', 'showAnchorage', 'showRestricted', + 'showDredged', 'showTSS', 'showContours', + ] as const; + + if (toggleKeys.some((k) => prev[k] !== settings[k])) { + applyEncVisibility(map, settings); + } + + const colorKeys = [ + 'landColor', 'coastlineColor', 'backgroundColor', + 'depthDrying', 'depthVeryShallow', 'depthSafetyZone', 'depthMedium', 'depthDeep', + ] as const; + if (colorKeys.some((k) => prev[k] !== settings[k])) { + applyEncColors(map, settings); + } + }, [baseMap, settings]); +} diff --git a/apps/web/src/features/encMap/index.ts b/apps/web/src/features/encMap/index.ts new file mode 100644 index 0000000..02823f7 --- /dev/null +++ b/apps/web/src/features/encMap/index.ts @@ -0,0 +1,6 @@ +export type { EncMapSettings } from './model/types'; +export { DEFAULT_ENC_MAP_SETTINGS } from './model/types'; +export { fetchEncStyle } from './lib/encStyle'; +export { applyEncVisibility, applyEncColors } from './lib/encSettings'; +export { useEncMapSettings } from './hooks/useEncMapSettings'; +export { EncMapSettingsPanel } from './ui/EncMapSettingsPanel'; diff --git a/apps/web/src/features/encMap/lib/encSettings.ts b/apps/web/src/features/encMap/lib/encSettings.ts new file mode 100644 index 0000000..b2d273b --- /dev/null +++ b/apps/web/src/features/encMap/lib/encSettings.ts @@ -0,0 +1,61 @@ +import type maplibregl from 'maplibre-gl'; +import type { EncMapSettings } from '../model/types'; +import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from '../model/types'; + +/** + * Apply symbol category visibility toggles at runtime. + */ +export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void { + for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) { + const visible = settings[key as keyof EncMapSettings] as boolean; + const vis = visible ? 'visible' : 'none'; + for (const layerId of layerIds) { + try { + if (map.getLayer(layerId)) { + map.setLayoutProperty(layerId, 'visibility', vis); + } + } catch { + // layer may not exist + } + } + } +} + +/** + * Apply runtime color changes to area/line layers. + */ +export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void { + // 육지/해안선 + for (const [layerId, prop, key] of ENC_COLOR_TARGETS) { + try { + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, prop, settings[key] as string); + } + } catch { + // ignore + } + } + + // 배경색 + try { + if (map.getLayer('background')) { + map.setPaintProperty('background', 'background-color', settings.backgroundColor); + } + } catch { + // ignore + } + + // 수심별 색상 + for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) { + const color = settings[key] as string; + for (const layerId of layerIds) { + try { + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, 'fill-color', color); + } + } catch { + // ignore + } + } + } +} diff --git a/apps/web/src/features/encMap/lib/encStyle.ts b/apps/web/src/features/encMap/lib/encStyle.ts new file mode 100644 index 0000000..83db30a --- /dev/null +++ b/apps/web/src/features/encMap/lib/encStyle.ts @@ -0,0 +1,28 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json'; + +/** Fonts available on the tile server */ +const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular']; + +/** + * Fetch the nautical chart style from gcnautical tile server. + * Patches text-font arrays to use only server-supported fonts (avoids 404 on composite fontstack). + */ +export async function fetchEncStyle(signal: AbortSignal): Promise { + const res = await fetch(NAUTICAL_STYLE_URL, { signal }); + if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`); + const style = (await res.json()) as StyleSpecification; + + // Patch text-font to avoid composite fontstack 404 errors + for (const layer of style.layers) { + const layout = (layer as { layout?: Record }).layout; + if (!layout) continue; + const tf = layout['text-font']; + if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) { + layout['text-font'] = SERVER_FONTS; + } + } + + return style; +} diff --git a/apps/web/src/features/encMap/model/types.ts b/apps/web/src/features/encMap/model/types.ts new file mode 100644 index 0000000..31b5386 --- /dev/null +++ b/apps/web/src/features/encMap/model/types.ts @@ -0,0 +1,98 @@ +export interface EncDepthColor { + label: string; + layerIds: string[]; + color: string; +} + +export interface EncMapSettings { + // 심볼 카테고리별 표시 토글 + showBuoys: boolean; + showBeacons: boolean; + showLights: boolean; + showDangers: boolean; + showLandmarks: boolean; + showSoundings: boolean; + showPilot: boolean; + showAnchorage: boolean; + showRestricted: boolean; + showDredged: boolean; + showTSS: boolean; + showContours: boolean; + + // 영역 색상 (nautical.json 기본값 기준) + landColor: string; + coastlineColor: string; + backgroundColor: string; + + // 수심별 색상 + depthDrying: string; + depthVeryShallow: string; + depthSafetyZone: string; + depthMedium: string; + depthDeep: string; +} + +/** nautical.json 기본 색상 기준 */ +export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = { + showBuoys: true, + showBeacons: true, + showLights: true, + showDangers: true, + showLandmarks: true, + showSoundings: true, + showPilot: true, + showAnchorage: true, + showRestricted: true, + showDredged: true, + showTSS: true, + showContours: true, + + landColor: '#BFBE8D', + coastlineColor: '#4C5B62', + backgroundColor: '#93AEBB', + + depthDrying: '#58AF99', + depthVeryShallow: '#61B7FF', + depthSafetyZone: '#82CAFF', + depthMedium: '#A7D9FA', + depthDeep: '#C9EDFD', +}; + +/** + * 심볼 카테고리 → nautical.json 레이어 ID 매핑. + * 서버 스타일의 49개 레이어를 12개 카테고리로 그룹화. + */ +export const ENC_LAYER_CATEGORIES: Record = { + showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'], + showBeacons: ['lndmrk'], + showLights: ['lights', 'lights-catlit'], + showDangers: ['uwtroc', 'obstrn', 'wrecks'], + showLandmarks: ['lndmrk'], + showSoundings: ['soundg', 'soundg-critical'], + showPilot: ['pilbop'], + showAnchorage: ['achare', 'achare-outline'], + showRestricted: ['resare-outline', 'resare-symbol', 'mipare'], + showDredged: [ + 'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone', + 'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol', + ], + showTSS: ['tsslpt', 'tsslpt-outline'], + showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'], +}; + +/** 영역 색상 → 레이어 ID + paint 속성 매핑 */ +export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [ + ['lndare', 'fill-color', 'landColor'], + ['globe-lndare', 'fill-color', 'landColor'], + ['coalne', 'line-color', 'coastlineColor'], + ['globe-coalne', 'line-color', 'coastlineColor'], +]; + +/** 수심별 색상 → 레이어 ID 매핑 */ +export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [ + { key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] }, + { key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] }, + { key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] }, + { key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] }, + { key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] }, +]; diff --git a/apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx b/apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx new file mode 100644 index 0000000..e9f9da8 --- /dev/null +++ b/apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx @@ -0,0 +1,138 @@ +import { useState } from 'react'; +import type { EncMapSettings } from '../model/types'; +import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from '../model/types'; + +interface EncMapSettingsPanelProps { + value: EncMapSettings; + onChange: (next: EncMapSettings) => void; +} + +const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [ + { key: 'showBuoys', label: '부표' }, + { key: 'showBeacons', label: '비콘' }, + { key: 'showLights', label: '등대' }, + { key: 'showDangers', label: '위험물' }, + { key: 'showLandmarks', label: '랜드마크' }, + { key: 'showSoundings', label: '수심' }, + { key: 'showPilot', label: '도선소' }, + { key: 'showAnchorage', label: '정박지' }, + { key: 'showRestricted', label: '제한구역' }, + { key: 'showDredged', label: '준설구역' }, + { key: 'showTSS', label: '통항분리대' }, + { key: 'showContours', label: '등심선' }, +]; + +const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [ + { key: 'backgroundColor', label: '바다 배경' }, + { key: 'landColor', label: '육지' }, + { key: 'coastlineColor', label: '해안선' }, +]; + +export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) { + const [open, setOpen] = useState(false); + + const update = (key: K, val: EncMapSettings[K]) => { + onChange({ ...value, [key]: val }); + }; + + const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS); + + const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean); + const toggleAll = (checked: boolean) => { + const next = { ...value }; + for (const { key } of SYMBOL_TOGGLES) { + (next as Record)[key] = checked; + } + onChange(next); + }; + + return ( + <> + + + {open && ( +
+
+ ENC 설정 + {!isDefault && ( + + )} +
+ + {/* ── 레이어 토글 ── */} +
+
+ 레이어 표시 + +
+
+ {SYMBOL_TOGGLES.map(({ key, label }) => ( + + ))} +
+
+ + {/* ── 영역 색상 ── */} +
+
영역 색상
+ {AREA_COLOR_INPUTS.map(({ key, label }) => ( +
+ {label} + update(key, e.target.value as never)} + title={label} + /> +
+ ))} +
+ + {/* ── 수심별 색상 ── */} +
+
수심 색상
+ {ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => ( +
+ {label} + update(key, e.target.value as never)} + title={label} + /> +
+ ))} +
+
+ )} + + ); +} diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index f22cc60..33fe55f 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useRef, useState } from "react"; import { useAuth } from "../../shared/auth"; import { useTheme } from "../../shared/hooks"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; @@ -30,6 +30,8 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel"; import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel"; +import { EncMapSettingsPanel } from "../../features/encMap/ui/EncMapSettingsPanel"; +import { useEncMapSettings } from "../../features/encMap/hooks/useEncMapSettings"; import { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement"; import { useVesselSelectModal } from "../../features/vesselSelect"; import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal"; @@ -109,6 +111,14 @@ export function DashboardPage() { alarmKindEnabled, } = state; + // ── ENC map settings (runtime updates) ── + const mapRefForEnc = useRef(null); + const handleMapReadyWithRef = useCallback((map: import('maplibre-gl').Map) => { + mapRefForEnc.current = map; + handleMapReady(map); + }, [handleMapReady]); + useEncMapSettings(mapRefForEnc, baseMap, state.encMapSettings); + // ── Weather ── const weather = useWeatherPolling(zones); const weatherOverlay = useWeatherOverlay(mapInstance); @@ -442,11 +452,12 @@ export function DashboardPage() { onRequestTrack={handleRequestTrack} onCloseTrackMenu={handleCloseTrackMenu} onOpenTrackMenu={handleOpenTrackMenu} - onMapReady={handleMapReady} + onMapReady={handleMapReadyWithRef} alarmMmsiMap={alarmMmsiMap} onClickShipPhoto={handleOpenImageModal} freeCamera={state.freeCamera} oceanMapSettings={state.oceanMapSettings} + encMapSettings={state.encMapSettings} /> {baseMap === 'ocean' ? ( - ) : baseMap !== 'osm' ? ( + ) : baseMap === 'enc' ? ( + + ) : ( - ) : null} - {baseMap !== 'ocean' && baseMap !== 'osm' && } + )} + {baseMap !== 'ocean' && baseMap !== 'enc' && } {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} /> diff --git a/apps/web/src/pages/dashboard/DashboardSidebar.tsx b/apps/web/src/pages/dashboard/DashboardSidebar.tsx index 44019e5..f307d6d 100644 --- a/apps/web/src/pages/dashboard/DashboardSidebar.tsx +++ b/apps/web/src/pages/dashboard/DashboardSidebar.tsx @@ -187,12 +187,12 @@ export function DashboardSidebar({ Base setBaseMap('osm')} - title="OSM 기본 래스터 지도" + on={baseMap === 'enc'} + onClick={() => setBaseMap('enc')} + title="ENC 전자해도" className="px-2 py-0.5 text-[9px]" > - OSM + ENC ('mercator'); const [mapStyleSettings, setMapStyleSettings] = usePersistedState(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = usePersistedState(uid, 'overlays', { - pairLines: true, pairRange: true, fcLines: true, zones: true, - fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, + pairLines: false, pairRange: false, fcLines: false, zones: true, + fleetCircles: false, predictVectors: false, shipLabels: true, subcables: false, }); const [settings, setSettings] = usePersistedState(uid, 'map3dSettings', { showShips: true, showDensity: false, showSeamark: false, }); const [mapView, setMapView] = usePersistedState(uid, 'mapView', null); const [oceanMapSettings, setOceanMapSettings] = usePersistedState(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS); + const [encMapSettingsRaw, setEncMapSettings] = usePersistedState(uid, 'encMapSettings', DEFAULT_ENC_MAP_SETTINGS); + // Merge with defaults to fill missing fields from older localStorage entries + const encMapSettings: EncMapSettings = { ...DEFAULT_ENC_MAP_SETTINGS, ...encMapSettingsRaw }; // ── 자유 시점 (모드별 독립) ── const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true); @@ -68,7 +73,7 @@ export function useDashboardState(uid: number | null) { const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState(uid, 'sortMode', 'count'); const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState>( uid, 'alarmKindEnabled', - () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record, + () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, false])) as Record, ); // ── Fleet focus ── @@ -142,7 +147,9 @@ export function useDashboardState(uid: number | null) { baseMap, setBaseMap, projection, setProjection, mapStyleSettings, setMapStyleSettings, overlays, setOverlays, settings, setSettings, - mapView, setMapView, freeCamera, toggleFreeCamera, oceanMapSettings, setOceanMapSettings, + mapView, setMapView, freeCamera, toggleFreeCamera, + oceanMapSettings, setOceanMapSettings, + encMapSettings, setEncMapSettings, fleetRelationSortMode, setFleetRelationSortMode, alarmKindEnabled, setAlarmKindEnabled, fleetFocus, setFleetFocus, diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index a7c4182..6745eff 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -29,6 +29,7 @@ import { useSubcablesLayer } from './hooks/useSubcablesLayer'; import { useTrackReplayLayer } from './hooks/useTrackReplayLayer'; import { useMapStyleSettings } from './hooks/useMapStyleSettings'; import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings'; +import { useShipLabelColor } from './hooks/useShipLabelColor'; import { VesselContextMenu } from './components/VesselContextMenu'; import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter'; import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender'; @@ -86,6 +87,7 @@ export function Map3D({ onClickShipPhoto, freeCamera = true, oceanMapSettings, + encMapSettings, }: Props) { // ── Shared refs ────────────────────────────────────────────────────── const containerRef = useRef(null); @@ -578,6 +580,7 @@ export function Map3D({ useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch }); useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch }); + const shipLabelColors = useShipLabelColor(mapRef, baseMap, mapSyncEpoch, encMapSettings); useZonesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, @@ -635,6 +638,7 @@ export function Map3D({ onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, ensureMercatorOverlay, alarmMmsiMap, onClickShipPhoto, + shipLabelColors, }, ); diff --git a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts index 7d30a91..dd93da9 100644 --- a/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts +++ b/apps/web/src/widgets/map3d/hooks/useBaseMapToggle.ts @@ -19,26 +19,32 @@ export function useBaseMapToggle( const showSeamarkRef = useRef(showSeamark); const bathyZoomProfileKeyRef = useRef(''); + const initialLoadRef = useRef(true); useEffect(() => { showSeamarkRef.current = showSeamark; }, [showSeamark]); - // Base map style toggle + // Base map style toggle — skip first run (useMapInit handles initial style) useEffect(() => { + if (initialLoadRef.current) { + initialLoadRef.current = false; + return; + } const map = mapRef.current; if (!map) return; let cancelled = false; const controller = new AbortController(); - let stop: (() => void) | null = null; (async () => { try { const style = await resolveMapStyle(baseMap, controller.signal); if (cancelled) return; map.setStyle(style, { diff: false }); - stop = onMapStyleReady(map, () => { + + map.once('style.load', () => { + if (cancelled) return; kickRepaint(map); requestAnimationFrame(() => kickRepaint(map)); pulseMapSync(); @@ -52,7 +58,6 @@ export function useBaseMapToggle( return () => { cancelled = true; controller.abort(); - stop?.(); }; }, [baseMap]); @@ -63,7 +68,7 @@ export function useBaseMapToggle( const apply = () => { if (!map.isStyleLoaded()) return; - if (baseMap === 'osm') return; + if (baseMap === 'enc') return; const seaVisibility = 'visible' as const; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; diff --git a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts index 7fca618..94ff351 100644 --- a/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useDeckLayers.ts @@ -83,6 +83,7 @@ export function useDeckLayers( ensureMercatorOverlay: () => MapboxOverlay | null; alarmMmsiMap?: Map; onClickShipPhoto?: (mmsi: number) => void; + shipLabelColors?: import('../lib/labelColor').ShipLabelColors; }, ) { const { @@ -97,12 +98,15 @@ export function useDeckLayers( onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, ensureMercatorOverlay, alarmMmsiMap, onClickShipPhoto, + shipLabelColors, } = opts; + // Use shipLayerData (clustered/visible) instead of shipData (all) so halo + // only appears for targets that are currently rendered after clustering. const legacyTargets = useMemo(() => { if (!legacyHits) return []; - return shipData.filter((t) => legacyHits.has(t.mmsi)); - }, [shipData, legacyHits]); + return shipLayerData.filter((t) => legacyHits.has(t.mmsi)); + }, [shipLayerData, legacyHits]); const legacyTargetsOrdered = useMemo(() => { if (legacyTargets.length === 0) return legacyTargets; @@ -121,8 +125,8 @@ export function useDeckLayers( const alarmTargets = useMemo(() => { if (!alarmMmsiMap || alarmMmsiMap.size === 0) return []; - return shipData.filter((t) => alarmMmsiMap.has(t.mmsi)); - }, [shipData, alarmMmsiMap]); + return shipLayerData.filter((t) => alarmMmsiMap.has(t.mmsi)); + }, [shipLayerData, alarmMmsiMap]); const shipPhotoTargets = useMemo(() => { return shipData.filter((t) => !!t.shipImagePath); @@ -184,6 +188,7 @@ export function useDeckLayers( alarmPulseHoverRadius: 12, shipPhotoTargets, onClickShipPhoto, + shipLabelColors, }); const normalizedBaseLayers = sanitizeDeckLayerList(layers); @@ -288,6 +293,7 @@ export function useDeckLayers( alarmMmsiMap, shipPhotoTargets, onClickShipPhoto, + shipLabelColors, ]); // Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류 diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts index e1799e8..8cf61ff 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeFcFleetOverlay.ts @@ -244,11 +244,9 @@ export function useGlobeFcFleetOverlay( : false; // ── FC lines ── - const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0; - const fcVisible = overlays.fcLines || pairActive; + const fcVisible = overlays.fcLines; // ── Fleet circles ── - const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; - const fleetVisible = overlays.fleetCircles || fleetActive; + const fleetVisible = overlays.fleetCircles; try { if (map.getLayer('fc-lines-ml')) { map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts index d2fa944..953e8b4 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobePairOverlay.ts @@ -241,7 +241,7 @@ export function useGlobePairOverlay( : false; // ── Pair lines: 가시성 + 하이라이트 ── - const pairLinesVisible = overlays.pairLines || active; + const pairLinesVisible = overlays.pairLines; try { if (map.getLayer('pair-lines-ml')) { map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0); @@ -265,7 +265,7 @@ export function useGlobePairOverlay( } // ── Pair range: 가시성 + 하이라이트 ── - const pairRangeVisible = overlays.pairRange || active; + const pairRangeVisible = overlays.pairRange; try { if (map.getLayer('pair-range-ml')) { map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0); diff --git a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts index 6727a24..8f07570 100644 --- a/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts +++ b/apps/web/src/widgets/map3d/hooks/useGlobeShipLayers.ts @@ -9,11 +9,8 @@ import type { Map3DSettings, MapProjectionId } from '../types'; import { ANCHORED_SHIP_ICON_ID, GLOBE_ICON_HEADING_OFFSET_DEG, - GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, } from '../constants'; import { isFiniteNumber } from '../lib/setUtils'; -import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { isAnchoredShip, @@ -100,7 +97,7 @@ export function useGlobeShipLayers( features: shipData.map((t) => { const legacy = legacyHits?.get(t.mmsi) ?? null; const alarmKind = alarmMmsiMap?.get(t.mmsi) ?? null; - const baseName = legacy?.shipNameCn || legacy?.shipNameRoman || t.name || ''; + const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim(); const labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName; const heading = getDisplayHeading({ cog: t.cog, @@ -306,87 +303,7 @@ export function useGlobeShipLayers( ['==', ['to-number', ['get', 'alarmed'], 0], 0], ] as unknown as unknown[]; - if (!map.getLayer(haloId)) { - needReorder = true; - try { - map.addLayer( - { - id: haloId, - type: 'circle', - source: srcId, - layout: { - visibility, - 'circle-sort-key': [ - 'case', - ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 112, - ['==', ['get', 'permitted'], 1], 110, - ['==', ['get', 'alarmed'], 1], 22, - 20, - ] as never, - }, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'circle-opacity': [ - 'case', - ['==', ['feature-state', 'selected'], 1], 0.38, - ['==', ['feature-state', 'highlighted'], 1], 0.34, - ['==', ['get', 'permitted'], 1], 0.16, - 0.25, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship halo layer add failed:', e); - } - } - - if (!map.getLayer(outlineId)) { - needReorder = true; - try { - map.addLayer( - { - id: outlineId, - type: 'circle', - source: srcId, - paint: { - 'circle-radius': GLOBE_SHIP_CIRCLE_RADIUS_EXPR, - 'circle-color': 'rgba(0,0,0,0)', - 'circle-stroke-color': [ - 'case', - ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', - ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', - ['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED, - GLOBE_OUTLINE_OTHER, - ] as never, - 'circle-stroke-width': [ - 'case', - ['==', ['feature-state', 'selected'], 1], 3.4, - ['==', ['feature-state', 'highlighted'], 1], 2.7, - ['==', ['get', 'permitted'], 1], 1.8, - 1.2, - ] as never, - 'circle-stroke-opacity': 0.85, - }, - layout: { - visibility, - 'circle-sort-key': [ - 'case', - ['all', ['==', ['get', 'alarmed'], 1], ['==', ['get', 'permitted'], 1]], 122, - ['==', ['get', 'permitted'], 1], 120, - ['==', ['get', 'alarmed'], 1], 32, - 30, - ] as never, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship outline layer add failed:', e); - } - } + // Ship halo + outline circles — disabled (아이콘 본체만 표시) // Alarm pulse circle (above outline, below ship icons) // Uses separate alarm source for stable rendering @@ -424,7 +341,7 @@ export function useGlobeShipLayers( id: symbolLiteId, type: 'symbol', source: srcId, - minzoom: 6.5, + minzoom: 2, filter: nonPriorityFilter as never, layout: { visibility, @@ -439,16 +356,12 @@ export function useGlobeShipLayers( 'interpolate', ['linear'], ['zoom'], - 6.5, - ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], - 8, - ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], - 10, - ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], - 14, - ['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78], - 18, - ['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78], + 2, 0.5, + 5, 0.6, + 8, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.806], 0.6], + 10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.936], 0.7], + 14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.014], + 18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.014], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, @@ -468,15 +381,14 @@ export function useGlobeShipLayers( 'interpolate', ['linear'], ['zoom'], - 6.5, - 0.28, - 8, - 0.45, - 11, - 0.65, - 14, - 0.78, + 6.5, 0.6, + 8, 0.75, + 11, 0.9, + 14, 1, ] as never, + 'icon-halo-color': 'rgba(0,0,0,0.5)', + 'icon-halo-width': 0.8, + 'icon-halo-blur': 0.3, }, } as unknown as LayerSpecification, before, @@ -512,11 +424,12 @@ export function useGlobeShipLayers( ] as never, 'icon-size': [ 'interpolate', ['linear'], ['zoom'], - 3, ['to-number', ['get', 'iconSize3'], 0.35], - 7, ['to-number', ['get', 'iconSize7'], 0.45], - 10, ['to-number', ['get', 'iconSize10'], 0.58], - 14, ['to-number', ['get', 'iconSize14'], 0.85], - 18, ['to-number', ['get', 'iconSize18'], 2.5], + 2, 0.8, + 5, 0.9, + 7, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 1.3], 0.9], + 10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 1.3], 0.9], + 14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.3], + 18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.3], ] as unknown as number[], 'icon-allow-overlap': true, 'icon-ignore-placement': true, @@ -531,13 +444,20 @@ export function useGlobeShipLayers( }, paint: { 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, - 'icon-opacity': [ + 'icon-opacity': 1, + 'icon-halo-color': [ 'case', - ['==', ['feature-state', 'selected'], 1], 1, - ['==', ['feature-state', 'highlighted'], 1], 0.95, - ['==', ['get', 'permitted'], 1], 0.93, - 0.9, + ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + 'rgba(0,0,0,0.7)', ] as never, + 'icon-halo-width': [ + 'case', + ['==', ['feature-state', 'selected'], 1], 2.5, + ['==', ['feature-state', 'highlighted'], 1], 2, + 1, + ] as never, + 'icon-halo-blur': 0.5, }, } as unknown as LayerSpecification, before, @@ -547,33 +467,7 @@ export function useGlobeShipLayers( } } - // Photo indicator circle (above ship icons, below labels) - if (!map.getLayer(photoId)) { - needReorder = true; - try { - map.addLayer( - { - id: photoId, - type: 'circle', - source: srcId, - filter: ['==', ['get', 'hasPhoto'], 1] as never, - layout: { visibility: photoVisibility }, - paint: { - 'circle-radius': [ - 'interpolate', ['linear'], ['zoom'], - 3, 3, 7, 4, 10, 5, 14, 6, - ] as never, - 'circle-color': 'rgba(0, 188, 212, 0.7)', - 'circle-stroke-color': 'rgba(255, 255, 255, 0.8)', - 'circle-stroke-width': 1, - }, - } as unknown as LayerSpecification, - before, - ); - } catch (e) { - console.warn('Ship photo indicator layer add failed:', e); - } - } + // Photo indicator circle — disabled (파란 원 아이콘 제거) const labelFilter = [ 'all', @@ -589,13 +483,13 @@ export function useGlobeShipLayers( id: labelId, type: 'symbol', source: srcId, - minzoom: 7, + minzoom: 4, filter: labelFilter as never, layout: { visibility: labelVisibility, 'symbol-placement': 'point', 'text-field': ['get', 'labelName'] as never, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never, 'text-anchor': 'top', 'text-offset': [0, 1.1], @@ -636,7 +530,7 @@ export function useGlobeShipLayers( layout: { visibility, 'text-field': ['get', 'alarmBadgeLabel'] as never, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-size': 11, 'text-allow-overlap': true, 'text-ignore-placement': true, diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts index 70b6311..262c29f 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -163,8 +163,8 @@ export function useMapStyleSettings( const map = mapRef.current; const s = settingsRef.current; if (!map || !s) return; - // Ocean 모드는 useOceanMapSettings에서 별도 처리 - if (baseMap === 'ocean') return; + // Ocean/ENC 모드는 전용 훅에서 별도 처리 + if (baseMap === 'ocean' || baseMap === 'enc') return; const stop = onMapStyleReady(map, () => { applyLabelLanguage(map, s.labelLanguage); diff --git a/apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts b/apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts new file mode 100644 index 0000000..42b5856 --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts @@ -0,0 +1,55 @@ +import { useEffect, useMemo, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { BaseMapId } from '../types'; +import { computeShipLabelColors, type ShipLabelColors } from '../lib/labelColor'; +import type { EncMapSettings } from '../../../features/encMap/model/types'; +import { DEFAULT_ENC_MAP_SETTINGS } from '../../../features/encMap/model/types'; + +/** Default colors for non-ENC basemaps (dark background) */ +const DARK_BG_COLORS = computeShipLabelColors('#010610'); + +/** + * Compute ship label colors based on the current basemap background. + * Updates Globe MapLibre text-color paint property on style changes. + */ +export function useShipLabelColor( + mapRef: MutableRefObject, + baseMap: BaseMapId, + mapSyncEpoch: number, + encMapSettings?: EncMapSettings, +): ShipLabelColors { + const bgHex = baseMap === 'enc' + ? (encMapSettings ?? DEFAULT_ENC_MAP_SETTINGS).backgroundColor + : '#010610'; + + const colors = useMemo(() => computeShipLabelColors(bgHex), [bgHex]); + + // Update Globe label paint properties when colors change + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + const applyGlobeLabelColor = () => { + const labelLayerId = 'ships-globe-label'; + try { + if (!map.getLayer(labelLayerId)) return; + + // Preserve selected/highlighted colors, only change default + map.setPaintProperty(labelLayerId, 'text-color', [ + 'case', + ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)', + ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)', + colors.mlDefault, + ] as never); + map.setPaintProperty(labelLayerId, 'text-halo-color', colors.mlHalo); + } catch { + // layer may not exist yet + } + }; + + // Apply immediately and also on next style ready + applyGlobeLabelColor(); + }, [colors, mapSyncEpoch]); + + return baseMap === 'enc' ? colors : DARK_BG_COLORS; +} diff --git a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts index 23c290c..5da3812 100644 --- a/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useSubcablesLayer.ts @@ -101,7 +101,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [ 'symbol-placement': 'line', 'text-field': ['get', 'name'], 'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-allow-overlap': false, 'text-padding': 8, 'text-rotation-alignment': 'map', @@ -123,7 +123,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [ 'symbol-placement': 'line', 'text-field': ['get', 'name'], 'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20], - 'text-font': ['Noto Sans Bold', 'Open Sans Bold'], + 'text-font': ['Noto Sans Bold'], 'text-allow-overlap': true, 'text-padding': 2, 'text-rotation-alignment': 'map', diff --git a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts index ac90c04..209100c 100644 --- a/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts +++ b/apps/web/src/widgets/map3d/hooks/useZonesLayer.ts @@ -231,7 +231,7 @@ export function useZonesLayer( 'symbol-placement': 'point', 'text-field': zoneLabelExpr as never, 'text-size': 11, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-anchor': 'top', 'text-offset': [0, 0.35], 'text-allow-overlap': false, diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 5bc8904..21426df 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -224,7 +224,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK layout: { 'symbol-placement': 'line', 'text-field': depthLabel, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12], 'text-allow-overlap': false, 'text-padding': 4, @@ -249,7 +249,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK layout: { 'symbol-placement': 'line', 'text-field': depthLabel, - 'text-font': ['Noto Sans Regular', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16], 'text-allow-overlap': false, 'text-padding': 4, @@ -272,7 +272,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK filter: ['has', 'name'] as unknown as unknown[], layout: { 'text-field': ['get', 'name'] as unknown as unknown[], - 'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'], + 'text-font': ['Noto Sans Regular'], 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14], 'text-allow-overlap': false, 'text-anchor': 'center', @@ -394,22 +394,9 @@ export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle'); return resolveOceanStyle(signal); } - if (baseMap === 'osm') { - void signal; - return { - version: 8, - name: 'OSM Raster', - glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', - sources: { - osm: { - type: 'raster', - tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], - tileSize: 256, - attribution: '© OpenStreetMap contributors', - }, - }, - layers: [{ id: 'osm', type: 'raster', source: 'osm' }], - } satisfies StyleSpecification; + if (baseMap === 'enc') { + const { fetchEncStyle } = await import('../../../features/encMap/lib/encStyle'); + return fetchEncStyle(signal); } // 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용 // if (baseMap === 'legacy') return '/map/styles/carto-dark.json'; diff --git a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts index e266515..cd9ffe6 100644 --- a/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts +++ b/apps/web/src/widgets/map3d/lib/deckLayerFactories.ts @@ -91,6 +91,7 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect alarmPulseHoverRadius?: number; shipPhotoTargets?: AisTarget[]; onClickShipPhoto?: (mmsi: number) => void; + shipLabelColors?: import('./labelColor').ShipLabelColors; } export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { @@ -381,17 +382,17 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ } } - /* ─ interactive overlays ─ */ - if (ctx.pairRangesInteractive.length > 0) { + /* ─ interactive overlays (only when parent overlay is enabled) ─ */ + if (ctx.overlays.pairRange && ctx.pairRangesInteractive.length > 0) { layers.push(new ScatterplotLayer({ id: 'pair-range-overlay', data: ctx.pairRangesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, radiusMinPixels: 10, lineWidthUnits: 'pixels', getLineWidth: () => 2.8, getLineColor: (d) => (d.warn ? PAIR_RANGE_WARN_DECK_HL : PAIR_RANGE_NORMAL_DECK_HL), getPosition: (d) => d.center })); } - if (ctx.pairLinksInteractive.length > 0) { + if (ctx.overlays.pairLines && ctx.pairLinksInteractive.length > 0) { layers.push(new LineLayer({ id: 'pair-lines-overlay', data: ctx.pairLinksInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.warn ? PAIR_LINE_WARN_DECK_HL : PAIR_LINE_NORMAL_DECK_HL), getWidth: () => 4.5, widthUnits: 'pixels' })); } - if (ctx.fcLinesInteractive.length > 0) { + if (ctx.overlays.fcLines && ctx.fcLinesInteractive.length > 0) { layers.push(new LineLayer({ id: 'fc-lines-overlay', data: ctx.fcLinesInteractive, pickable: false, parameters: overlayParams, getSourcePosition: (d) => d.from, getTargetPosition: (d) => d.to, getColor: (d) => (d.suspicious ? FC_LINE_SUSPICIOUS_DECK_HL : FC_LINE_NORMAL_DECK_HL), getWidth: () => 3.2, widthUnits: 'pixels' })); } - if (ctx.fleetCirclesInteractive.length > 0) { + if (ctx.overlays.fleetCircles && ctx.fleetCirclesInteractive.length > 0) { layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay-fill', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: true, stroked: false, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, getFillColor: () => FLEET_RANGE_FILL_DECK_HL })); layers.push(new ScatterplotLayer({ id: 'fleet-circles-overlay', data: ctx.fleetCirclesInteractive, pickable: false, billboard: false, parameters: overlayParams, filled: false, stroked: true, radiusUnits: 'meters', getRadius: (d) => d.radiusNm * 1852, lineWidthUnits: 'pixels', getLineWidth: () => 3.0, getLineColor: () => FLEET_RANGE_LINE_DECK_HL, getPosition: (d) => d.center })); } @@ -436,7 +437,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ billboard: true, getText: (d) => { const legacy = ctx.legacyHits?.get(d.mmsi); - const baseName = (legacy?.shipNameCn || legacy?.shipNameRoman || d.name || '').trim(); + const baseName = (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || d.name?.toUpperCase() || '').trim(); if (!baseName) return ''; const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null; return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName; @@ -445,7 +446,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ getColor: (d) => { if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242]; if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 242]; - return [226, 232, 240, 234]; + return ctx.shipLabelColors?.deckDefault ?? [226, 232, 240, 234]; }, getSize: 11, sizeUnits: 'pixels', @@ -454,7 +455,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ getPixelOffset: [0, 16], getTextAnchor: 'middle', outlineWidth: 1, - outlineColor: [0, 0, 0, 230], + outlineColor: ctx.shipLabelColors?.deckOutline ?? [0, 0, 0, 230], }), ); } @@ -516,30 +517,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[ ); } - /* ─ ship photo indicator (사진 유무 표시) ─ */ - const photoTargets = ctx.shipPhotoTargets ?? []; - if (ctx.showShips && photoTargets.length > 0) { - layers.push( - new ScatterplotLayer({ - id: 'ship-photo-indicator', - data: photoTargets, - pickable: true, - billboard: false, - filled: true, - stroked: true, - radiusUnits: 'pixels', - getRadius: 5, - getFillColor: [0, 188, 212, 180], - getLineColor: [255, 255, 255, 200], - lineWidthUnits: 'pixels', - getLineWidth: 1, - getPosition: (d) => [d.lon, d.lat] as [number, number], - onClick: (info: PickingInfo) => { - if (info.object) ctx.onClickShipPhoto?.((info.object as AisTarget).mmsi); - }, - }), - ); - } + /* ─ ship photo indicator — disabled (파란 원 아이콘 제거) ─ */ return layers; } diff --git a/apps/web/src/widgets/map3d/lib/labelColor.ts b/apps/web/src/widgets/map3d/lib/labelColor.ts new file mode 100644 index 0000000..5f6564a --- /dev/null +++ b/apps/web/src/widgets/map3d/lib/labelColor.ts @@ -0,0 +1,59 @@ +/** + * Compute a readable ship label color based on the map background luminance. + * Returns RGBA arrays for Deck.gl and CSS string for MapLibre. + */ + +function hexToRgb(hex: string): [number, number, number] { + const h = hex.replace('#', ''); + return [ + parseInt(h.slice(0, 2), 16), + parseInt(h.slice(2, 4), 16), + parseInt(h.slice(4, 6), 16), + ]; +} + +/** Relative luminance (WCAG) */ +function luminance(r: number, g: number, b: number): number { + const [rs, gs, bs] = [r, g, b].map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs; +} + +export interface ShipLabelColors { + /** Deck.gl TextLayer default getColor [R,G,B,A] */ + deckDefault: [number, number, number, number]; + /** MapLibre text-color CSS string */ + mlDefault: string; + /** MapLibre text-halo-color CSS string */ + mlHalo: string; + /** Deck.gl outlineColor [R,G,B,A] */ + deckOutline: [number, number, number, number]; +} + +/** + * Given a background hex color, compute label colors that contrast well. + */ +export function computeShipLabelColors(bgHex: string): ShipLabelColors { + const [r, g, b] = hexToRgb(bgHex); + const lum = luminance(r, g, b); + + // Light background (lum > 0.4): dark labels with light halo + // Dark background (lum <= 0.4): light labels with dark halo + if (lum > 0.4) { + return { + deckDefault: [30, 30, 40, 234], + mlDefault: 'rgba(30,30,40,0.92)', + mlHalo: 'rgba(255,255,255,0.85)', + deckOutline: [255, 255, 255, 210], + }; + } + + return { + deckDefault: [226, 232, 240, 234], + mlDefault: 'rgba(226,232,240,0.92)', + mlHalo: 'rgba(0,0,0,0.9)', + deckOutline: [0, 0, 0, 230], + }; +} diff --git a/apps/web/src/widgets/map3d/lib/shipUtils.ts b/apps/web/src/widgets/map3d/lib/shipUtils.ts index d18b4a1..b0230f6 100644 --- a/apps/web/src/widgets/map3d/lib/shipUtils.ts +++ b/apps/web/src/widgets/map3d/lib/shipUtils.ts @@ -90,7 +90,7 @@ export function buildGlobeShipFeature( selected: isSelected, highlighted: isHighlighted, permitted: legacy ? 1 : 0, - labelName: (t.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || '', + labelName: (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || t.name?.toUpperCase() || '').trim(), legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '', }; } diff --git a/apps/web/src/widgets/map3d/lib/tooltips.ts b/apps/web/src/widgets/map3d/lib/tooltips.ts index 399ff87..1909fb3 100644 --- a/apps/web/src/widgets/map3d/lib/tooltips.ts +++ b/apps/web/src/widgets/map3d/lib/tooltips.ts @@ -23,7 +23,7 @@ export function getTargetName( const legacy = legacyHits?.get(mmsi); const target = targetByMmsi.get(mmsi); return ( - (target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` + (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || target?.name?.toUpperCase() || '').trim() || `MMSI ${mmsi}` ); } diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index eb5769f..c2f7864 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -7,6 +7,7 @@ import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types'; import type { MapStyleSettings } from '../../features/mapSettings/types'; import type { OceanMapSettings } from '../../features/oceanMap/model/types'; +import type { EncMapSettings } from '../../features/encMap/model/types'; export type Map3DSettings = { showSeamark: boolean; @@ -14,7 +15,7 @@ export type Map3DSettings = { showDensity: boolean; }; -export type BaseMapId = 'enhanced' | 'osm' | 'ocean' | 'legacy'; +export type BaseMapId = 'enhanced' | 'enc' | 'ocean' | 'legacy'; export type MapProjectionId = 'mercator' | 'globe'; export interface MapViewState { @@ -78,6 +79,8 @@ export interface Map3DProps { freeCamera?: boolean; /** Ocean 지도 전용 설정 */ oceanMapSettings?: OceanMapSettings; + /** ENC 전자해도 전용 설정 */ + encMapSettings?: EncMapSettings; } export type DashSeg = { From f7ccab18dd38556be2dfb5f2384a8ff45b9ae7f9 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 25 Mar 2026 14:20:57 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index f5c4a56..1727ad8 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,25 @@ ## [Unreleased] +### 추가 +- ENC 전자해도 베이스맵 (gcnautical 타일 서버, S-52 49개 레이어 + 73개 스프라이트) +- ENC 설정 패널 — 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계 커스텀 +- 배경색 밝기 기반 선박 라벨 색상 자동 전환 +- Globe 선박 아이콘 SDF 테두리 (icon-halo) + +### 변경 +- Globe 선박 원형 halo/outline 제거 → 아이콘 본체만 표시 +- Globe 선박 아이콘 1.3배 스케일, 줌아웃 최소 크기 보장 (minzoom 2) +- 선박명 영문 우선 표시 (영문 → 한자 → AIS 순), 대문자 변환 +- 연결선/범위/선단 토글 off 시 인터랙티브 오버레이 완전 차단 +- 강조 링/알람 링 클러스터링 연동 (줌아웃 시 미표시 선박 제거) +- 기타 AIS 투명도 상향, Globe 줌아웃 시 가시성 개선 +- 폰트 Open Sans 폴백 전면 제거 → Noto Sans 단독 + +### 기타 +- 경고 필터 초기값 false, 연결선/범위/선단 초기 비활성 +- 사진 파란 원 아이콘 제거 (Globe + Mercator) + ## [2026-03-18] ### 추가 From 3839c6d224c450c58afb914561cf4e08742e8344 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 25 Mar 2026 14:22:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 1727ad8..e8b9146 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-25] + ### 추가 - ENC 전자해도 베이스맵 (gcnautical 타일 서버, S-52 49개 레이어 + 73개 스프라이트) - ENC 설정 패널 — 12개 레이어 토글, 영역 색상 3종, 수심 색상 5단계 커스텀