release: 2026-03-25 (5건 커밋) #63
@ -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 {
|
||||
|
||||
43
apps/web/src/features/encMap/hooks/useEncMapSettings.ts
Normal file
43
apps/web/src/features/encMap/hooks/useEncMapSettings.ts
Normal file
@ -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]);
|
||||
}
|
||||
6
apps/web/src/features/encMap/index.ts
Normal file
6
apps/web/src/features/encMap/index.ts
Normal file
@ -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';
|
||||
61
apps/web/src/features/encMap/lib/encSettings.ts
Normal file
61
apps/web/src/features/encMap/lib/encSettings.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
apps/web/src/features/encMap/lib/encStyle.ts
Normal file
28
apps/web/src/features/encMap/lib/encStyle.ts
Normal file
@ -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;
|
||||
}
|
||||
98
apps/web/src/features/encMap/model/types.ts
Normal file
98
apps/web/src/features/encMap/model/types.ts
Normal file
@ -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'] },
|
||||
];
|
||||
138
apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx
Normal file
138
apps/web/src/features/encMap/ui/EncMapSettingsPanel.tsx
Normal file
@ -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 { 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<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 ──
|
||||
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}
|
||||
/>
|
||||
<GlobalTrackReplayPanel
|
||||
isVesselListOpen={vesselSelectModal.isOpen}
|
||||
@ -464,10 +475,12 @@ export function DashboardPage() {
|
||||
<WeatherOverlayPanel {...weatherOverlay} />
|
||||
{baseMap === 'ocean' ? (
|
||||
<OceanMapSettingsPanel value={state.oceanMapSettings} onChange={state.setOceanMapSettings} />
|
||||
) : baseMap !== 'osm' ? (
|
||||
) : baseMap === 'enc' ? (
|
||||
<EncMapSettingsPanel value={state.encMapSettings} onChange={state.setEncMapSettings} />
|
||||
) : (
|
||||
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||
) : null}
|
||||
{baseMap !== 'ocean' && baseMap !== 'osm' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
|
||||
)}
|
||||
{baseMap !== 'ocean' && baseMap !== 'enc' && <DepthLegend depthStops={mapStyleSettings.depthStops} />}
|
||||
<MapLegend />
|
||||
{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} />
|
||||
|
||||
@ -187,12 +187,12 @@ export function DashboardSidebar({
|
||||
Base
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
on={baseMap === 'osm'}
|
||||
onClick={() => setBaseMap('osm')}
|
||||
title="OSM 기본 래스터 지도"
|
||||
on={baseMap === 'enc'}
|
||||
onClick={() => setBaseMap('enc')}
|
||||
title="ENC 전자해도"
|
||||
className="px-2 py-0.5 text-[9px]"
|
||||
>
|
||||
OSM
|
||||
ENC
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
on={baseMap === 'ocean'}
|
||||
|
||||
@ -10,6 +10,8 @@ import { DEFAULT_MAP_STYLE_SETTINGS } from '../../features/mapSettings/types';
|
||||
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||
import { DEFAULT_OCEAN_MAP_SETTINGS } 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';
|
||||
|
||||
export type Bbox = [number, number, number, number];
|
||||
@ -46,14 +48,17 @@ export function useDashboardState(uid: number | null) {
|
||||
const [projection, setProjection] = useState<MapProjectionId>('mercator');
|
||||
const [mapStyleSettings, setMapStyleSettings] = usePersistedState<MapStyleSettings>(uid, 'mapStyleSettings', DEFAULT_MAP_STYLE_SETTINGS);
|
||||
const [overlays, setOverlays] = usePersistedState<MapToggleState>(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<Map3DSettings>(uid, 'map3dSettings', {
|
||||
showShips: true, showDensity: false, showSeamark: false,
|
||||
});
|
||||
const [mapView, setMapView] = usePersistedState<MapViewState | null>(uid, 'mapView', null);
|
||||
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);
|
||||
@ -68,7 +73,7 @@ export function useDashboardState(uid: number | null) {
|
||||
const [fleetRelationSortMode, setFleetRelationSortMode] = usePersistedState<FleetRelationSortMode>(uid, 'sortMode', 'count');
|
||||
const [alarmKindEnabled, setAlarmKindEnabled] = usePersistedState<Record<LegacyAlarmKind, boolean>>(
|
||||
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 ──
|
||||
@ -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,
|
||||
|
||||
@ -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<HTMLDivElement | null>(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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -19,26 +19,32 @@ export function useBaseMapToggle(
|
||||
|
||||
const showSeamarkRef = useRef(showSeamark);
|
||||
const bathyZoomProfileKeyRef = useRef<string>('');
|
||||
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;
|
||||
|
||||
|
||||
@ -83,6 +83,7 @@ export function useDeckLayers(
|
||||
ensureMercatorOverlay: () => MapboxOverlay | null;
|
||||
alarmMmsiMap?: Map<number, LegacyAlarmKind>;
|
||||
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) — 알람 맥동 + 대상 선박 브리딩 합류
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
|
||||
55
apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts
Normal file
55
apps/web/src/widgets/map3d/hooks/useShipLabelColor.ts
Normal file
@ -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',
|
||||
'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',
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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<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' }));
|
||||
}
|
||||
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' }));
|
||||
}
|
||||
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', 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<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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
/* ─ ship photo indicator — disabled (파란 원 아이콘 제거) ─ */
|
||||
|
||||
return layers;
|
||||
}
|
||||
|
||||
59
apps/web/src/widgets/map3d/lib/labelColor.ts
Normal file
59
apps/web/src/widgets/map3d/lib/labelColor.ts
Normal file
@ -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,
|
||||
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})` : '',
|
||||
};
|
||||
}
|
||||
|
||||
@ -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}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -4,6 +4,27 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-25]
|
||||
|
||||
### 추가
|
||||
- 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]
|
||||
|
||||
### 추가
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user