Merge pull request 'feat(encMap): ENC 전자해도 + 선박 표시 개선' (#61) from experiment/enc-gcnautical into develop

This commit is contained in:
htlee 2026-03-25 14:21:39 +09:00
커밋 a0239c3d44
27개의 변경된 파일706개의 추가작업 그리고 231개의 파일을 삭제

파일 보기

@ -141,6 +141,79 @@
border-color: var(--accent); 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 ──────────────────────────────────────────────────── */
.depth-legend { .depth-legend {

파일 보기

@ -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<maplibregl.Map | null>,
baseMap: BaseMapId,
settings: EncMapSettings,
) {
const prevRef = useRef<EncMapSettings>(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]);
}

파일 보기

@ -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';

파일 보기

@ -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
}
}
}
}

파일 보기

@ -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<StyleSpecification> {
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<string, unknown> }).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;
}

파일 보기

@ -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<string, string[]> = {
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'] },
];

파일 보기

@ -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 = <K extends keyof EncMapSettings>(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<string, unknown>)[key] = checked;
}
onChange(next);
};
return (
<>
<button
className={`map-settings-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="ENC 설정"
type="button"
>
</button>
{open && (
<div className="map-settings-panel">
<div className="ms-title">
ENC
{!isDefault && (
<button
className="ms-reset-btn"
onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
title="기본값 복원"
type="button"
>
</button>
)}
</div>
{/* ── 레이어 토글 ── */}
<div className="ms-section">
<div className="ms-label">
<label className="ms-toggle-item ms-toggle-all">
<input
type="checkbox"
checked={allChecked}
onChange={(e) => toggleAll(e.target.checked)}
/>
<span></span>
</label>
</div>
<div className="ms-toggle-grid">
{SYMBOL_TOGGLES.map(({ key, label }) => (
<label key={key} className="ms-toggle-item">
<input
type="checkbox"
checked={value[key] as boolean}
onChange={(e) => update(key, e.target.checked as never)}
/>
<span>{label}</span>
</label>
))}
</div>
</div>
{/* ── 영역 색상 ── */}
<div className="ms-section">
<div className="ms-label"> </div>
{AREA_COLOR_INPUTS.map(({ key, label }) => (
<div key={key} className="ms-color-row">
<span>{label}</span>
<input
type="color"
value={value[key] as string}
onChange={(e) => update(key, e.target.value as never)}
title={label}
/>
</div>
))}
</div>
{/* ── 수심별 색상 ── */}
<div className="ms-section">
<div className="ms-label"> </div>
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
<div key={key} className="ms-color-row">
<span>{label}</span>
<input
type="color"
value={value[key] as string}
onChange={(e) => update(key, e.target.value as never)}
title={label}
/>
</div>
))}
</div>
</div>
)}
</>
);
}

파일 보기

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import { useAuth } from "../../shared/auth"; import { useAuth } from "../../shared/auth";
import { useTheme } from "../../shared/hooks"; import { useTheme } from "../../shared/hooks";
import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling"; import { useAisTargetPolling } from "../../features/aisPolling/useAisTargetPolling";
@ -30,6 +30,8 @@ import { WeatherPanel } from "../../widgets/weatherPanel/WeatherPanel";
import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel"; import { WeatherOverlayPanel } from "../../widgets/weatherOverlay/WeatherOverlayPanel";
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel"; import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
import { OceanMapSettingsPanel } from "../../features/oceanMap/ui/OceanMapSettingsPanel"; 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 { useAnnouncementPopup, AnnouncementModal } from "../../features/announcement";
import { useVesselSelectModal } from "../../features/vesselSelect"; import { useVesselSelectModal } from "../../features/vesselSelect";
import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal"; import { VesselSelectModal } from "../../widgets/vesselSelect/VesselSelectModal";
@ -109,6 +111,14 @@ export function DashboardPage() {
alarmKindEnabled, alarmKindEnabled,
} = state; } = state;
// ── ENC map settings (runtime updates) ──
const mapRefForEnc = useRef<import('maplibre-gl').Map | null>(null);
const handleMapReadyWithRef = useCallback((map: import('maplibre-gl').Map) => {
mapRefForEnc.current = map;
handleMapReady(map);
}, [handleMapReady]);
useEncMapSettings(mapRefForEnc, baseMap, state.encMapSettings);
// ── Weather ── // ── Weather ──
const weather = useWeatherPolling(zones); const weather = useWeatherPolling(zones);
const weatherOverlay = useWeatherOverlay(mapInstance); const weatherOverlay = useWeatherOverlay(mapInstance);
@ -442,11 +452,12 @@ export function DashboardPage() {
onRequestTrack={handleRequestTrack} onRequestTrack={handleRequestTrack}
onCloseTrackMenu={handleCloseTrackMenu} onCloseTrackMenu={handleCloseTrackMenu}
onOpenTrackMenu={handleOpenTrackMenu} onOpenTrackMenu={handleOpenTrackMenu}
onMapReady={handleMapReady} onMapReady={handleMapReadyWithRef}
alarmMmsiMap={alarmMmsiMap} alarmMmsiMap={alarmMmsiMap}
onClickShipPhoto={handleOpenImageModal} onClickShipPhoto={handleOpenImageModal}
freeCamera={state.freeCamera} freeCamera={state.freeCamera}
oceanMapSettings={state.oceanMapSettings} oceanMapSettings={state.oceanMapSettings}
encMapSettings={state.encMapSettings}
/> />
<GlobalTrackReplayPanel <GlobalTrackReplayPanel
isVesselListOpen={vesselSelectModal.isOpen} isVesselListOpen={vesselSelectModal.isOpen}
@ -464,10 +475,12 @@ export function DashboardPage() {
<WeatherOverlayPanel {...weatherOverlay} /> <WeatherOverlayPanel {...weatherOverlay} />
{baseMap === 'ocean' ? ( {baseMap === 'ocean' ? (
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} /> <OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
) : baseMap !== 'osm' ? ( ) : baseMap === 'enc' ? (
<EncMapSettingsPanel value={state.encMapSettings} onChange={state.setEncMapSettings} />
) : (
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} /> <MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
) : null} )}
{baseMap !== 'ocean' && baseMap !== 'osm' && <DepthLegend depthStops={mapStyleSettings.depthStops} />} {baseMap !== 'ocean' && baseMap !== 'enc' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
<MapLegend /> <MapLegend />
{selectedLegacyVessel ? ( {selectedLegacyVessel ? (
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} /> <VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} imo={selectedTarget && selectedTarget.imo > 0 ? selectedTarget.imo : undefined} shipImagePath={selectedTarget?.shipImagePath} shipImageCount={selectedTarget?.shipImageCount} onOpenImageModal={handlePanelOpenImageModal} />

파일 보기

@ -187,12 +187,12 @@ export function DashboardSidebar({
Base Base
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton
on={baseMap === 'osm'} on={baseMap === 'enc'}
onClick={() => setBaseMap('osm')} onClick={() => setBaseMap('enc')}
title="OSM 기본 래스터 지도" title="ENC 전자해도"
className="px-2 py-0.5 text-[9px]" className="px-2 py-0.5 text-[9px]"
> >
OSM ENC
</ToggleButton> </ToggleButton>
<ToggleButton <ToggleButton
on={baseMap === 'ocean'} on={baseMap === 'ocean'}

파일 보기

@ -10,6 +10,8 @@ import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
import type { MapStyleSettings } from '../../features/mapSettings/types'; import type { MapStyleSettings } from '../../features/mapSettings/types';
import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types'; import { DEFAULT_OCEAN_MAP_SETTINGS } from '../../features/oceanMap/model/types';
import type { OceanMapSettings } from '../../features/oceanMap/model/types'; import type { OceanMapSettings } from '../../features/oceanMap/model/types';
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/model/types';
import type { EncMapSettings } from '../../features/encMap/model/types';
import { fmtDateTimeFull } from '../../shared/lib/datetime'; import { fmtDateTimeFull } from '../../shared/lib/datetime';
export type Bbox = [number, number, number, number]; export type Bbox = [number, number, number, number];
@ -46,14 +48,17 @@ export function useDashboardState(uid: number | null) {
const [projection, setProjection] = useState<MapProjectionId>('mercator'); const [projection, setProjection] = useState<MapProjectionId>('mercator');
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS); const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', { const [overlays, setOverlays] = usePersistedState<MapToggleState>(uid, 'overlays', {
pairLines: true, pairRange: true, fcLines: true, zones: true, pairLines: false, pairRange: false, fcLines: false, zones: true,
fleetCircles: true, predictVectors: true, shipLabels: true, subcables: false, fleetCircles: false, predictVectors: false, shipLabels: true, subcables: false,
}); });
const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', { const [settings, setSettings] = usePersistedState<Map3DSettings>(uid, 'map3dSettings', {
showShips: true, showDensity: false, showSeamark: false, showShips: true, showDensity: false, showSeamark: false,
}); });
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null); const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS); const [oceanMapSettings, setOceanMapSettings] = usePersistedState<OceanMapSettings>(uid, 'oceanMapSettings', DEFAULT_OCEAN_MAP_SETTINGS);
const [encMapSettingsRaw, setEncMapSettings] = usePersistedState<EncMapSettings>(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); const [freeCameraMercator, setFreeCameraMercator] = usePersistedState(uid, 'freeCameraMercator', true);
@ -68,7 +73,7 @@ export function useDashboardState(uid: number | null) {
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count'); const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>( const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
uid, 'alarmKindEnabled', uid, 'alarmKindEnabled',
() => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, true])) as Record<LegacyAlarmKind, boolean>, () => Object.fromEntries(LEGACY_ALARM_KINDS.map((k) => [k, false])) as Record<LegacyAlarmKind, boolean>,
); );
// ── Fleet focus ── // ── Fleet focus ──
@ -142,7 +147,9 @@ export function useDashboardState(uid: number | null) {
baseMap, setBaseMap, projection, setProjection, baseMap, setBaseMap, projection, setProjection,
mapStyleSettings, setMapStyleSettings, mapStyleSettings, setMapStyleSettings,
overlays, setOverlays, settings, setSettings, overlays, setOverlays, settings, setSettings,
mapView, setMapView, freeCamera, toggleFreeCamera, oceanMapSettings, setOceanMapSettings, mapView, setMapView, freeCamera, toggleFreeCamera,
oceanMapSettings, setOceanMapSettings,
encMapSettings, setEncMapSettings,
fleetRelationSortMode, setFleetRelationSortMode, fleetRelationSortMode, setFleetRelationSortMode,
alarmKindEnabled, setAlarmKindEnabled, alarmKindEnabled, setAlarmKindEnabled,
fleetFocus, setFleetFocus, fleetFocus, setFleetFocus,

파일 보기

@ -29,6 +29,7 @@ import { useSubcablesLayer } from './hooks/useSubcablesLayer';
import { useTrackReplayLayer } from './hooks/useTrackReplayLayer'; import { useTrackReplayLayer } from './hooks/useTrackReplayLayer';
import { useMapStyleSettings } from './hooks/useMapStyleSettings'; import { useMapStyleSettings } from './hooks/useMapStyleSettings';
import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings'; import { useOceanMapSettings } from '../../features/oceanMap/hooks/useOceanMapSettings';
import { useShipLabelColor } from './hooks/useShipLabelColor';
import { VesselContextMenu } from './components/VesselContextMenu'; import { VesselContextMenu } from './components/VesselContextMenu';
import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter'; import { useLiveShipAdapter } from '../../features/liveRenderer/hooks/useLiveShipAdapter';
import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender'; import { useLiveShipBatchRender } from '../../features/liveRenderer/hooks/useLiveShipBatchRender';
@ -86,6 +87,7 @@ export function Map3D({
onClickShipPhoto, onClickShipPhoto,
freeCamera = true, freeCamera = true,
oceanMapSettings, oceanMapSettings,
encMapSettings,
}: Props) { }: Props) {
// ── Shared refs ────────────────────────────────────────────────────── // ── Shared refs ──────────────────────────────────────────────────────
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -578,6 +580,7 @@ export function Map3D({
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch }); useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch }); useOceanMapSettings(mapRef, oceanMapSettings, { baseMap, mapSyncEpoch });
const shipLabelColors = useShipLabelColor(mapRef, baseMap, mapSyncEpoch, encMapSettings);
useZonesLayer( useZonesLayer(
mapRef, projectionBusyRef, reorderGlobeFeatureLayers, mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
@ -635,6 +638,7 @@ export function Map3D({
onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi, onDeckSelectOrHighlight, onSelectMmsi: onMapSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap, ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto, onClickShipPhoto,
shipLabelColors,
}, },
); );

파일 보기

@ -19,26 +19,32 @@ export function useBaseMapToggle(
const showSeamarkRef = useRef(showSeamark); const showSeamarkRef = useRef(showSeamark);
const bathyZoomProfileKeyRef = useRef<string>(''); const bathyZoomProfileKeyRef = useRef<string>('');
const initialLoadRef = useRef(true);
useEffect(() => { useEffect(() => {
showSeamarkRef.current = showSeamark; showSeamarkRef.current = showSeamark;
}, [showSeamark]); }, [showSeamark]);
// Base map style toggle // Base map style toggle — skip first run (useMapInit handles initial style)
useEffect(() => { useEffect(() => {
if (initialLoadRef.current) {
initialLoadRef.current = false;
return;
}
const map = mapRef.current; const map = mapRef.current;
if (!map) return; if (!map) return;
let cancelled = false; let cancelled = false;
const controller = new AbortController(); const controller = new AbortController();
let stop: (() => void) | null = null;
(async () => { (async () => {
try { try {
const style = await resolveMapStyle(baseMap, controller.signal); const style = await resolveMapStyle(baseMap, controller.signal);
if (cancelled) return; if (cancelled) return;
map.setStyle(style, { diff: false }); map.setStyle(style, { diff: false });
stop = onMapStyleReady(map, () => {
map.once('style.load', () => {
if (cancelled) return;
kickRepaint(map); kickRepaint(map);
requestAnimationFrame(() => kickRepaint(map)); requestAnimationFrame(() => kickRepaint(map));
pulseMapSync(); pulseMapSync();
@ -52,7 +58,6 @@ export function useBaseMapToggle(
return () => { return () => {
cancelled = true; cancelled = true;
controller.abort(); controller.abort();
stop?.();
}; };
}, [baseMap]); }, [baseMap]);
@ -63,7 +68,7 @@ export function useBaseMapToggle(
const apply = () => { const apply = () => {
if (!map.isStyleLoaded()) return; if (!map.isStyleLoaded()) return;
if (baseMap === 'osm') return; if (baseMap === 'enc') return;
const seaVisibility = 'visible' as const; const seaVisibility = 'visible' as const;
const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i; const seaRegex = /(water|sea|ocean|river|lake|coast|bay)/i;

파일 보기

@ -83,6 +83,7 @@ export function useDeckLayers(
ensureMercatorOverlay: () => MapboxOverlay | null; ensureMercatorOverlay: () => MapboxOverlay | null;
alarmMmsiMap?: Map<number, LegacyAlarmKind>; alarmMmsiMap?: Map<number, LegacyAlarmKind>;
onClickShipPhoto?: (mmsi: number) => void; onClickShipPhoto?: (mmsi: number) => void;
shipLabelColors?: import('../lib/labelColor').ShipLabelColors;
}, },
) { ) {
const { const {
@ -97,12 +98,15 @@ export function useDeckLayers(
onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi, onDeckSelectOrHighlight, onSelectMmsi, onToggleHighlightMmsi,
ensureMercatorOverlay, alarmMmsiMap, ensureMercatorOverlay, alarmMmsiMap,
onClickShipPhoto, onClickShipPhoto,
shipLabelColors,
} = opts; } = opts;
// Use shipLayerData (clustered/visible) instead of shipData (all) so halo
// only appears for targets that are currently rendered after clustering.
const legacyTargets = useMemo(() => { const legacyTargets = useMemo(() => {
if (!legacyHits) return []; if (!legacyHits) return [];
return shipData.filter((t) => legacyHits.has(t.mmsi)); return shipLayerData.filter((t) => legacyHits.has(t.mmsi));
}, [shipData, legacyHits]); }, [shipLayerData, legacyHits]);
const legacyTargetsOrdered = useMemo(() => { const legacyTargetsOrdered = useMemo(() => {
if (legacyTargets.length === 0) return legacyTargets; if (legacyTargets.length === 0) return legacyTargets;
@ -121,8 +125,8 @@ export function useDeckLayers(
const alarmTargets = useMemo(() => { const alarmTargets = useMemo(() => {
if (!alarmMmsiMap || alarmMmsiMap.size === 0) return []; if (!alarmMmsiMap || alarmMmsiMap.size === 0) return [];
return shipData.filter((t) => alarmMmsiMap.has(t.mmsi)); return shipLayerData.filter((t) => alarmMmsiMap.has(t.mmsi));
}, [shipData, alarmMmsiMap]); }, [shipLayerData, alarmMmsiMap]);
const shipPhotoTargets = useMemo(() => { const shipPhotoTargets = useMemo(() => {
return shipData.filter((t) => !!t.shipImagePath); return shipData.filter((t) => !!t.shipImagePath);
@ -184,6 +188,7 @@ export function useDeckLayers(
alarmPulseHoverRadius: 12, alarmPulseHoverRadius: 12,
shipPhotoTargets, shipPhotoTargets,
onClickShipPhoto, onClickShipPhoto,
shipLabelColors,
}); });
const normalizedBaseLayers = sanitizeDeckLayerList(layers); const normalizedBaseLayers = sanitizeDeckLayerList(layers);
@ -288,6 +293,7 @@ export function useDeckLayers(
alarmMmsiMap, alarmMmsiMap,
shipPhotoTargets, shipPhotoTargets,
onClickShipPhoto, onClickShipPhoto,
shipLabelColors,
]); ]);
// Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류 // Mercator 브리딩 애니메이션 (rAF) — 알람 맥동 + 대상 선박 브리딩 합류

파일 보기

@ -244,11 +244,9 @@ export function useGlobeFcFleetOverlay(
: false; : false;
// ── FC lines ── // ── FC lines ──
const pairActive = hoveredPairMmsiList.length > 0 || hoveredFleetMmsiList.length > 0; const fcVisible = overlays.fcLines;
const fcVisible = overlays.fcLines || pairActive;
// ── Fleet circles ── // ── Fleet circles ──
const fleetActive = hoveredFleetOwnerKeyList.length > 0 || hoveredFleetMmsiList.length > 0; const fleetVisible = overlays.fleetCircles;
const fleetVisible = overlays.fleetCircles || fleetActive;
try { try {
if (map.getLayer('fc-lines-ml')) { if (map.getLayer('fc-lines-ml')) {
map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0); map.setPaintProperty('fc-lines-ml', 'line-opacity', fcVisible ? 0.9 : 0);

파일 보기

@ -241,7 +241,7 @@ export function useGlobePairOverlay(
: false; : false;
// ── Pair lines: 가시성 + 하이라이트 ── // ── Pair lines: 가시성 + 하이라이트 ──
const pairLinesVisible = overlays.pairLines || active; const pairLinesVisible = overlays.pairLines;
try { try {
if (map.getLayer('pair-lines-ml')) { if (map.getLayer('pair-lines-ml')) {
map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0); map.setPaintProperty('pair-lines-ml', 'line-opacity', pairLinesVisible ? 0.9 : 0);
@ -265,7 +265,7 @@ export function useGlobePairOverlay(
} }
// ── Pair range: 가시성 + 하이라이트 ── // ── Pair range: 가시성 + 하이라이트 ──
const pairRangeVisible = overlays.pairRange || active; const pairRangeVisible = overlays.pairRange;
try { try {
if (map.getLayer('pair-range-ml')) { if (map.getLayer('pair-range-ml')) {
map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0); map.setPaintProperty('pair-range-ml', 'line-opacity', pairRangeVisible ? 0.85 : 0);

파일 보기

@ -9,11 +9,8 @@ import type { Map3DSettings, MapProjectionId } from '../types';
import { import {
ANCHORED_SHIP_ICON_ID, ANCHORED_SHIP_ICON_ID,
GLOBE_ICON_HEADING_OFFSET_DEG, GLOBE_ICON_HEADING_OFFSET_DEG,
GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
} from '../constants'; } from '../constants';
import { isFiniteNumber } from '../lib/setUtils'; import { isFiniteNumber } from '../lib/setUtils';
import { GLOBE_SHIP_CIRCLE_RADIUS_EXPR } from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { import {
isAnchoredShip, isAnchoredShip,
@ -100,7 +97,7 @@ export function useGlobeShipLayers(
features: shipData.map((t) => { features: shipData.map((t) => {
const legacy = legacyHits?.get(t.mmsi) ?? null; const legacy = legacyHits?.get(t.mmsi) ?? null;
const alarmKind = alarmMmsiMap?.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 labelName = alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
const heading = getDisplayHeading({ const heading = getDisplayHeading({
cog: t.cog, cog: t.cog,
@ -306,87 +303,7 @@ export function useGlobeShipLayers(
['==', ['to-number', ['get', 'alarmed'], 0], 0], ['==', ['to-number', ['get', 'alarmed'], 0], 0],
] as unknown as unknown[]; ] as unknown as unknown[];
if (!map.getLayer(haloId)) { // Ship halo + outline circles — disabled (아이콘 본체만 표시)
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);
}
}
// Alarm pulse circle (above outline, below ship icons) // Alarm pulse circle (above outline, below ship icons)
// Uses separate alarm source for stable rendering // Uses separate alarm source for stable rendering
@ -424,7 +341,7 @@ export function useGlobeShipLayers(
id: symbolLiteId, id: symbolLiteId,
type: 'symbol', type: 'symbol',
source: srcId, source: srcId,
minzoom: 6.5, minzoom: 2,
filter: nonPriorityFilter as never, filter: nonPriorityFilter as never,
layout: { layout: {
visibility, visibility,
@ -439,16 +356,12 @@ export function useGlobeShipLayers(
'interpolate', 'interpolate',
['linear'], ['linear'],
['zoom'], ['zoom'],
6.5, 2, 0.5,
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.45], 5, 0.6,
8, 8, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.806], 0.6],
['*', ['to-number', ['get', 'iconSize7'], 0.45], 0.62], 10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.936], 0.7],
10, 14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.014],
['*', ['to-number', ['get', 'iconSize10'], 0.58], 0.72], 18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.014],
14,
['*', ['to-number', ['get', 'iconSize14'], 0.85], 0.78],
18,
['*', ['to-number', ['get', 'iconSize18'], 2.5], 0.78],
] as unknown as number[], ] as unknown as number[],
'icon-allow-overlap': true, 'icon-allow-overlap': true,
'icon-ignore-placement': true, 'icon-ignore-placement': true,
@ -468,15 +381,14 @@ export function useGlobeShipLayers(
'interpolate', 'interpolate',
['linear'], ['linear'],
['zoom'], ['zoom'],
6.5, 6.5, 0.6,
0.28, 8, 0.75,
8, 11, 0.9,
0.45, 14, 1,
11,
0.65,
14,
0.78,
] as never, ] 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, } as unknown as LayerSpecification,
before, before,
@ -512,11 +424,12 @@ export function useGlobeShipLayers(
] as never, ] as never,
'icon-size': [ 'icon-size': [
'interpolate', ['linear'], ['zoom'], 'interpolate', ['linear'], ['zoom'],
3, ['to-number', ['get', 'iconSize3'], 0.35], 2, 0.8,
7, ['to-number', ['get', 'iconSize7'], 0.45], 5, 0.9,
10, ['to-number', ['get', 'iconSize10'], 0.58], 7, ['max', ['*', ['to-number', ['get', 'iconSize7'], 0.45], 1.3], 0.9],
14, ['to-number', ['get', 'iconSize14'], 0.85], 10, ['max', ['*', ['to-number', ['get', 'iconSize10'], 0.58], 1.3], 0.9],
18, ['to-number', ['get', 'iconSize18'], 2.5], 14, ['*', ['to-number', ['get', 'iconSize14'], 0.85], 1.3],
18, ['*', ['to-number', ['get', 'iconSize18'], 2.5], 1.3],
] as unknown as number[], ] as unknown as number[],
'icon-allow-overlap': true, 'icon-allow-overlap': true,
'icon-ignore-placement': true, 'icon-ignore-placement': true,
@ -531,13 +444,20 @@ export function useGlobeShipLayers(
}, },
paint: { paint: {
'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never, 'icon-color': ['coalesce', ['get', 'shipColor'], '#64748b'] as never,
'icon-opacity': [ 'icon-opacity': 1,
'icon-halo-color': [
'case', 'case',
['==', ['feature-state', 'selected'], 1], 1, ['==', ['feature-state', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['feature-state', 'highlighted'], 1], 0.95, ['==', ['feature-state', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], 0.93, 'rgba(0,0,0,0.7)',
0.9,
] as never, ] 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, } as unknown as LayerSpecification,
before, before,
@ -547,33 +467,7 @@ export function useGlobeShipLayers(
} }
} }
// Photo indicator circle (above ship icons, below labels) // Photo indicator circle — disabled (파란 원 아이콘 제거)
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);
}
}
const labelFilter = [ const labelFilter = [
'all', 'all',
@ -589,13 +483,13 @@ export function useGlobeShipLayers(
id: labelId, id: labelId,
type: 'symbol', type: 'symbol',
source: srcId, source: srcId,
minzoom: 7, minzoom: 4,
filter: labelFilter as never, filter: labelFilter as never,
layout: { layout: {
visibility: labelVisibility, visibility: labelVisibility,
'symbol-placement': 'point', 'symbol-placement': 'point',
'text-field': ['get', 'labelName'] as never, '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-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 10, 11, 12, 12, 14, 13] as never,
'text-anchor': 'top', 'text-anchor': 'top',
'text-offset': [0, 1.1], 'text-offset': [0, 1.1],
@ -636,7 +530,7 @@ export function useGlobeShipLayers(
layout: { layout: {
visibility, visibility,
'text-field': ['get', 'alarmBadgeLabel'] as never, 'text-field': ['get', 'alarmBadgeLabel'] as never,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-font': ['Noto Sans Regular'],
'text-size': 11, 'text-size': 11,
'text-allow-overlap': true, 'text-allow-overlap': true,
'text-ignore-placement': true, 'text-ignore-placement': true,

파일 보기

@ -163,8 +163,8 @@ export function useMapStyleSettings(
const map = mapRef.current; const map = mapRef.current;
const s = settingsRef.current; const s = settingsRef.current;
if (!map || !s) return; if (!map || !s) return;
// Ocean 모드는 useOceanMapSettings에서 별도 처리 // Ocean/ENC 모드는 전용 훅에서 별도 처리
if (baseMap === 'ocean') return; if (baseMap === 'ocean' || baseMap === 'enc') return;
const stop = onMapStyleReady(map, () => { const stop = onMapStyleReady(map, () => {
applyLabelLanguage(map, s.labelLanguage); applyLabelLanguage(map, s.labelLanguage);

파일 보기

@ -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<maplibregl.Map | null>,
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;
}

파일 보기

@ -101,7 +101,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 4, 9, 8, 11, 12, 13], '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-allow-overlap': false,
'text-padding': 8, 'text-padding': 8,
'text-rotation-alignment': 'map', 'text-rotation-alignment': 'map',
@ -123,7 +123,7 @@ const LAYER_SPECS: NativeLayerSpec[] = [
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': ['get', 'name'], 'text-field': ['get', 'name'],
'text-size': ['interpolate', ['linear'], ['zoom'], 2, 14, 6, 17, 10, 20], '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-allow-overlap': true,
'text-padding': 2, 'text-padding': 2,
'text-rotation-alignment': 'map', 'text-rotation-alignment': 'map',

파일 보기

@ -231,7 +231,7 @@ export function useZonesLayer(
'symbol-placement': 'point', 'symbol-placement': 'point',
'text-field': zoneLabelExpr as never, 'text-field': zoneLabelExpr as never,
'text-size': 11, 'text-size': 11,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'], 'text-font': ['Noto Sans Regular'],
'text-anchor': 'top', 'text-anchor': 'top',
'text-offset': [0, 0.35], 'text-offset': [0, 0.35],
'text-allow-overlap': false, 'text-allow-overlap': false,

파일 보기

@ -224,7 +224,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
layout: { layout: {
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': depthLabel, '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-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-padding': 4, 'text-padding': 4,
@ -249,7 +249,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
layout: { layout: {
'symbol-placement': 'line', 'symbol-placement': 'line',
'text-field': depthLabel, '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-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-padding': 4, 'text-padding': 4,
@ -272,7 +272,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
filter: ['has', 'name'] as unknown as unknown[], filter: ['has', 'name'] as unknown as unknown[],
layout: { layout: {
'text-field': ['get', 'name'] as unknown as unknown[], '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-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
'text-allow-overlap': false, 'text-allow-overlap': false,
'text-anchor': 'center', 'text-anchor': 'center',
@ -394,22 +394,9 @@ export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal):
const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle'); const { resolveOceanStyle } = await import('../../../features/oceanMap/lib/resolveOceanStyle');
return resolveOceanStyle(signal); return resolveOceanStyle(signal);
} }
if (baseMap === 'osm') { if (baseMap === 'enc') {
void signal; const { fetchEncStyle } = await import('../../../features/encMap/lib/encStyle');
return { return fetchEncStyle(signal);
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 === 'legacy') return '/map/styles/carto-dark.json'; // if (baseMap === 'legacy') return '/map/styles/carto-dark.json';

파일 보기

@ -91,6 +91,7 @@ export interface MercatorDeckLayerContext extends DeckHoverCallbacks, DeckSelect
alarmPulseHoverRadius?: number; alarmPulseHoverRadius?: number;
shipPhotoTargets?: AisTarget[]; shipPhotoTargets?: AisTarget[];
onClickShipPhoto?: (mmsi: number) => void; onClickShipPhoto?: (mmsi: number) => void;
shipLabelColors?: import('./labelColor').ShipLabelColors;
} }
export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] { export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[] {
@ -381,17 +382,17 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
} }
} }
/* ─ interactive overlays ─ */ /* ─ interactive overlays (only when parent overlay is enabled) ─ */
if (ctx.pairRangesInteractive.length > 0) { if (ctx.overlays.pairRange && ctx.pairRangesInteractive.length > 0) {
layers.push(new ScatterplotLayer<PairRangeCircle>({ 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 })); layers.push(new ScatterplotLayer<PairRangeCircle>({ 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<PairLink>({ 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' })); layers.push(new LineLayer<PairLink>({ 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<DashSeg>({ 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' })); layers.push(new LineLayer<DashSeg>({ 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<FleetCircle>({ 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<FleetCircle>({ 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<FleetCircle>({ 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 })); layers.push(new ScatterplotLayer<FleetCircle>({ 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, billboard: true,
getText: (d) => { getText: (d) => {
const legacy = ctx.legacyHits?.get(d.mmsi); 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 ''; if (!baseName) return '';
const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null; const alarmKind = ctx.alarmMmsiMap?.get(d.mmsi) ?? null;
return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName; return alarmKind ? `[${ALARM_BADGE[alarmKind].label}] ${baseName}` : baseName;
@ -445,7 +446,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getColor: (d) => { getColor: (d) => {
if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242]; if (ctx.selectedMmsi != null && d.mmsi === ctx.selectedMmsi) return [14, 234, 255, 242];
if (ctx.shipHighlightSet.has(d.mmsi)) return [245, 158, 11, 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, getSize: 11,
sizeUnits: 'pixels', sizeUnits: 'pixels',
@ -454,7 +455,7 @@ export function buildMercatorDeckLayers(ctx: MercatorDeckLayerContext): unknown[
getPixelOffset: [0, 16], getPixelOffset: [0, 16],
getTextAnchor: 'middle', getTextAnchor: 'middle',
outlineWidth: 1, 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 (사진 유무 표시) ─ */ /* ─ ship photo indicator — disabled (파란 원 아이콘 제거) ─ */
const photoTargets = ctx.shipPhotoTargets ?? [];
if (ctx.showShips && photoTargets.length > 0) {
layers.push(
new ScatterplotLayer<AisTarget>({
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);
},
}),
);
}
return layers; return layers;
} }

파일 보기

@ -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],
};
}

파일 보기

@ -90,7 +90,7 @@ export function buildGlobeShipFeature(
selected: isSelected, selected: isSelected,
highlighted: isHighlighted, highlighted: isHighlighted,
permitted: legacy ? 1 : 0, 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})` : '', legacyTag: legacy ? `${legacy.permitNo} (${legacy.shipCode})` : '',
}; };
} }

