Compare commits

..

10 커밋

작성자 SHA1 메시지 날짜
c03dee0ade fix(map): globe 노란벽 해결 + 심해색 복구
- deck-globe 렌더 파이프라인 비활성화로 노란벽 해소
- 베이스 수색 원복 (#14606e/#114f5c)
- globe 필터 로직 제거 (불필요)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:04:58 +09:00
1fd9f3da82 fix(map): fill 단일화 + globe 배경색 심해색 통일
- fill 3-tier 제거 → 단일 레이어(전체 depth) 복원
- setSky: sky/horizon/fog를 심해색(#010610)으로 설정
- 캔버스/map-area 배경: #010610 (타일 gap seam 비가시화)
- 타일 경계 gap으로 배경이 비칠 때 색상 차이를 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:34:49 +09:00
3a001ca9b6 fix(map): fill 3-tier LOD로 타일 seam 해결
- 심해 fill 폴리곤이 globe 타일 경계에서 seam 아티팩트 발생
- bathymetry-fill: z3-7 (depth >= -2000, 천해만)
- bathymetry-fill-medium: z7-9 (depth >= -4000)
- bathymetry-fill-deep: z9+ (전체 depth)
- applyDepthGradient: 3개 fill 레이어 모두 적용

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:28:23 +09:00
2095503e50 fix(map): 심해 등심선 복원 + 전체 depth LOD
- shallowFilter 제거: 전체 depth 범위 렌더링 복원
- bathyFillColor: -8000m, -4000m 색상 stop 복원
- DEPTHS_MEDIUM: -4000m 추가 (z7-9)
- DEPTHS_DETAIL: -4000m, -8000m 추가 (z9+)
- 줌 기반 LOD가 vertex 제어 담당 (depth 필터 불필요)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:21:46 +09:00
f50c227fd4 fix(map): globe 모드 수역 fill/text 복구
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:15:45 +09:00
b022e4bc36 perf(map): 줌 기반 LOD + 심해 등심선 제거
- applyLandLayerLOD: 베이스맵 육지 레이어에 minzoom 적용
  (landcover z9, transportation z8, building z14 등)
- 수심 3-tier LOD: coarse(z3-7), medium(z7-9), detail(z9+)
- shallowFilter: depth >= -2000, 심해 feature GPU 미전달
- applyDepthGradient ascending order 에러 수정
- vertex 경고 passthrough (디버깅용 유지)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 15:15:31 +09:00
d5700ba587 fix(map): zone 간소화를 projectionBusy 앞으로 이동
소스 데이터 간소화가 projectionBusy 가드 뒤에 있어서
globe 전환 시 원본 데이터(2100+ vertex)로 tessellation 진행 →
73,000+ vertex 폭증. setData를 가드 앞으로 이동하고
useMemo로 간소화 데이터 캐싱.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:59:21 +09:00
7bec1ae86d fix(map): globe 수역 line vertex 초과 해결
zones-line도 globe tessellation에서 73,300+ vertex로 폭증.
globe 모드에서 수역 소스 데이터를 ring당 60점으로 서브샘플링.
원본 2100+ vertex → ~240 vertex → globe tessellation 후 65535 이내.
mercator 모드에서는 원본 데이터 유지.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:44:26 +09:00
99d714582b fix(map): globe 모드 zones-fill 숨김 + 라벨 가드
- globe tessellation에서 수역 fill polygon vertex 65535 초과
  (해안선 2100 vertex → globe에서 108890+로 폭증) → 노란 막대
- globe 모드에서 zones-fill visibility: none으로 설정
- guardedSetVisibility 적용으로 수역 라벨 사라짐 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:38:37 +09:00
95d9ea8aef fix(map): 라벨 사라짐 + easing 경고 + vertex 경고 수정
- guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty
  호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한
  text-allow-overlap:false 라벨 사라짐 현상 해결
- useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거
  (data-driven 표현식은 addLayer 시 1회 설정으로 충분)
- _render 래퍼에서 globe scrollZoom easing 경고 억제
- fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:42 +09:00
12개의 변경된 파일320개의 추가작업 그리고 309개의 파일을 삭제

파일 보기

@ -105,6 +105,7 @@ body {
.map-area {
position: relative;
background: #010610;
}
.sb {

파일 보기

@ -110,7 +110,7 @@ export function useBaseMapToggle(
if (!map) return;
if (showSeamark) {
try {
ensureSeamarkOverlay(map, 'bathymetry-lines');
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
} catch {
// ignore until style is ready

파일 보기

@ -51,6 +51,12 @@ import {
} from '../lib/tooltips';
import { sanitizeDeckLayerList } from '../lib/mapCore';
// NOTE:
// Globe mode now relies on MapLibre native overlays (useGlobeOverlays/useGlobeShips).
// Keep Deck custom-layer rendering disabled in globe to avoid projection-space artifacts.
const ENABLE_GLOBE_DECK_OVERLAYS = false;
export function useDeckLayers(
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
overlayRef: MutableRefObject<MapboxOverlay | null>,
@ -595,6 +601,16 @@ export function useDeckLayers(
const deckTarget = globeDeckLayerRef.current;
if (!deckTarget) return;
if (!ENABLE_GLOBE_DECK_OVERLAYS) {
try {
deckTarget.setProps({ layers: [], getTooltip: undefined, onClick: undefined } as never);
} catch {
// ignore
}
return;
}
const overlayParams = GLOBE_OVERLAY_PARAMS;
const globeLayers: unknown[] = [];

파일 보기

@ -118,7 +118,7 @@ export function useGlobeInteraction(
});
}
if (layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill') {
if (layerId === 'fleet-circles-ml') {
return getFleetCircleTooltipHtml({
ownerKey: String(props.ownerKey ?? ''),
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
@ -186,7 +186,7 @@ export function useGlobeInteraction(
candidateLayerIds = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml',
'fleet-circles-ml', 'fleet-circles-ml-fill',
'fleet-circles-ml',
'pair-range-ml',
'zones-fill', 'zones-line', 'zones-label',
].filter((id) => map.getLayer(id));
@ -213,7 +213,7 @@ export function useGlobeInteraction(
const priority = [
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
'fleet-circles-ml-fill', 'fleet-circles-ml',
'fleet-circles-ml',
'zones-fill', 'zones-line', 'zones-label',
];
@ -232,7 +232,7 @@ export function useGlobeInteraction(
const isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
const isFcLayer = layerId === 'fc-lines-ml';
const isFleetLayer = layerId === 'fleet-circles-ml' || layerId === 'fleet-circles-ml-fill';
const isFleetLayer = layerId === 'fleet-circles-ml';
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
if (isShipLayer) {

파일 보기

@ -11,7 +11,6 @@ import {
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
FLEET_FILL_ML, FLEET_FILL_ML_HL,
FLEET_LINE_ML, FLEET_LINE_ML_HL,
} from '../constants';
import { makeUniqueSorted } from '../lib/setUtils';
@ -28,6 +27,7 @@ import {
} from '../lib/mlExpressions';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { circleRingLngLat } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
import { dashifyLine } from '../lib/dashifyLine';
export function useGlobeOverlays(
@ -60,11 +60,7 @@ export function useGlobeOverlays(
const layerId = 'pair-lines-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
@ -132,11 +128,7 @@ export function useGlobeOverlays(
console.warn('Pair lines layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
@ -159,11 +151,7 @@ export function useGlobeOverlays(
const layerId = 'fc-lines-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
@ -235,11 +223,7 @@ export function useGlobeOverlays(
console.warn('FC lines layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
@ -259,21 +243,13 @@ export function useGlobeOverlays(
if (!map) return;
const srcId = 'fleet-circles-ml-src';
const fillSrcId = 'fleet-circles-ml-fill-src';
const layerId = 'fleet-circles-ml';
const fillLayerId = 'fleet-circles-ml-fill';
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
// 라인만으로 fleet circle 시각화 충분
const remove = () => {
try {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
} catch {
// ignore
}
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
@ -304,29 +280,6 @@ export function useGlobeOverlays(
}),
};
// fill 폴리곤은 globe 테셀레이션으로 vertex 수가 급격히 증가하므로
// 스텝 수를 줄이고 (24), 반경을 500nm으로 상한 설정
const MAX_FILL_RADIUS_M = 500 * 1852;
const fcFill: GeoJSON.FeatureCollection<GeoJSON.Polygon> = {
type: 'FeatureCollection',
features: (fleetCircles || []).map((c) => {
const ring = circleRingLngLat(c.center, Math.min(c.radiusNm * 1852, MAX_FILL_RADIUS_M), 24);
return {
type: 'Feature',
id: `${makeFleetCircleFeatureId(c.ownerKey)}-fill`,
geometry: { type: 'Polygon', coordinates: [ring] },
properties: {
type: 'fleet-fill',
ownerKey: c.ownerKey,
ownerLabel: c.ownerLabel,
count: c.count,
vesselMmsis: c.vesselMmsis,
highlighted: 0,
},
};
}),
};
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(fcLine);
@ -336,41 +289,6 @@ export function useGlobeOverlays(
return;
}
try {
const existingFill = map.getSource(fillSrcId) as GeoJSONSource | undefined;
if (existingFill) existingFill.setData(fcFill);
else map.addSource(fillSrcId, { type: 'geojson', data: fcFill } as GeoJSONSourceSpecification);
} catch (e) {
console.warn('Fleet circles source setup failed:', e);
return;
}
if (!map.getLayer(fillLayerId)) {
try {
map.addLayer(
{
id: fillLayerId,
type: 'fill',
source: fillSrcId,
layout: { visibility: 'visible' },
paint: {
'fill-color': ['case', ['==', ['get', 'highlighted'], 1], FLEET_FILL_ML_HL, FLEET_FILL_ML] as never,
'fill-opacity': ['case', ['==', ['get', 'highlighted'], 1], 0.7, 0.36] as never,
},
} as unknown as LayerSpecification,
undefined,
);
} catch (e) {
console.warn('Fleet circles fill layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
} catch {
// ignore
}
}
if (!map.getLayer(layerId)) {
try {
map.addLayer(
@ -391,11 +309,7 @@ export function useGlobeOverlays(
console.warn('Fleet circles layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'visible');
}
reorderGlobeFeatureLayers();
@ -418,11 +332,7 @@ export function useGlobeOverlays(
const layerId = 'pair-range-ml';
const remove = () => {
try {
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'none');
};
const ensure = () => {
@ -506,11 +416,7 @@ export function useGlobeOverlays(
console.warn('Pair range layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(layerId, 'visibility', 'visible');
} catch {
// ignore
}
guardedSetVisibility(map, layerId, 'visible');
}
kickRepaint(map);
@ -596,10 +502,7 @@ export function useGlobeOverlays(
}
try {
if (map.getLayer('fleet-circles-ml-fill')) {
map.setPaintProperty('fleet-circles-ml-fill', 'fill-color', ['case', fleetHighlightExpr, FLEET_FILL_ML_HL, FLEET_FILL_ML] as never);
map.setPaintProperty('fleet-circles-ml-fill', 'fill-opacity', ['case', fleetHighlightExpr, 0.7, 0.28] as never);
}
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
if (map.getLayer('fleet-circles-ml')) {
map.setPaintProperty('fleet-circles-ml', 'line-color', ['case', fleetHighlightExpr, FLEET_LINE_ML_HL, FLEET_LINE_ML] as never);
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);

파일 보기

@ -25,6 +25,7 @@ import {
ensureFallbackShipImage,
} from '../lib/globeShipIcon';
import { clampNumber } from '../lib/geometry';
import { guardedSetVisibility } from '../lib/layerHelpers';
export function useGlobeShips(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -273,11 +274,10 @@ export function useGlobeShips(
const labelId = 'ships-globe-label';
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
const hide = () => {
for (const id of [labelId, symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
} catch { /* ignore */ }
guardedSetVisibility(map, id, 'none');
}
};
@ -296,15 +296,19 @@ export function useGlobeShips(
}
// 빠른 visibility 토글 — projectionBusy 중에도 실행
// 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선
const visibility = projection === 'globe' ? 'visible' : 'none';
const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
if (map.getLayer(symbolId)) {
for (const id of [haloId, outlineId, symbolId]) {
try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ }
const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility;
if (changed) {
for (const id of [haloId, outlineId, symbolId]) {
guardedSetVisibility(map, id, visibility);
}
if (projection === 'globe') kickRepaint(map);
}
try { map.setLayoutProperty(labelId, 'visibility', labelVisibility); } catch { /* ignore */ }
if (projection === 'globe') kickRepaint(map);
guardedSetVisibility(map, labelId, labelVisibility);
}
// 데이터 업데이트는 projectionBusy 중에는 차단
@ -374,35 +378,8 @@ export function useGlobeShips(
} catch (e) {
console.warn('Ship halo layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(haloId, 'visibility', visibility);
map.setLayoutProperty(haloId, 'circle-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 120,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 115,
['==', ['get', 'permitted'], 1], 110,
['==', ['get', 'selected'], 1], 60,
['==', ['get', 'highlighted'], 1], 55,
20,
] as never);
map.setPaintProperty(haloId, 'circle-color', [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,1)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,1)',
['coalesce', ['get', 'shipColor'], '#64748b'],
] as never);
map.setPaintProperty(haloId, 'circle-opacity', [
'case',
['==', ['get', 'selected'], 1], 0.38,
['==', ['get', 'highlighted'], 1], 0.34,
0.16,
] as never);
map.setPaintProperty(haloId, 'circle-radius', GLOBE_SHIP_CIRCLE_RADIUS_EXPR);
} catch {
// ignore
}
}
// halo: data-driven expressions are static — visibility handled by fast toggle above
if (!map.getLayer(outlineId)) {
try {
@ -448,36 +425,8 @@ export function useGlobeShips(
} catch (e) {
console.warn('Ship outline layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(outlineId, 'visibility', visibility);
map.setLayoutProperty(outlineId, 'circle-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 130,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 125,
['==', ['get', 'permitted'], 1], 120,
['==', ['get', 'selected'], 1], 70,
['==', ['get', 'highlighted'], 1], 65,
30,
] as never);
map.setPaintProperty(outlineId, 'circle-stroke-color', [
'case',
['==', ['get', 'selected'], 1], 'rgba(14,234,255,0.95)',
['==', ['get', 'highlighted'], 1], 'rgba(245,158,11,0.95)',
['==', ['get', 'permitted'], 1], GLOBE_OUTLINE_PERMITTED,
GLOBE_OUTLINE_OTHER,
] as never);
map.setPaintProperty(outlineId, 'circle-stroke-width', [
'case',
['==', ['get', 'selected'], 1], 3.4,
['==', ['get', 'highlighted'], 1], 2.7,
['==', ['get', 'permitted'], 1], 1.8,
0.7,
] as never);
} catch {
// ignore
}
}
// outline: data-driven expressions are static — visibility handled by fast toggle
if (!map.getLayer(symbolId)) {
try {
@ -538,29 +487,8 @@ export function useGlobeShips(
} catch (e) {
console.warn('Ship symbol layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(symbolId, 'visibility', visibility);
map.setLayoutProperty(symbolId, 'symbol-sort-key', [
'case',
['all', ['==', ['get', 'selected'], 1], ['==', ['get', 'permitted'], 1]], 140,
['all', ['==', ['get', 'highlighted'], 1], ['==', ['get', 'permitted'], 1]], 135,
['==', ['get', 'permitted'], 1], 130,
['==', ['get', 'selected'], 1], 80,
['==', ['get', 'highlighted'], 1], 75,
45,
] as never);
map.setPaintProperty(symbolId, 'icon-opacity', [
'case',
['==', ['get', 'permitted'], 1], 1,
['==', ['get', 'selected'], 1], 0.86,
['==', ['get', 'highlighted'], 1], 0.82,
0.66,
] as never);
} catch {
// ignore
}
}
// symbol: data-driven expressions are static — visibility handled by fast toggle
const labelFilter = [
'all',
@ -611,15 +539,8 @@ export function useGlobeShips(
} catch (e) {
console.warn('Ship label layer add failed:', e);
}
} else {
try {
map.setLayoutProperty(labelId, 'visibility', labelVisibility);
map.setFilter(labelId, labelFilter as never);
map.setLayoutProperty(labelId, 'text-field', ['get', 'labelName'] as never);
} catch {
// ignore
}
}
// label: filter/text-field are static — visibility handled by fast toggle
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
onGlobeShipsReady?.(true);
@ -658,9 +579,7 @@ export function useGlobeShips(
const hideHover = () => {
for (const id of [symbolId, outlineId, haloId]) {
try {
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
} catch { /* ignore */ }
guardedSetVisibility(map, id, 'none');
}
};

파일 보기

@ -93,11 +93,27 @@ export function useMapInit(
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
// MapLibre 내부 placement TypeError 방어
// MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
// globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제
{
const origRender = (map as unknown as { _render: (arg?: number) => void })._render;
const origWarn = console.warn;
(map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) {
// globe 모드에서 scrollZoom의 easeTo around 경고 억제
// eslint-disable-next-line no-console
console.warn = function (...args: unknown[]) {
if (typeof args[0] === 'string') {
const msg = args[0] as string;
if (msg.includes('Easing around a point')) return;
// vertex 경고는 디버그용으로 1회만 출력 후 억제
if (msg.includes('Max vertices per segment')) {
origWarn.apply(console, args as [unknown, ...unknown[]]);
return;
}
}
origWarn.apply(console, args as [unknown, ...unknown[]]);
};
try {
origRender.call(this, arg);
} catch (e) {
@ -105,6 +121,9 @@ export function useMapInit(
return;
}
throw e;
} finally {
// eslint-disable-next-line no-console
console.warn = origWarn;
}
};
}
@ -166,7 +185,7 @@ export function useMapInit(
}
if (!showSeamarkRef.current) return;
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch {
// ignore
}
@ -190,9 +209,29 @@ export function useMapInit(
}, 60_000);
map.once('load', () => {
// Globe 배경(타일 밖)을 심해 색상과 맞춰 타일 경계 seam을 비가시화
try {
map!.setSky({
'sky-color': '#010610',
'horizon-color': '#010610',
'fog-color': '#010610',
'fog-ground-blend': 1,
'sky-horizon-blend': 0,
'atmosphere-blend': 0,
});
} catch {
// ignore
}
// 캔버스 배경도 심해색으로 통일
try {
map!.getCanvas().style.background = '#010610';
} catch {
// ignore
}
if (showSeamarkRef.current) {
try {
ensureSeamarkOverlay(map!, 'bathymetry-lines');
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
} catch {
// ignore
}

파일 보기

@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
if (layer.type !== 'symbol') continue;
const layout = (layer as { layout?: Record<string, unknown> }).layout;
if (!layout?.['text-field']) continue;
if (layer.id === 'bathymetry-labels') continue;
if (layer.id.startsWith('bathymetry-labels')) continue;
const textField =
lang === 'local'
? ['get', 'name']
@ -102,17 +102,19 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
}
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
if (!map.getLayer('bathymetry-fill')) return;
const depth = ['to-number', ['get', 'depth']];
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
if (sorted.length === 0) return;
const expr: unknown[] = ['interpolate', ['linear'], depth];
const deepest = sorted[0];
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
for (const s of sorted) {
expr.push(s.depth, s.color);
}
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
const shallowest = sorted[sorted.length - 1];
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
if (shallowest.depth < 0) {
expr.push(0, lightenHex(shallowest.color, 1.8));
}
if (!map.getLayer('bathymetry-fill')) return;
try {
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
} catch {
@ -122,7 +124,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
const expr = DEPTH_FONT_SIZE_MAP[size];
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setLayoutProperty(layerId, 'text-size', expr);
@ -133,7 +135,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
}
function applyDepthFontColor(map: maplibregl.Map, color: string) {
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
for (const layerId of ['bathymetry-labels', 'bathymetry-labels-coarse', 'bathymetry-landforms']) {
if (!map.getLayer(layerId)) continue;
try {
map.setPaintProperty(layerId, 'text-color', color);

파일 보기

@ -111,7 +111,6 @@ export function useProjectionToggle(
'pair-lines-ml',
'fc-lines-ml',
'pair-range-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
];

파일 보기

@ -1,4 +1,4 @@
import { useEffect, type MutableRefObject } from 'react';
import { useEffect, useMemo, type MutableRefObject } from 'react';
import maplibregl, {
type GeoJSONSource,
type GeoJSONSourceSpecification,
@ -10,6 +10,34 @@ import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
import type { BaseMapId, MapProjectionId } from '../types';
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
import { guardedSetVisibility } from '../lib/layerHelpers';
/** Globe tessellation vertex 65535 .
* ( 2100+ vertex) globe에서 70,000+
* ring당 maxPts개로 . centroid에는 . */
function simplifyZonesForGlobe(zones: ZonesGeoJson): ZonesGeoJson {
const MAX_PTS = 60;
const subsample = (ring: GeoJSON.Position[]): GeoJSON.Position[] => {
if (ring.length <= MAX_PTS) return ring;
const step = Math.ceil(ring.length / MAX_PTS);
const out: GeoJSON.Position[] = [ring[0]];
for (let i = step; i < ring.length - 1; i += step) out.push(ring[i]);
out.push(ring[0]); // close ring
return out;
};
return {
...zones,
features: zones.features.map((f) => ({
...f,
geometry: {
...f.geometry,
coordinates: f.geometry.coordinates.map((polygon) =>
polygon.map((ring) => subsample(ring)),
),
},
})),
};
}
export function useZonesLayer(
mapRef: MutableRefObject<maplibregl.Map | null>,
@ -26,6 +54,12 @@ export function useZonesLayer(
) {
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
const simplifiedZones = useMemo(
() => (zones ? simplifyZonesForGlobe(zones) : null),
[zones],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
@ -47,33 +81,31 @@ export function useZonesLayer(
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
const ensure = () => {
if (projectionBusyRef.current) return;
const visibility = overlays.zones ? 'visible' : 'none';
try {
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
} catch {
// ignore
}
try {
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
} catch {
// ignore
}
try {
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
} catch {
// ignore
// 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
// globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
const sourceData = projection === 'globe' ? simplifiedZones : zones;
if (sourceData) {
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) existing.setData(sourceData);
} catch { /* ignore — source may not exist yet */ }
}
const visibility: 'visible' | 'none' = overlays.zones ? 'visible' : 'none';
guardedSetVisibility(map, fillId, visibility);
guardedSetVisibility(map, lineId, visibility);
guardedSetVisibility(map, labelId, visibility);
if (projectionBusyRef.current) return;
if (!zones) return;
if (!map.isStyleLoaded()) return;
try {
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
if (existing) {
existing.setData(zones);
} else {
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
// 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
if (!map.getSource(srcId)) {
const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
}
const style = map.getStyle();
@ -226,5 +258,5 @@ export function useZonesLayer(
return () => {
stop();
};
}, [zones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
}

파일 보기

@ -11,10 +11,53 @@ export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
{ id: 'bathymetry-borders', mercator: [7, 24], globe: [7, 24] },
{ id: 'bathymetry-lines-coarse', mercator: [5, 7], globe: [5, 7] },
{ id: 'bathymetry-lines-major', mercator: [7, 9], globe: [7, 9] },
{ id: 'bathymetry-lines-detail', mercator: [9, 24], globe: [9, 24] },
{ id: 'bathymetry-labels-coarse', mercator: [6, 9], globe: [6, 9] },
{ id: 'bathymetry-labels', mercator: [9, 24], globe: [9, 24] },
];
/**
* LOD vertex가 minzoom을
* , .
* .
*/
function applyLandLayerLOD(style: StyleSpecification): void {
if (!style.layers || !Array.isArray(style.layers)) return;
// source-layer → 렌더링을 시작할 최소 줌 레벨
// globe 모드 줌아웃 시 vertex 65535 초과로 GPU 렌더링 아티팩트(노란 막대) 방지
const LOD_MINZOOM: Record<string, number> = {
'landcover': 9,
'globallandcover': 9,
'landuse': 11,
'boundary': 5,
'transportation': 8,
'transportation_name': 10,
'building': 14,
'housenumber': 16,
'aeroway': 11,
'park': 10,
'mountain_peak': 11,
};
for (const layer of style.layers as unknown as LayerSpecification[]) {
const spec = layer as Record<string, unknown>;
const sourceLayer = spec['source-layer'] as string | undefined;
if (!sourceLayer) continue;
const lodMin = LOD_MINZOOM[sourceLayer];
if (lodMin === undefined) continue;
// 기존 minzoom보다 높을 때만 덮어씀
const current = (spec.minzoom as number) ?? 0;
if (lodMin > current) {
spec.minzoom = lodMin;
}
}
}
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
const oceanSourceId = 'maptiler-ocean';
@ -31,17 +74,13 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const depth = ['to-number', ['get', 'depth']] as unknown as number[];
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
// Bug #3 fix: shallow depths now use brighter teal tones to distinguish from deep ocean
// 수심 색상: 전체 범위 (-8000m ~ 0m)
const bathyFillColor = [
'interpolate',
['linear'],
depth,
-11000,
'#00040b',
-8000,
'#010610',
-6000,
'#020816',
-4000,
'#030c1c',
-2000,
@ -64,6 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
'#2097a6',
] as const;
// --- Depth tiers for zoom-based LOD ---
// 줌 기반 LOD로 vertex 제어 — 줌아웃에선 주요 등심선만, 줌인에서 점진적 디테일
const DEPTHS_COARSE = [-1000, -2000];
const DEPTHS_MEDIUM = [-100, -500, -1000, -2000, -4000];
const DEPTHS_DETAIL = [-50, -100, -200, -500, -1000, -2000, -4000, -8000];
const depthIn = (depths: number[]) =>
['in', depth, ['literal', depths]] as unknown[];
// === Fill (contour polygons) — 단일 레이어, 전체 depth ===
const bathyFill: LayerSpecification = {
id: 'bathymetry-fill',
type: 'fill',
@ -77,98 +125,132 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
},
} as unknown as LayerSpecification;
const bathyBandBorders: LayerSpecification = {
// === Borders (contour polygon edges) — 2-tier LOD ===
// z3-z7: 1000m, 2000m 경계만
const bathyBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4],
},
} as unknown as LayerSpecification;
// z7+: 전체 등심선 경계
const bathyBorders: LayerSpecification = {
id: 'bathymetry-borders',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 5, // fill은 3부터, borders는 5부터
minzoom: 7,
maxzoom: 24,
paint: {
'line-color': 'rgba(255,255,255,0.06)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
},
} as unknown as LayerSpecification;
const bathyLinesMinor: LayerSpecification = {
id: 'bathymetry-lines',
// === Contour lines (contour_line) — 3-tier LOD ===
// z5-z7: 1000m, 2000m만
const bathyLinesCoarse: LayerSpecification = {
id: 'bathymetry-lines-coarse',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
minzoom: 5,
maxzoom: 7,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
paint: {
'line-color': [
'interpolate',
['linear'],
depth,
-11000,
'rgba(255,255,255,0.04)',
-6000,
'rgba(255,255,255,0.05)',
-2000,
'rgba(255,255,255,0.07)',
0,
'rgba(255,255,255,0.10)',
],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.18, 10, 0.22, 12, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.8, 11, 0.3],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.35, 10, 0.55, 12, 0.85],
'line-color': 'rgba(255,255,255,0.12)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
'line-blur': 0.5,
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
},
} as unknown as LayerSpecification;
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
const bathyMajorDepthFilter: unknown[] = [
'in',
['to-number', ['get', 'depth']],
['literal', majorDepths],
] as unknown[];
// z7-z9: 100, 500, 1000, 2000, 4000m
const bathyLinesMajor: LayerSpecification = {
id: 'bathymetry-lines-major',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[],
maxzoom: 9,
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
},
} as unknown as LayerSpecification;
const bathyBandBordersMajor: LayerSpecification = {
id: 'bathymetry-borders-major',
// z9+: 50~8000m (풀 디테일)
const bathyLinesDetail: LayerSpecification = {
id: 'bathymetry-lines-detail',
type: 'line',
source: oceanSourceId,
'source-layer': 'contour',
minzoom: 3,
'source-layer': 'contour_line',
minzoom: 9,
maxzoom: 24,
filter: bathyMajorDepthFilter as unknown as unknown[],
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
paint: {
'line-color': 'rgba(255,255,255,0.14)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
'line-color': 'rgba(255,255,255,0.16)',
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
},
} as unknown as LayerSpecification;
// === Labels — 2-tier LOD ===
// z6-z9: 1000m, 2000m 라벨만
const bathyLabelsCoarse: LayerSpecification = {
id: 'bathymetry-labels-coarse',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 6,
maxzoom: 9,
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 9, 12],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
},
paint: {
'text-color': 'rgba(226,232,240,0.78)',
'text-halo-color': 'rgba(2,6,23,0.88)',
'text-halo-width': 1.2,
'text-halo-blur': 0.5,
},
} as unknown as LayerSpecification;
// z9+: 100~4000m 라벨
const bathyLabels: LayerSpecification = {
id: 'bathymetry-labels',
type: 'symbol',
source: oceanSourceId,
'source-layer': 'contour_line',
minzoom: 7,
filter: bathyMajorDepthFilter as unknown as unknown[],
minzoom: 9,
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
layout: {
'symbol-placement': 'line',
'text-field': depthLabel,
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
'text-size': ['interpolate', ['linear'], ['zoom'], 9, 12, 11, 14, 13, 16],
'text-allow-overlap': false,
'text-padding': 4,
'text-rotation-alignment': 'map',
@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
const toInsert = [
bathyFill,
bathyBandBorders,
bathyBandBordersMajor,
bathyLinesMinor,
bathyBordersMajor,
bathyBorders,
bathyLinesCoarse,
bathyLinesMajor,
bathyLinesDetail,
bathyLabelsCoarse,
bathyLabels,
landformLabels,
].filter((l) => !existingIds.has(l.id));
@ -273,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
// ignore
}
}
}
function applyKoreanLabels(style: StyleSpecification) {
@ -298,6 +383,7 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
const json = (await res.json()) as StyleSpecification;
applyLandLayerLOD(json);
applyKoreanLabels(json);
injectOceanBathymetryLayers(json, key);
return json;

파일 보기

@ -28,7 +28,6 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
const GLOBE_NATIVE_LAYER_IDS = [
'pair-lines-ml',
'fc-lines-ml',
'fleet-circles-ml-fill',
'fleet-circles-ml',
'pair-range-ml',
'subcables-hitarea',
@ -44,7 +43,6 @@ const GLOBE_NATIVE_SOURCE_IDS = [
'pair-lines-ml-src',
'fc-lines-ml-src',
'fleet-circles-ml-src',
'fleet-circles-ml-fill-src',
'pair-range-ml-src',
'subcables-src',
'subcables-pts-src',
@ -96,6 +94,22 @@ export function setLayerVisibility(map: maplibregl.Map, layerId: string, visible
}
}
/**
* setLayoutProperty('visibility') wrapper .
* MapLibre는 setLayoutProperty style._changed = true를
* symbol layer의 placement를 . text-allow-overlap:false
* , .
*/
export function guardedSetVisibility(map: maplibregl.Map, layerId: string, target: 'visible' | 'none') {
if (!map.getLayer(layerId)) return;
try {
if (map.getLayoutProperty(layerId, 'visibility') === target) return;
map.setLayoutProperty(layerId, 'visibility', target);
} catch {
// ignore
}
}
export function cleanupLayers(
map: maplibregl.Map,
layerIds: string[],