Compare commits
10 커밋
91df90b528
...
c03dee0ade
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| c03dee0ade | |||
| 1fd9f3da82 | |||
| 3a001ca9b6 | |||
| 2095503e50 | |||
| f50c227fd4 | |||
| b022e4bc36 | |||
| d5700ba587 | |||
| 7bec1ae86d | |||
| 99d714582b | |||
| 95d9ea8aef |
@ -105,6 +105,7 @@ body {
|
|||||||
|
|
||||||
.map-area {
|
.map-area {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
background: #010610;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sb {
|
.sb {
|
||||||
|
|||||||
@ -110,7 +110,7 @@ export function useBaseMapToggle(
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
if (showSeamark) {
|
if (showSeamark) {
|
||||||
try {
|
try {
|
||||||
ensureSeamarkOverlay(map, 'bathymetry-lines');
|
ensureSeamarkOverlay(map, 'bathymetry-lines-coarse');
|
||||||
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
map.setPaintProperty('seamark', 'raster-opacity', 0.85);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore until style is ready
|
// ignore until style is ready
|
||||||
|
|||||||
@ -51,6 +51,12 @@ import {
|
|||||||
} from '../lib/tooltips';
|
} from '../lib/tooltips';
|
||||||
import { sanitizeDeckLayerList } from '../lib/mapCore';
|
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(
|
export function useDeckLayers(
|
||||||
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
mapRef: MutableRefObject<import('maplibre-gl').Map | null>,
|
||||||
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
||||||
@ -595,6 +601,16 @@ export function useDeckLayers(
|
|||||||
const deckTarget = globeDeckLayerRef.current;
|
const deckTarget = globeDeckLayerRef.current;
|
||||||
if (!deckTarget) return;
|
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 overlayParams = GLOBE_OVERLAY_PARAMS;
|
||||||
const globeLayers: unknown[] = [];
|
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({
|
return getFleetCircleTooltipHtml({
|
||||||
ownerKey: String(props.ownerKey ?? ''),
|
ownerKey: String(props.ownerKey ?? ''),
|
||||||
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
ownerLabel: String(props.ownerLabel ?? props.ownerKey ?? ''),
|
||||||
@ -186,7 +186,7 @@ export function useGlobeInteraction(
|
|||||||
candidateLayerIds = [
|
candidateLayerIds = [
|
||||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
'pair-lines-ml', 'fc-lines-ml',
|
'pair-lines-ml', 'fc-lines-ml',
|
||||||
'fleet-circles-ml', 'fleet-circles-ml-fill',
|
'fleet-circles-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
'zones-fill', 'zones-line', 'zones-label',
|
'zones-fill', 'zones-line', 'zones-label',
|
||||||
].filter((id) => map.getLayer(id));
|
].filter((id) => map.getLayer(id));
|
||||||
@ -213,7 +213,7 @@ export function useGlobeInteraction(
|
|||||||
const priority = [
|
const priority = [
|
||||||
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
'ships-globe', 'ships-globe-halo', 'ships-globe-outline',
|
||||||
'pair-lines-ml', 'fc-lines-ml', 'pair-range-ml',
|
'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',
|
'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 isShipLayer = layerId === 'ships-globe' || layerId === 'ships-globe-halo' || layerId === 'ships-globe-outline';
|
||||||
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
const isPairLayer = layerId === 'pair-lines-ml' || layerId === 'pair-range-ml';
|
||||||
const isFcLayer = layerId === 'fc-lines-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';
|
const isZoneLayer = layerId === 'zones-fill' || layerId === 'zones-line' || layerId === 'zones-label';
|
||||||
|
|
||||||
if (isShipLayer) {
|
if (isShipLayer) {
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import {
|
|||||||
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
PAIR_RANGE_NORMAL_ML_HL, PAIR_RANGE_WARN_ML_HL,
|
||||||
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
FC_LINE_NORMAL_ML, FC_LINE_SUSPICIOUS_ML,
|
||||||
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
FC_LINE_NORMAL_ML_HL, FC_LINE_SUSPICIOUS_ML_HL,
|
||||||
FLEET_FILL_ML, FLEET_FILL_ML_HL,
|
|
||||||
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
FLEET_LINE_ML, FLEET_LINE_ML_HL,
|
||||||
} from '../constants';
|
} from '../constants';
|
||||||
import { makeUniqueSorted } from '../lib/setUtils';
|
import { makeUniqueSorted } from '../lib/setUtils';
|
||||||
@ -28,6 +27,7 @@ import {
|
|||||||
} from '../lib/mlExpressions';
|
} from '../lib/mlExpressions';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
import { circleRingLngLat } from '../lib/geometry';
|
import { circleRingLngLat } from '../lib/geometry';
|
||||||
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
import { dashifyLine } from '../lib/dashifyLine';
|
import { dashifyLine } from '../lib/dashifyLine';
|
||||||
|
|
||||||
export function useGlobeOverlays(
|
export function useGlobeOverlays(
|
||||||
@ -60,11 +60,7 @@ export function useGlobeOverlays(
|
|||||||
const layerId = 'pair-lines-ml';
|
const layerId = 'pair-lines-ml';
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
@ -132,11 +128,7 @@ export function useGlobeOverlays(
|
|||||||
console.warn('Pair lines layer add failed:', e);
|
console.warn('Pair lines layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
@ -159,11 +151,7 @@ export function useGlobeOverlays(
|
|||||||
const layerId = 'fc-lines-ml';
|
const layerId = 'fc-lines-ml';
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
@ -235,11 +223,7 @@ export function useGlobeOverlays(
|
|||||||
console.warn('FC lines layer add failed:', e);
|
console.warn('FC lines layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
@ -259,21 +243,13 @@ export function useGlobeOverlays(
|
|||||||
if (!map) return;
|
if (!map) return;
|
||||||
|
|
||||||
const srcId = 'fleet-circles-ml-src';
|
const srcId = 'fleet-circles-ml-src';
|
||||||
const fillSrcId = 'fleet-circles-ml-fill-src';
|
|
||||||
const layerId = 'fleet-circles-ml';
|
const layerId = 'fleet-circles-ml';
|
||||||
const fillLayerId = 'fleet-circles-ml-fill';
|
|
||||||
|
// fill layer 제거됨 — globe tessellation에서 vertex 65535 초과 경고 원인
|
||||||
|
// 라인만으로 fleet circle 시각화 충분
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
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 {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
if (existing) existing.setData(fcLine);
|
if (existing) existing.setData(fcLine);
|
||||||
@ -336,41 +289,6 @@ export function useGlobeOverlays(
|
|||||||
return;
|
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)) {
|
if (!map.getLayer(layerId)) {
|
||||||
try {
|
try {
|
||||||
map.addLayer(
|
map.addLayer(
|
||||||
@ -391,11 +309,7 @@ export function useGlobeOverlays(
|
|||||||
console.warn('Fleet circles layer add failed:', e);
|
console.warn('Fleet circles layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reorderGlobeFeatureLayers();
|
reorderGlobeFeatureLayers();
|
||||||
@ -418,11 +332,7 @@ export function useGlobeOverlays(
|
|||||||
const layerId = 'pair-range-ml';
|
const layerId = 'pair-range-ml';
|
||||||
|
|
||||||
const remove = () => {
|
const remove = () => {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'none');
|
||||||
if (map.getLayer(layerId)) map.setLayoutProperty(layerId, 'visibility', 'none');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
@ -506,11 +416,7 @@ export function useGlobeOverlays(
|
|||||||
console.warn('Pair range layer add failed:', e);
|
console.warn('Pair range layer add failed:', e);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
guardedSetVisibility(map, layerId, 'visible');
|
||||||
map.setLayoutProperty(layerId, 'visibility', 'visible');
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
kickRepaint(map);
|
kickRepaint(map);
|
||||||
@ -596,10 +502,7 @@ export function useGlobeOverlays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (map.getLayer('fleet-circles-ml-fill')) {
|
// fleet-circles-ml-fill 제거됨 (vertex 65535 경고 원인)
|
||||||
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);
|
|
||||||
}
|
|
||||||
if (map.getLayer('fleet-circles-ml')) {
|
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-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);
|
map.setPaintProperty('fleet-circles-ml', 'line-width', ['case', fleetHighlightExpr, 2, 1.1] as never);
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
ensureFallbackShipImage,
|
ensureFallbackShipImage,
|
||||||
} from '../lib/globeShipIcon';
|
} from '../lib/globeShipIcon';
|
||||||
import { clampNumber } from '../lib/geometry';
|
import { clampNumber } from '../lib/geometry';
|
||||||
|
import { guardedSetVisibility } from '../lib/layerHelpers';
|
||||||
|
|
||||||
export function useGlobeShips(
|
export function useGlobeShips(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -273,11 +274,10 @@ export function useGlobeShips(
|
|||||||
const labelId = 'ships-globe-label';
|
const labelId = 'ships-globe-label';
|
||||||
|
|
||||||
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
// 레이어를 제거하지 않고 visibility만 'none'으로 설정
|
||||||
|
// guardedSetVisibility로 현재 값과 동일하면 호출 생략 (style._changed 방지)
|
||||||
const hide = () => {
|
const hide = () => {
|
||||||
for (const id of [labelId, symbolId, outlineId, haloId]) {
|
for (const id of [labelId, symbolId, outlineId, haloId]) {
|
||||||
try {
|
guardedSetVisibility(map, id, 'none');
|
||||||
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -296,15 +296,19 @@ export function useGlobeShips(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
// 빠른 visibility 토글 — projectionBusy 중에도 실행
|
||||||
// 이미 생성된 레이어의 표시 상태만 즉시 전환하여 projection 전환 체감 속도 개선
|
// guardedSetVisibility로 값이 실제로 바뀔 때만 setLayoutProperty 호출
|
||||||
const visibility = projection === 'globe' ? 'visible' : 'none';
|
// → style._changed 방지 → 불필요한 symbol placement 재계산 방지 → 라벨 사라짐 방지
|
||||||
const labelVisibility = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
const visibility: 'visible' | 'none' = projection === 'globe' ? 'visible' : 'none';
|
||||||
|
const labelVisibility: 'visible' | 'none' = projection === 'globe' && overlays.shipLabels ? 'visible' : 'none';
|
||||||
if (map.getLayer(symbolId)) {
|
if (map.getLayer(symbolId)) {
|
||||||
for (const id of [haloId, outlineId, symbolId]) {
|
const changed = map.getLayoutProperty(symbolId, 'visibility') !== visibility;
|
||||||
try { map.setLayoutProperty(id, 'visibility', visibility); } catch { /* ignore */ }
|
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 */ }
|
guardedSetVisibility(map, labelId, labelVisibility);
|
||||||
if (projection === 'globe') kickRepaint(map);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 데이터 업데이트는 projectionBusy 중에는 차단
|
// 데이터 업데이트는 projectionBusy 중에는 차단
|
||||||
@ -374,35 +378,8 @@ export function useGlobeShips(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ship halo layer add failed:', 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)) {
|
if (!map.getLayer(outlineId)) {
|
||||||
try {
|
try {
|
||||||
@ -448,36 +425,8 @@ export function useGlobeShips(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ship outline layer add failed:', 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)) {
|
if (!map.getLayer(symbolId)) {
|
||||||
try {
|
try {
|
||||||
@ -538,29 +487,8 @@ export function useGlobeShips(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ship symbol layer add failed:', 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 = [
|
const labelFilter = [
|
||||||
'all',
|
'all',
|
||||||
@ -611,15 +539,8 @@ export function useGlobeShips(
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Ship label layer add failed:', 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과 무관 — 토글 버튼 활성화용)
|
// 레이어가 준비되었음을 알림 (projection과 무관 — 토글 버튼 활성화용)
|
||||||
onGlobeShipsReady?.(true);
|
onGlobeShipsReady?.(true);
|
||||||
@ -658,9 +579,7 @@ export function useGlobeShips(
|
|||||||
|
|
||||||
const hideHover = () => {
|
const hideHover = () => {
|
||||||
for (const id of [symbolId, outlineId, haloId]) {
|
for (const id of [symbolId, outlineId, haloId]) {
|
||||||
try {
|
guardedSetVisibility(map, id, 'none');
|
||||||
if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', 'none');
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -93,11 +93,27 @@ export function useMapInit(
|
|||||||
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left');
|
||||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||||
|
|
||||||
// MapLibre 내부 placement TypeError 방어
|
// MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제
|
||||||
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
|
// symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제
|
||||||
|
// globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제
|
||||||
{
|
{
|
||||||
const origRender = (map as unknown as { _render: (arg?: number) => void })._render;
|
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) {
|
(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 {
|
try {
|
||||||
origRender.call(this, arg);
|
origRender.call(this, arg);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -105,6 +121,9 @@ export function useMapInit(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
|
} finally {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn = origWarn;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -166,7 +185,7 @@ export function useMapInit(
|
|||||||
}
|
}
|
||||||
if (!showSeamarkRef.current) return;
|
if (!showSeamarkRef.current) return;
|
||||||
try {
|
try {
|
||||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@ -190,9 +209,29 @@ export function useMapInit(
|
|||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
map.once('load', () => {
|
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) {
|
if (showSeamarkRef.current) {
|
||||||
try {
|
try {
|
||||||
ensureSeamarkOverlay(map!, 'bathymetry-lines');
|
ensureSeamarkOverlay(map!, 'bathymetry-lines-coarse');
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,7 +35,7 @@ function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
|||||||
if (layer.type !== 'symbol') continue;
|
if (layer.type !== 'symbol') continue;
|
||||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||||
if (!layout?.['text-field']) continue;
|
if (!layout?.['text-field']) continue;
|
||||||
if (layer.id === 'bathymetry-labels') continue;
|
if (layer.id.startsWith('bathymetry-labels')) continue;
|
||||||
const textField =
|
const textField =
|
||||||
lang === 'local'
|
lang === 'local'
|
||||||
? ['get', 'name']
|
? ['get', 'name']
|
||||||
@ -102,17 +102,19 @@ function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||||
if (!map.getLayer('bathymetry-fill')) return;
|
|
||||||
const depth = ['to-number', ['get', 'depth']];
|
const depth = ['to-number', ['get', 'depth']];
|
||||||
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||||
|
if (sorted.length === 0) return;
|
||||||
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
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) {
|
for (const s of sorted) {
|
||||||
expr.push(s.depth, s.color);
|
expr.push(s.depth, s.color);
|
||||||
}
|
}
|
||||||
|
// 0m까지 확장 (최천층 stop이 0보다 깊으면)
|
||||||
const shallowest = sorted[sorted.length - 1];
|
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 {
|
try {
|
||||||
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||||
} catch {
|
} catch {
|
||||||
@ -122,7 +124,7 @@ function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
|||||||
|
|
||||||
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||||
const expr = DEPTH_FONT_SIZE_MAP[size];
|
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;
|
if (!map.getLayer(layerId)) continue;
|
||||||
try {
|
try {
|
||||||
map.setLayoutProperty(layerId, 'text-size', expr);
|
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||||
@ -133,7 +135,7 @@ function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
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;
|
if (!map.getLayer(layerId)) continue;
|
||||||
try {
|
try {
|
||||||
map.setPaintProperty(layerId, 'text-color', color);
|
map.setPaintProperty(layerId, 'text-color', color);
|
||||||
|
|||||||
@ -111,7 +111,6 @@ export function useProjectionToggle(
|
|||||||
'pair-lines-ml',
|
'pair-lines-ml',
|
||||||
'fc-lines-ml',
|
'fc-lines-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
'fleet-circles-ml-fill',
|
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, type MutableRefObject } from 'react';
|
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
||||||
import maplibregl, {
|
import maplibregl, {
|
||||||
type GeoJSONSource,
|
type GeoJSONSource,
|
||||||
type GeoJSONSourceSpecification,
|
type GeoJSONSourceSpecification,
|
||||||
@ -10,6 +10,34 @@ import type { ZonesGeoJson } from '../../../entities/zone/api/useZones';
|
|||||||
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../../features/mapToggles/MapToggles';
|
||||||
import type { BaseMapId, MapProjectionId } from '../types';
|
import type { BaseMapId, MapProjectionId } from '../types';
|
||||||
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
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(
|
export function useZonesLayer(
|
||||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
@ -26,6 +54,12 @@ export function useZonesLayer(
|
|||||||
) {
|
) {
|
||||||
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
const { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
// globe용 간소화 데이터를 미리 캐싱 — ensure() 내 매번 재계산 방지
|
||||||
|
const simplifiedZones = useMemo(
|
||||||
|
() => (zones ? simplifyZonesForGlobe(zones) : null),
|
||||||
|
[zones],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@ -47,33 +81,31 @@ export function useZonesLayer(
|
|||||||
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
||||||
|
|
||||||
const ensure = () => {
|
const ensure = () => {
|
||||||
if (projectionBusyRef.current) return;
|
// 소스 데이터 간소화 — projectionBusy 중에도 실행해야 함
|
||||||
const visibility = overlays.zones ? 'visible' : 'none';
|
// globe 전환 시 projectionBusy 가드 뒤에서만 실행하면 MapLibre가
|
||||||
try {
|
// 원본(2100+ vertex) 데이터로 globe tessellation → 73,000+ vertex → 노란 막대
|
||||||
if (map.getLayer(fillId)) map.setLayoutProperty(fillId, 'visibility', visibility);
|
const sourceData = projection === 'globe' ? simplifiedZones : zones;
|
||||||
} catch {
|
if (sourceData) {
|
||||||
// ignore
|
try {
|
||||||
}
|
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
||||||
try {
|
if (existing) existing.setData(sourceData);
|
||||||
if (map.getLayer(lineId)) map.setLayoutProperty(lineId, 'visibility', visibility);
|
} catch { /* ignore — source may not exist yet */ }
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
if (map.getLayer(labelId)) map.setLayoutProperty(labelId, 'visibility', visibility);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (!zones) return;
|
||||||
if (!map.isStyleLoaded()) return;
|
if (!map.isStyleLoaded()) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = map.getSource(srcId) as GeoJSONSource | undefined;
|
// 소스가 아직 없으면 생성 (setData는 위에서 이미 처리됨)
|
||||||
if (existing) {
|
if (!map.getSource(srcId)) {
|
||||||
existing.setData(zones);
|
const data = projection === 'globe' ? simplifiedZones ?? zones : zones;
|
||||||
} else {
|
map.addSource(srcId, { type: 'geojson', data: data! } as GeoJSONSourceSpecification);
|
||||||
map.addSource(srcId, { type: 'geojson', data: zones } as GeoJSONSourceSpecification);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = map.getStyle();
|
const style = map.getStyle();
|
||||||
@ -226,5 +258,5 @@ export function useZonesLayer(
|
|||||||
return () => {
|
return () => {
|
||||||
stop();
|
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[] = [
|
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||||
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||||
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
|
{ id: 'bathymetry-borders-major', mercator: [3, 7], globe: [3, 7] },
|
||||||
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
|
{ 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) {
|
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||||
const oceanSourceId = 'maptiler-ocean';
|
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 depth = ['to-number', ['get', 'depth']] as unknown as number[];
|
||||||
const depthLabel = ['concat', ['to-string', ['*', depth, -1]], 'm'] as unknown as string[];
|
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 = [
|
const bathyFillColor = [
|
||||||
'interpolate',
|
'interpolate',
|
||||||
['linear'],
|
['linear'],
|
||||||
depth,
|
depth,
|
||||||
-11000,
|
|
||||||
'#00040b',
|
|
||||||
-8000,
|
-8000,
|
||||||
'#010610',
|
'#010610',
|
||||||
-6000,
|
|
||||||
'#020816',
|
|
||||||
-4000,
|
-4000,
|
||||||
'#030c1c',
|
'#030c1c',
|
||||||
-2000,
|
-2000,
|
||||||
@ -64,6 +103,15 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
'#2097a6',
|
'#2097a6',
|
||||||
] as const;
|
] 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 = {
|
const bathyFill: LayerSpecification = {
|
||||||
id: 'bathymetry-fill',
|
id: 'bathymetry-fill',
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
@ -77,98 +125,132 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} 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',
|
id: 'bathymetry-borders',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour',
|
'source-layer': 'contour',
|
||||||
minzoom: 5, // fill은 3부터, borders는 5부터
|
minzoom: 7,
|
||||||
maxzoom: 24,
|
maxzoom: 24,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': 'rgba(255,255,255,0.06)',
|
'line-color': 'rgba(255,255,255,0.06)',
|
||||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 4, 0.12, 8, 0.18, 12, 0.22],
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.18, 12, 0.22],
|
||||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 0.2],
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.3, 10, 0.2],
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 0.2, 8, 0.35, 12, 0.6],
|
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.35, 12, 0.6],
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
const bathyLinesMinor: LayerSpecification = {
|
// === Contour lines (contour_line) — 3-tier LOD ===
|
||||||
id: 'bathymetry-lines',
|
// z5-z7: 1000m, 2000m만
|
||||||
|
const bathyLinesCoarse: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-coarse',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour_line',
|
'source-layer': 'contour_line',
|
||||||
minzoom: 7,
|
minzoom: 5,
|
||||||
|
maxzoom: 7,
|
||||||
|
filter: depthIn(DEPTHS_COARSE) as unknown as unknown[],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': [
|
'line-color': 'rgba(255,255,255,0.12)',
|
||||||
'interpolate',
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 5, 0.15, 7, 0.22],
|
||||||
['linear'],
|
'line-blur': 0.5,
|
||||||
depth,
|
'line-width': ['interpolate', ['linear'], ['zoom'], 5, 0.4, 7, 0.6],
|
||||||
-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],
|
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
const majorDepths = [-50, -100, -200, -500, -1000, -2000, -4000, -6000, -8000, -9500];
|
// z7-z9: 100, 500, 1000, 2000, 4000m
|
||||||
const bathyMajorDepthFilter: unknown[] = [
|
|
||||||
'in',
|
|
||||||
['to-number', ['get', 'depth']],
|
|
||||||
['literal', majorDepths],
|
|
||||||
] as unknown[];
|
|
||||||
|
|
||||||
const bathyLinesMajor: LayerSpecification = {
|
const bathyLinesMajor: LayerSpecification = {
|
||||||
id: 'bathymetry-lines-major',
|
id: 'bathymetry-lines-major',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour_line',
|
'source-layer': 'contour_line',
|
||||||
minzoom: 7,
|
minzoom: 7,
|
||||||
maxzoom: 24,
|
maxzoom: 9,
|
||||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': 'rgba(255,255,255,0.16)',
|
'line-color': 'rgba(255,255,255,0.16)',
|
||||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 8, 0.22, 10, 0.28, 12, 0.34],
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 7, 0.22, 9, 0.28],
|
||||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 8, 0.4, 11, 0.2],
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 7, 0.4, 9, 0.2],
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 0.6, 10, 0.95, 12, 1.3],
|
'line-width': ['interpolate', ['linear'], ['zoom'], 7, 0.6, 9, 0.95],
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
const bathyBandBordersMajor: LayerSpecification = {
|
// z9+: 50~8000m (풀 디테일)
|
||||||
id: 'bathymetry-borders-major',
|
const bathyLinesDetail: LayerSpecification = {
|
||||||
|
id: 'bathymetry-lines-detail',
|
||||||
type: 'line',
|
type: 'line',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour',
|
'source-layer': 'contour_line',
|
||||||
minzoom: 3,
|
minzoom: 9,
|
||||||
maxzoom: 24,
|
maxzoom: 24,
|
||||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
filter: depthIn(DEPTHS_DETAIL) as unknown as unknown[],
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': 'rgba(255,255,255,0.14)',
|
'line-color': 'rgba(255,255,255,0.16)',
|
||||||
'line-opacity': ['interpolate', ['linear'], ['zoom'], 3, 0.10, 6, 0.16, 8, 0.2, 12, 0.26],
|
'line-opacity': ['interpolate', ['linear'], ['zoom'], 9, 0.28, 12, 0.34],
|
||||||
'line-blur': ['interpolate', ['linear'], ['zoom'], 3, 0.5, 6, 0.35, 10, 0.15],
|
'line-blur': ['interpolate', ['linear'], ['zoom'], 9, 0.2, 11, 0.15],
|
||||||
'line-width': ['interpolate', ['linear'], ['zoom'], 3, 0.25, 6, 0.4, 8, 0.55, 12, 0.85],
|
'line-width': ['interpolate', ['linear'], ['zoom'], 9, 0.95, 12, 1.3],
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} 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 = {
|
const bathyLabels: LayerSpecification = {
|
||||||
id: 'bathymetry-labels',
|
id: 'bathymetry-labels',
|
||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour_line',
|
'source-layer': 'contour_line',
|
||||||
minzoom: 7,
|
minzoom: 9,
|
||||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
filter: depthIn(DEPTHS_MEDIUM) as unknown as unknown[],
|
||||||
layout: {
|
layout: {
|
||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': depthLabel,
|
'text-field': depthLabel,
|
||||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
'text-font': ['Noto Sans Regular', '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-allow-overlap': false,
|
||||||
'text-padding': 4,
|
'text-padding': 4,
|
||||||
'text-rotation-alignment': 'map',
|
'text-rotation-alignment': 'map',
|
||||||
@ -244,10 +326,12 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
|
|
||||||
const toInsert = [
|
const toInsert = [
|
||||||
bathyFill,
|
bathyFill,
|
||||||
bathyBandBorders,
|
bathyBordersMajor,
|
||||||
bathyBandBordersMajor,
|
bathyBorders,
|
||||||
bathyLinesMinor,
|
bathyLinesCoarse,
|
||||||
bathyLinesMajor,
|
bathyLinesMajor,
|
||||||
|
bathyLinesDetail,
|
||||||
|
bathyLabelsCoarse,
|
||||||
bathyLabels,
|
bathyLabels,
|
||||||
landformLabels,
|
landformLabels,
|
||||||
].filter((l) => !existingIds.has(l.id));
|
].filter((l) => !existingIds.has(l.id));
|
||||||
@ -273,6 +357,7 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyKoreanLabels(style: StyleSpecification) {
|
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' } });
|
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||||
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||||
const json = (await res.json()) as StyleSpecification;
|
const json = (await res.json()) as StyleSpecification;
|
||||||
|
applyLandLayerLOD(json);
|
||||||
applyKoreanLabels(json);
|
applyKoreanLabels(json);
|
||||||
injectOceanBathymetryLayers(json, key);
|
injectOceanBathymetryLayers(json, key);
|
||||||
return json;
|
return json;
|
||||||
|
|||||||
@ -28,7 +28,6 @@ export function removeSourceIfExists(map: maplibregl.Map, sourceId: string) {
|
|||||||
const GLOBE_NATIVE_LAYER_IDS = [
|
const GLOBE_NATIVE_LAYER_IDS = [
|
||||||
'pair-lines-ml',
|
'pair-lines-ml',
|
||||||
'fc-lines-ml',
|
'fc-lines-ml',
|
||||||
'fleet-circles-ml-fill',
|
|
||||||
'fleet-circles-ml',
|
'fleet-circles-ml',
|
||||||
'pair-range-ml',
|
'pair-range-ml',
|
||||||
'subcables-hitarea',
|
'subcables-hitarea',
|
||||||
@ -44,7 +43,6 @@ const GLOBE_NATIVE_SOURCE_IDS = [
|
|||||||
'pair-lines-ml-src',
|
'pair-lines-ml-src',
|
||||||
'fc-lines-ml-src',
|
'fc-lines-ml-src',
|
||||||
'fleet-circles-ml-src',
|
'fleet-circles-ml-src',
|
||||||
'fleet-circles-ml-fill-src',
|
|
||||||
'pair-range-ml-src',
|
'pair-range-ml-src',
|
||||||
'subcables-src',
|
'subcables-src',
|
||||||
'subcables-pts-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(
|
export function cleanupLayers(
|
||||||
map: maplibregl.Map,
|
map: maplibregl.Map,
|
||||||
layerIds: string[],
|
layerIds: string[],
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user