263 lines
9.0 KiB
TypeScript
263 lines
9.0 KiB
TypeScript
import { useEffect, useMemo, type MutableRefObject } from 'react';
|
|
import maplibregl, {
|
|
type GeoJSONSource,
|
|
type GeoJSONSourceSpecification,
|
|
type LayerSpecification,
|
|
} from 'maplibre-gl';
|
|
import type { ZoneId } from '../../../entities/zone/model/meta';
|
|
import { ZONE_META } from '../../../entities/zone/model/meta';
|
|
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>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
reorderGlobeFeatureLayers: () => void,
|
|
opts: {
|
|
zones: ZonesGeoJson | null;
|
|
overlays: MapToggleState;
|
|
projection: MapProjectionId;
|
|
baseMap: BaseMapId;
|
|
hoveredZoneId: string | null;
|
|
mapSyncEpoch: number;
|
|
},
|
|
) {
|
|
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;
|
|
|
|
const srcId = 'zones-src';
|
|
const fillId = 'zones-fill';
|
|
const lineId = 'zones-line';
|
|
const labelId = 'zones-label';
|
|
|
|
const zoneColorExpr: unknown[] = ['match', ['get', 'zoneId']];
|
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
|
zoneColorExpr.push(k, ZONE_META[k].color);
|
|
}
|
|
zoneColorExpr.push('#3B82F6');
|
|
const zoneLabelExpr: unknown[] = ['match', ['to-string', ['coalesce', ['get', 'zoneId'], '']]];
|
|
for (const k of Object.keys(ZONE_META) as ZoneId[]) {
|
|
zoneLabelExpr.push(k, ZONE_META[k].name);
|
|
}
|
|
zoneLabelExpr.push(['coalesce', ['get', 'zoneName'], ['get', 'zoneLabel'], ['get', 'NAME'], '수역']);
|
|
|
|
const ensure = () => {
|
|
// 소스 데이터 간소화 — 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 {
|
|
// 소스가 아직 없으면 생성 (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();
|
|
const styleLayers = style && Array.isArray(style.layers) ? style.layers : [];
|
|
const firstSymbol = styleLayers.find((l) => (l as { type?: string } | undefined)?.type === 'symbol') as
|
|
| { id?: string }
|
|
| undefined;
|
|
const before = map.getLayer('deck-globe')
|
|
? 'deck-globe'
|
|
: map.getLayer('ships')
|
|
? 'ships'
|
|
: map.getLayer('seamark')
|
|
? 'seamark'
|
|
: firstSymbol?.id;
|
|
|
|
const zoneMatchExpr =
|
|
hoveredZoneId !== null
|
|
? (['==', ['to-string', ['coalesce', ['get', 'zoneId'], '']], hoveredZoneId] as unknown[])
|
|
: false;
|
|
const zoneLineWidthExpr = hoveredZoneId
|
|
? ([
|
|
'interpolate',
|
|
['linear'],
|
|
['zoom'],
|
|
4,
|
|
['case', zoneMatchExpr, 1.6, 0.8],
|
|
10,
|
|
['case', zoneMatchExpr, 2.0, 1.4],
|
|
14,
|
|
['case', zoneMatchExpr, 2.8, 2.1],
|
|
] as unknown as never)
|
|
: (['interpolate', ['linear'], ['zoom'], 4, 0.8, 10, 1.4, 14, 2.1] as never);
|
|
|
|
if (map.getLayer(fillId)) {
|
|
try {
|
|
map.setPaintProperty(
|
|
fillId,
|
|
'fill-opacity',
|
|
hoveredZoneId ? (['case', zoneMatchExpr, 0.24, 0.1] as unknown as number) : 0.12,
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (map.getLayer(lineId)) {
|
|
try {
|
|
map.setPaintProperty(
|
|
lineId,
|
|
'line-color',
|
|
hoveredZoneId
|
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
|
: (zoneColorExpr as never),
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
map.setPaintProperty(lineId, 'line-opacity', hoveredZoneId ? (['case', zoneMatchExpr, 1, 0.85] as never) : 0.85);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
map.setPaintProperty(lineId, 'line-width', zoneLineWidthExpr);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (!map.getLayer(fillId)) {
|
|
map.addLayer(
|
|
{
|
|
id: fillId,
|
|
type: 'fill',
|
|
source: srcId,
|
|
paint: {
|
|
'fill-color': zoneColorExpr as never,
|
|
'fill-opacity': hoveredZoneId
|
|
? ([
|
|
'case',
|
|
zoneMatchExpr,
|
|
0.24,
|
|
0.1,
|
|
] as unknown as number)
|
|
: 0.12,
|
|
},
|
|
layout: { visibility },
|
|
} as unknown as LayerSpecification,
|
|
before,
|
|
);
|
|
}
|
|
|
|
if (!map.getLayer(lineId)) {
|
|
map.addLayer(
|
|
{
|
|
id: lineId,
|
|
type: 'line',
|
|
source: srcId,
|
|
paint: {
|
|
'line-color': hoveredZoneId
|
|
? (['case', zoneMatchExpr, 'rgba(125,211,252,0.98)', zoneColorExpr as never] as never)
|
|
: (zoneColorExpr as never),
|
|
'line-opacity': hoveredZoneId
|
|
? (['case', zoneMatchExpr, 1, 0.85] as never)
|
|
: 0.85,
|
|
'line-width': zoneLineWidthExpr,
|
|
},
|
|
layout: { visibility },
|
|
} as unknown as LayerSpecification,
|
|
before,
|
|
);
|
|
}
|
|
|
|
if (!map.getLayer(labelId)) {
|
|
map.addLayer(
|
|
{
|
|
id: labelId,
|
|
type: 'symbol',
|
|
source: srcId,
|
|
layout: {
|
|
visibility,
|
|
'symbol-placement': 'point',
|
|
'text-field': zoneLabelExpr as never,
|
|
'text-size': 11,
|
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
|
'text-anchor': 'top',
|
|
'text-offset': [0, 0.35],
|
|
'text-allow-overlap': false,
|
|
'text-ignore-placement': false,
|
|
},
|
|
paint: {
|
|
'text-color': '#dbeafe',
|
|
'text-halo-color': 'rgba(2,6,23,0.85)',
|
|
'text-halo-width': 1.2,
|
|
'text-halo-blur': 0.8,
|
|
},
|
|
} as unknown as LayerSpecification,
|
|
undefined,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Zones layer setup failed:', e);
|
|
} finally {
|
|
reorderGlobeFeatureLayers();
|
|
kickRepaint(map);
|
|
}
|
|
};
|
|
|
|
const stop = onMapStyleReady(map, ensure);
|
|
return () => {
|
|
stop();
|
|
};
|
|
}, [zones, simplifiedZones, overlays.zones, projection, baseMap, hoveredZoneId, mapSyncEpoch, reorderGlobeFeatureLayers]);
|
|
}
|