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, projectionBusyRef: MutableRefObject, 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]); }