파일 보기

@ -23,7 +23,7 @@ export function getTargetName(
const legacy = legacyHits?.get(mmsi); const legacy = legacyHits?.get(mmsi);
const target = targetByMmsi.get(mmsi); const target = targetByMmsi.get(mmsi);
return ( return (
(target?.name || '').trim() || legacy?.shipNameCn || legacy?.shipNameRoman || `MMSI ${mmsi}` (legacy?.shipNameRoman?.toUpperCase() || legacy?.shipNameCn || target?.name?.toUpperCase() || '').trim() || `MMSI ${mmsi}`
); );
} }

파일 보기

@ -7,6 +7,7 @@ import type { MapToggleState } from '../../features/mapToggles/MapToggles';
import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types'; import type { FcLink, FleetCircle, LegacyAlarmKind, PairLink } from '../../features/legacyDashboard/model/types';
import type { MapStyleSettings } from '../../features/mapSettings/types'; import type { MapStyleSettings } from '../../features/mapSettings/types';
import type { OceanMapSettings } from '../../features/oceanMap/model/types'; import type { OceanMapSettings } from '../../features/oceanMap/model/types';
import type { EncMapSettings } from '../../features/encMap/model/types';
export type Map3DSettings = { export type Map3DSettings = {
showSeamark: boolean; showSeamark: boolean;
@ -14,7 +15,7 @@ export type Map3DSettings = {
showDensity: boolean; showDensity: boolean;
}; };
export type BaseMapId = 'enhanced' | 'osm' | 'ocean' | 'legacy'; export type BaseMapId = 'enhanced' | 'enc' | 'ocean' | 'legacy';
export type MapProjectionId = 'mercator' | 'globe'; export type MapProjectionId = 'mercator' | 'globe';
export interface MapViewState { export interface MapViewState {
@ -78,6 +79,8 @@ export interface Map3DProps {
freeCamera?: boolean; freeCamera?: boolean;
/** Ocean 지도 전용 설정 */ /** Ocean 지도 전용 설정 */
oceanMapSettings?: OceanMapSettings; oceanMapSettings?: OceanMapSettings;
/** ENC 전자해도 전용 설정 */
encMapSettings?: EncMapSettings;
} }
export type DashSeg = { export type DashSeg = {

파일 보기

@ -4,6 +4,25 @@
## [Unreleased] ## [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] ## [2026-03-18]
### 추가 ### 추가