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>
This commit is contained in:
htlee 2026-02-16 13:34:42 +09:00
부모 91df90b528
커밋 95d9ea8aef
6개의 변경된 파일64개의 추가작업 그리고 218개의 파일을 삭제

파일 보기

@ -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,19 @@ 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' && (args[0] as string).includes('Easing around a point')) return;
origWarn.apply(console, args as [unknown, ...unknown[]]);
};
try {
origRender.call(this, arg);
} catch (e) {
@ -105,6 +113,9 @@ export function useMapInit(
return;
}
throw e;
} finally {
// eslint-disable-next-line no-console
console.warn = origWarn;
}
};
}

파일 보기

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

파일 보기

@ -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[],