diff --git a/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz b/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz deleted file mode 100644 index ddfbcbaa..00000000 Binary files a/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-name-2.1.0.tgz b/.yarn-offline-cache/color-name-2.1.0.tgz deleted file mode 100644 index c4eff8d9..00000000 Binary files a/.yarn-offline-cache/color-name-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-parse-2.0.2.tgz b/.yarn-offline-cache/color-parse-2.0.2.tgz deleted file mode 100644 index 78b906bc..00000000 Binary files a/.yarn-offline-cache/color-parse-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-rgba-3.0.0.tgz b/.yarn-offline-cache/color-rgba-3.0.0.tgz deleted file mode 100644 index 1df66897..00000000 Binary files a/.yarn-offline-cache/color-rgba-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-space-2.3.2.tgz b/.yarn-offline-cache/color-space-2.3.2.tgz deleted file mode 100644 index 93dcb94b..00000000 Binary files a/.yarn-offline-cache/color-space-2.3.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/geotiff-2.1.3.tgz b/.yarn-offline-cache/geotiff-2.1.3.tgz deleted file mode 100644 index 698c2dd2..00000000 Binary files a/.yarn-offline-cache/geotiff-2.1.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/lerc-3.0.0.tgz b/.yarn-offline-cache/lerc-3.0.0.tgz deleted file mode 100644 index 54938e93..00000000 Binary files a/.yarn-offline-cache/lerc-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ol-9.2.4.tgz b/.yarn-offline-cache/ol-9.2.4.tgz deleted file mode 100644 index a6e0de60..00000000 Binary files a/.yarn-offline-cache/ol-9.2.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ol-ext-4.0.37.tgz b/.yarn-offline-cache/ol-ext-4.0.37.tgz deleted file mode 100644 index b6322ac7..00000000 Binary files a/.yarn-offline-cache/ol-ext-4.0.37.tgz and /dev/null differ diff --git a/.yarn-offline-cache/pako-2.1.0.tgz b/.yarn-offline-cache/pako-2.1.0.tgz deleted file mode 100644 index 85e08da3..00000000 Binary files a/.yarn-offline-cache/pako-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/parse-headers-2.0.6.tgz b/.yarn-offline-cache/parse-headers-2.0.6.tgz deleted file mode 100644 index 97bbbcc7..00000000 Binary files a/.yarn-offline-cache/parse-headers-2.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/pbf-3.2.1.tgz b/.yarn-offline-cache/pbf-3.2.1.tgz deleted file mode 100644 index 241220ea..00000000 Binary files a/.yarn-offline-cache/pbf-3.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/quick-lru-6.1.2.tgz b/.yarn-offline-cache/quick-lru-6.1.2.tgz deleted file mode 100644 index d99b26d4..00000000 Binary files a/.yarn-offline-cache/quick-lru-6.1.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/web-worker-1.5.0.tgz b/.yarn-offline-cache/web-worker-1.5.0.tgz deleted file mode 100644 index f5c87727..00000000 Binary files a/.yarn-offline-cache/web-worker-1.5.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/xml-utils-1.10.2.tgz b/.yarn-offline-cache/xml-utils-1.10.2.tgz deleted file mode 100644 index d8bc19ac..00000000 Binary files a/.yarn-offline-cache/xml-utils-1.10.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zstddec-0.1.0.tgz b/.yarn-offline-cache/zstddec-0.1.0.tgz deleted file mode 100644 index 93eb7ef6..00000000 Binary files a/.yarn-offline-cache/zstddec-0.1.0.tgz and /dev/null differ diff --git a/package.json b/package.json index de5a3469..ad077b17 100644 --- a/package.json +++ b/package.json @@ -28,8 +28,6 @@ "dayjs": "^1.11.11", "html2canvas": "^1.4.1", "maplibre-gl": "^5.18.0", - "ol": "^9.2.4", - "ol-ext": "^4.0.10", "react": "^19.2.4", "react-dom": "^19.2.4", "react-router-dom": "^6.30.3", diff --git a/src/areaSearch/components/StsContactDetailModal.tsx b/src/areaSearch/components/StsContactDetailModal.tsx index 77243ae6..467dee48 100644 --- a/src/areaSearch/components/StsContactDetailModal.tsx +++ b/src/areaSearch/components/StsContactDetailModal.tsx @@ -1,21 +1,11 @@ /** - * STS 접촉 쌍 상세 모달 -- 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장 + * STS 접촉 쌍 상세 모달 -- 임베디드 MapLibre 지도 + 그리드 레이아웃 + 이미지 저장 * 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시 */ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import OlMap from 'ol/Map'; -import View from 'ol/View'; -import { XYZ } from 'ol/source'; -import TileLayer from 'ol/layer/Tile'; -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import { Feature } from 'ol'; -import { Point, LineString, Polygon } from 'ol/geom'; -import { fromLonLat } from 'ol/proj'; -import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; -import { defaults as defaultControls, ScaleLine } from 'ol/control'; -import { defaults as defaultInteractions } from 'ol/interaction'; +import maplibregl from 'maplibre-gl'; +import type { FeatureCollection, Feature, Polygon, LineString, Point } from 'geojson'; import html2canvas from 'html2canvas'; import { useStsStore } from '../stores/stsStore'; @@ -40,95 +30,102 @@ function getNationalFlagUrl(nationalCode: string | undefined): string | null { return `/ship/image/small/${nationalCode}.svg`; } -function createZoneFeatures(zones: Zone[]): Feature[] { - const features: Feature[] = []; - zones.forEach((zone) => { - const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); - const polygon = new Polygon([coords3857]); - const feature = new Feature({ geometry: polygon }); - const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; - feature.setStyle([ - new Style({ - fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), - stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), - }), - new Style({ - geometry: () => { - const ext = polygon.getExtent(); - const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; - return new Point(center); +/** + * MapLibre GeoJSON 빌더: 관심구역 폴리곤 + */ +function buildZoneGeoJSON(zones: Zone[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: zones.map((zone) => { + const coords = zone.coordinates; + // 폴리곤 닫기 보장 + const closed = [...coords]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + closed.push([first[0], first[1]]); + } + + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [closed], }, - text: new Text({ - text: `${zone.name}구역`, - font: 'bold 12px sans-serif', - fill: new Fill({ color: color.label || '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), - }), - }), - ]); - features.push(feature); - }); - return features; -} - -function createTrackFeature(track: ProcessedTrack): Feature { - const coords3857 = track.geometry.map((c) => fromLonLat(c)); - const line = new LineString(coords3857); - const feature = new Feature({ geometry: line }); - const color = getShipKindColor(track.shipKindCode); - feature.setStyle(new Style({ - stroke: new Stroke({ - color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, - width: 2, + properties: { + fillColor: `rgba(${color.fill.join(',')})`, + outlineColor: `rgba(${color.stroke.join(',')})`, + labelText: `${zone.name}구역`, + labelColor: color.label || '#fff', + }, + }; }), - })); - return feature; + }; } -function createContactMarkers(contacts: StsContact[]): Feature[] { - const features: Feature[] = []; +/** + * MapLibre GeoJSON 빌더: 2개 항적 LineString + */ +function buildTrackGeoJSON(tracks: ProcessedTrack[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: tracks.map((track) => { + const coords = track.geometry; + const shipColor = getShipKindColor(track.shipKindCode); + const colorStr = `rgba(${shipColor[0]},${shipColor[1]},${shipColor[2]},0.8)`; + + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: coords, + }, + properties: { + color: colorStr, + }, + }; + }), + }; +} + +/** + * MapLibre GeoJSON 빌더: 접촉 중심 마커 + */ +function buildContactGeoJSON(contacts: StsContact[]): FeatureCollection { + const features: Feature[] = []; contacts.forEach((contact, idx) => { if (!contact.contactCenterPoint) return; - const pos3857 = fromLonLat(contact.contactCenterPoint); const riskColor = getContactRiskColor(contact.indicators ?? null); + const labelText = contacts.length > 1 ? `#${idx + 1}` : '접촉 중심'; + const startLabel = contact.contactStartTimestamp + ? `시작 ${formatTimestamp(contact.contactStartTimestamp)}` + : ''; + const endLabel = contact.contactEndTimestamp + ? `종료 ${formatTimestamp(contact.contactEndTimestamp)}` + : ''; + const timeLabel = startLabel && endLabel ? `${startLabel}\n${endLabel}` : ''; - const f = new Feature({ geometry: new Point(pos3857) }); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 10, - fill: new Fill({ color: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)` }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: contacts.length > 1 ? `#${idx + 1}` : '접촉 중심', - font: 'bold 11px sans-serif', - fill: new Fill({ color: '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: -18, - }), - })); - features.push(f); - - if (contact.contactStartTimestamp) { - const startLabel = `시작 ${formatTimestamp(contact.contactStartTimestamp)}`; - const endLabel = `종료 ${formatTimestamp(contact.contactEndTimestamp)}`; - const labelF = new Feature({ geometry: new Point(pos3857) }); - labelF.setStyle(new Style({ - text: new Text({ - text: `${startLabel}\n${endLabel}`, - font: '10px sans-serif', - fill: new Fill({ color: '#ced4da' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: 24, - }), - })); - features.push(labelF); - } + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: contact.contactCenterPoint, + }, + properties: { + riskColor: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)`, + labelText, + timeLabel, + }, + }); }); - return features; + return { + type: 'FeatureCollection', + features, + }; } const MODAL_WIDTH = 680; @@ -145,7 +142,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }: StsContac const zones = useAreaSearchStore((s) => s.zones); const mapContainerRef = useRef(null); - const mapRef = useRef(null); + const mapRef = useRef(null); const contentRef = useRef(null); const [position, setPosition] = useState(() => ({ @@ -196,51 +193,167 @@ export default function StsContactDetailModal({ groupIndex, onClose }: StsContac [tracks, group], ); - // OL 지도 초기화 + // MapLibre 지도 초기화 useEffect(() => { if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return; - const tileSource = new XYZ({ - url: DARK_TILE_URL, - minZoom: 6, - maxZoom: 11, - }); - const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); - - const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); - const zoneLayer = new VectorLayer({ source: zoneSource }); - - const trackSource = new VectorSource({ - features: [createTrackFeature(vessel1Track), createTrackFeature(vessel2Track)], - }); - const trackLayer = new VectorLayer({ source: trackSource }); - - const markerFeatures = createContactMarkers(group.contacts); - const markerSource = new VectorSource({ features: markerFeatures }); - const markerLayer = new VectorLayer({ source: markerSource }); - - const map = new OlMap({ - target: mapContainerRef.current, - layers: [tileLayer, zoneLayer, trackLayer, markerLayer], - view: new View({ center: [0, 0], zoom: 7 }), - controls: defaultControls({ attribution: false, zoom: false, rotate: false }) - .extend([new ScaleLine({ units: 'nautical' })]), - interactions: defaultInteractions({ doubleClickZoom: false }), - }); - - const allSource = new VectorSource(); - [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); - const extent = allSource.getExtent(); - if (extent && extent[0] !== Infinity) { - map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); + // 기존 맵 정리 + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; } - mapRef.current = map; + const mlMap = new maplibregl.Map({ + container: mapContainerRef.current, + style: { + version: 8, + sources: { + 'osm-tiles': { + type: 'raster', + tiles: [DARK_TILE_URL], + tileSize: 256, + }, + }, + layers: [ + { id: 'osm-layer', type: 'raster', source: 'osm-tiles' }, + ], + }, + center: [0, 0], + zoom: 6, + dragPan: false, + scrollZoom: false, + doubleClickZoom: false, + attributionControl: false, + }); + + mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right'); + + mlMap.on('load', () => { + // Zone source + layers + mlMap.addSource('zone-source', { + type: 'geojson', + data: buildZoneGeoJSON(zones), + }); + mlMap.addLayer({ + id: 'zone-fill', + type: 'fill', + source: 'zone-source', + paint: { + 'fill-color': ['get', 'fillColor'], + 'fill-opacity': 1, + }, + }); + mlMap.addLayer({ + id: 'zone-line', + type: 'line', + source: 'zone-source', + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': 2, + }, + }); + mlMap.addLayer({ + id: 'zone-label', + type: 'symbol', + source: 'zone-source', + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': 12, + 'text-font': ['Open Sans Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'labelColor'], + 'text-halo-color': 'rgba(0,0,0,0.7)', + 'text-halo-width': 2, + }, + }); + + // Track source + layer (2개 항적) + mlMap.addSource('track-source', { + type: 'geojson', + data: buildTrackGeoJSON([vessel1Track, vessel2Track]), + }); + mlMap.addLayer({ + id: 'track-line', + type: 'line', + source: 'track-source', + paint: { + 'line-color': ['get', 'color'], + 'line-width': 2, + 'line-opacity': 0.8, + }, + }); + + // Contact source + layers + mlMap.addSource('contact-source', { + type: 'geojson', + data: buildContactGeoJSON(group.contacts), + }); + mlMap.addLayer({ + id: 'contact-circle', + type: 'circle', + source: 'contact-source', + paint: { + 'circle-radius': 10, + 'circle-color': ['get', 'riskColor'], + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'contact-label', + type: 'symbol', + source: 'contact-source', + layout: { + 'text-field': ['get', 'labelText'], // "#1" or "접촉 중심" + 'text-size': 11, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0, -1.8], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#fff', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 3, + }, + }); + mlMap.addLayer({ + id: 'contact-time', + type: 'symbol', + source: 'contact-source', + layout: { + 'text-field': ['get', 'timeLabel'], // "시작 ...\n종료 ..." + 'text-size': 10, + 'text-font': ['Open Sans Regular'], + 'text-offset': [0, 2.4], + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#ced4da', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 3, + }, + }); + + // fitBounds: 모든 피처 범위에 맞춤 + const bounds = new maplibregl.LngLatBounds(); + zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]]))); + vessel1Track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + vessel2Track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + group.contacts.forEach((c) => { + if (c.contactCenterPoint) bounds.extend([c.contactCenterPoint[0], c.contactCenterPoint[1]]); + }); + mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 }); + }); + + mapRef.current = mlMap; return () => { - map.setTarget(undefined); - map.dispose(); - mapRef.current = null; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } }; }, [group, vessel1Track, vessel2Track, zones]); diff --git a/src/areaSearch/components/VesselDetailModal.tsx b/src/areaSearch/components/VesselDetailModal.tsx index 21c4a3f0..aab8f9c3 100644 --- a/src/areaSearch/components/VesselDetailModal.tsx +++ b/src/areaSearch/components/VesselDetailModal.tsx @@ -1,20 +1,10 @@ /** - * 선박 상세 모달 -- 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장 + * 선박 상세 모달 -- 임베디드 MapLibre 지도 + 시간순 방문 이력 + 이미지 저장 */ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import OlMap from 'ol/Map'; -import View from 'ol/View'; -import { XYZ } from 'ol/source'; -import TileLayer from 'ol/layer/Tile'; -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import { Feature } from 'ol'; -import { Point, LineString, Polygon } from 'ol/geom'; -import { fromLonLat } from 'ol/proj'; -import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; -import { defaults as defaultControls, ScaleLine } from 'ol/control'; -import { defaults as defaultInteractions } from 'ol/interaction'; +import maplibregl from 'maplibre-gl'; +import type { FeatureCollection, Feature, Polygon, LineString, Point } from 'geojson'; import html2canvas from 'html2canvas'; import { useAreaSearchStore } from '../stores/areaSearchStore'; @@ -31,178 +21,109 @@ function getNationalFlagUrl(nationalCode: string | undefined): string | null { return `/ship/image/small/${nationalCode}.svg`; } -function createZoneFeatures(zones: Zone[]): Feature[] { - const features: Feature[] = []; - zones.forEach((zone) => { - const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); - const polygon = new Polygon([coords3857]); - const feature = new Feature({ geometry: polygon }); - const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; - feature.setStyle([ - new Style({ - fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), - stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), - }), - new Style({ - geometry: () => { - const ext = polygon.getExtent(); - const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; - return new Point(center); +/** + * MapLibre GeoJSON 빌더: 관심구역 폴리곤 + */ +function buildZoneGeoJSON(zones: Zone[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: zones.map((zone) => { + const coords = zone.coordinates; + // 폴리곤 닫기 보장 + const closed = [...coords]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + closed.push([first[0], first[1]]); + } + + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [closed], // [lon, lat] 직접 사용 (fromLonLat 불필요) }, - text: new Text({ - text: `${zone.name}구역`, - font: 'bold 12px sans-serif', - fill: new Fill({ color: color.label || '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), - }), - }), - ]); - features.push(feature); - }); - return features; -} - -function createTrackFeature(track: ProcessedTrack): Feature { - const coords3857 = track.geometry.map((c) => fromLonLat(c)); - const line = new LineString(coords3857); - const feature = new Feature({ geometry: line }); - const color = getShipKindColor(track.shipKindCode); - feature.setStyle(new Style({ - stroke: new Stroke({ - color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, - width: 2, + properties: { + fillColor: `rgba(${color.fill.join(',')})`, + outlineColor: `rgba(${color.stroke.join(',')})`, + labelText: `${zone.name}구역`, + labelColor: color.label || '#fff', + }, + }; }), - })); - return feature; + }; } -function createMarkerFeatures(sortedHits: HitDetail[]): Feature[] { - const features: Feature[] = []; +/** + * MapLibre GeoJSON 빌더: 항적 LineString + */ +function buildTrackGeoJSON(track: ProcessedTrack): FeatureCollection { + const coords = track.geometry; // [[lon, lat], ...] + const shipColor = getShipKindColor(track.shipKindCode); + const colorStr = `rgba(${shipColor[0]},${shipColor[1]},${shipColor[2]},0.8)`; + + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: coords, + }, + properties: { + color: colorStr, + }, + }], + }; +} + +/** + * MapLibre GeoJSON 빌더: 입퇴점 마커 (IN/OUT 분리) + */ +function buildMarkerGeoJSON(sortedHits: HitDetail[]): { + inPoints: FeatureCollection; + outPoints: FeatureCollection; +} { + const inFeatures: Feature[] = []; + const outFeatures: Feature[] = []; + sortedHits.forEach((hit, idx) => { const seqNum = idx + 1; if (hit.entryPosition) { - const pos3857 = fromLonLat(hit.entryPosition); - const f = new Feature({ geometry: new Point(pos3857) }); - const timeStr = formatTimestamp(hit.entryTimestamp); - f.set('_markerType', 'in'); - f.set('_seqNum', seqNum); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 7, - fill: new Fill({ color: '#2ecc71' }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: `${seqNum}-IN ${timeStr}`, - font: 'bold 10px sans-serif', - fill: new Fill({ color: '#2ecc71' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: -16, - textAlign: 'left', - offsetX: 10, - }), - })); - features.push(f); + inFeatures.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: hit.entryPosition, // [lon, lat] + }, + properties: { + label: `${seqNum}-IN ${formatTimestamp(hit.entryTimestamp)}`, + seqNum, + }, + }); } if (hit.exitPosition) { - const pos3857 = fromLonLat(hit.exitPosition); - const f = new Feature({ geometry: new Point(pos3857) }); - const timeStr = formatTimestamp(hit.exitTimestamp); - f.set('_markerType', 'out'); - f.set('_seqNum', seqNum); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 7, - fill: new Fill({ color: '#e74c3c' }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: `${seqNum}-OUT ${timeStr}`, - font: 'bold 10px sans-serif', - fill: new Fill({ color: '#e74c3c' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: 16, - textAlign: 'left', - offsetX: 10, - }), - })); - features.push(f); + outFeatures.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: hit.exitPosition, + }, + properties: { + label: `${seqNum}-OUT ${formatTimestamp(hit.exitTimestamp)}`, + seqNum, + }, + }); } }); - return features; -} -/** - * 마커 텍스트 겹침 보정 -- 포인트(원)는 그대로, 텍스트 offsetY만 조정 - * 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치 - */ -function adjustOverlappingLabels(features: Feature[], resolution: number | undefined): void { - if (!resolution || features.length < 2) return; - - const PROXIMITY_PX = 40; - const proximityMap = resolution * PROXIMITY_PX; - const LINE_HEIGHT_PX = 16; - - interface MarkerItem { - feature: Feature; - x: number; - y: number; - } - - // 피처별 좌표 추출 - const items: MarkerItem[] = features.map((f) => { - const coord = (f.getGeometry() as Point).getCoordinates(); - return { feature: f, x: coord[0], y: coord[1] }; - }); - - // 근접 그룹 찾기 (Union-Find 방식) - const parent = items.map((_, i) => i); - const find = (i: number): number => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; - const union = (a: number, b: number) => { parent[find(a)] = find(b); }; - - for (let i = 0; i < items.length; i++) { - for (let j = i + 1; j < items.length; j++) { - const dx = items[i].x - items[j].x; - const dy = items[i].y - items[j].y; - if (Math.sqrt(dx * dx + dy * dy) < proximityMap) { - union(i, j); - } - } - } - - // 그룹별 텍스트 offsetY 분산 - const groups: Record = {}; - items.forEach((item, i) => { - const root = find(i); - if (!groups[root]) groups[root] = []; - groups[root].push(item); - }); - - Object.values(groups).forEach((group) => { - if (group.length < 2) return; - // 시퀀스 번호 순 정렬 후 IN->OUT 순서 - group.sort((a, b) => { - const seqA = a.feature.get('_seqNum') as number; - const seqB = b.feature.get('_seqNum') as number; - if (seqA !== seqB) return seqA - seqB; - const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1; - const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1; - return typeA - typeB; - }); - - const totalHeight = group.length * LINE_HEIGHT_PX; - const startY = -totalHeight / 2 - 8; - - group.forEach((item, idx) => { - const style = item.feature.getStyle() as Style; - const textStyle = style.getText(); - if (textStyle) { - textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX); - } - }); - }); + return { + inPoints: { type: 'FeatureCollection', features: inFeatures }, + outPoints: { type: 'FeatureCollection', features: outFeatures }, + }; } const MODAL_WIDTH = 680; @@ -219,7 +140,7 @@ export default function VesselDetailModal({ vesselId, onClose }: VesselDetailMod const zones = useAreaSearchStore((s) => s.zones); const mapContainerRef = useRef(null); - const mapRef = useRef(null); + const mapRef = useRef(null); const contentRef = useRef(null); // 드래그 위치 관리 @@ -283,54 +204,181 @@ export default function VesselDetailModal({ vesselId, onClose }: VesselDetailMod [hits], ); - // OL 지도 초기화 + // MapLibre 지도 초기화 useEffect(() => { if (!mapContainerRef.current || !track) return; - const tileSource = new XYZ({ - url: DARK_TILE_URL, - minZoom: 6, - maxZoom: 11, - }); - const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); - - const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); - const zoneLayer = new VectorLayer({ source: zoneSource }); - - const trackSource = new VectorSource({ features: [createTrackFeature(track)] }); - const trackLayer = new VectorLayer({ source: trackSource }); - - const markerFeatures = createMarkerFeatures(sortedHits); - const markerSource = new VectorSource({ features: markerFeatures }); - const markerLayer = new VectorLayer({ source: markerSource }); - - const map = new OlMap({ - target: mapContainerRef.current, - layers: [tileLayer, zoneLayer, trackLayer, markerLayer], - view: new View({ center: [0, 0], zoom: 7 }), - controls: defaultControls({ attribution: false, zoom: false, rotate: false }) - .extend([new ScaleLine({ units: 'nautical' })]), - interactions: defaultInteractions({ doubleClickZoom: false }), - }); - - // 전체 extent에 맞춤 - const allSource = new VectorSource(); - [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); - const extent = allSource.getExtent(); - if (extent && extent[0] !== Infinity) { - map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); + // 기존 맵 정리 + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; } - // view fit 후 해상도 기반 텍스트 겹침 보정 - const resolution = map.getView().getResolution(); - adjustOverlappingLabels(markerFeatures, resolution); + const mlMap = new maplibregl.Map({ + container: mapContainerRef.current, + style: { + version: 8, + sources: { + 'osm-tiles': { + type: 'raster', + tiles: [DARK_TILE_URL], + tileSize: 256, + }, + }, + layers: [ + { id: 'osm-layer', type: 'raster', source: 'osm-tiles' }, + ], + }, + center: [0, 0], + zoom: 6, + dragPan: false, + scrollZoom: false, + doubleClickZoom: false, + attributionControl: false, + }); - mapRef.current = map; + mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right'); + + mlMap.on('load', () => { + // Zone source + layers + mlMap.addSource('zone-source', { + type: 'geojson', + data: buildZoneGeoJSON(zones), + }); + mlMap.addLayer({ + id: 'zone-fill', + type: 'fill', + source: 'zone-source', + paint: { + 'fill-color': ['get', 'fillColor'], // data-driven + 'fill-opacity': 1, + }, + }); + mlMap.addLayer({ + id: 'zone-line', + type: 'line', + source: 'zone-source', + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': 2, + }, + }); + mlMap.addLayer({ + id: 'zone-label', + type: 'symbol', + source: 'zone-source', + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': 12, + 'text-font': ['Open Sans Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'labelColor'], + 'text-halo-color': 'rgba(0,0,0,0.7)', + 'text-halo-width': 2, + }, + }); + + // Track source + layer + mlMap.addSource('track-source', { + type: 'geojson', + data: buildTrackGeoJSON(track), + }); + mlMap.addLayer({ + id: 'track-line', + type: 'line', + source: 'track-source', + paint: { + 'line-color': ['get', 'color'], // data-driven + 'line-width': 2, + 'line-opacity': 0.8, + }, + }); + + // Marker source + layers (IN/OUT 분리) + const { inPoints, outPoints } = buildMarkerGeoJSON(sortedHits); + + mlMap.addSource('marker-in-source', { type: 'geojson', data: inPoints }); + mlMap.addLayer({ + id: 'marker-in-circle', + type: 'circle', + source: 'marker-in-source', + paint: { + 'circle-radius': 7, + 'circle-color': '#2ecc71', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'marker-in-text', + type: 'symbol', + source: 'marker-in-source', + layout: { + 'text-field': ['get', 'label'], // "{seqNum}-IN {time}" + 'text-size': 10, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0.8, -1.3], + 'text-anchor': 'left', + 'text-allow-overlap': false, // 자동 겹침 방지 + }, + paint: { + 'text-color': '#2ecc71', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 2, + }, + }); + + mlMap.addSource('marker-out-source', { type: 'geojson', data: outPoints }); + mlMap.addLayer({ + id: 'marker-out-circle', + type: 'circle', + source: 'marker-out-source', + paint: { + 'circle-radius': 7, + 'circle-color': '#e74c3c', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'marker-out-text', + type: 'symbol', + source: 'marker-out-source', + layout: { + 'text-field': ['get', 'label'], // "{seqNum}-OUT {time}" + 'text-size': 10, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0.8, 1.3], + 'text-anchor': 'left', + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#e74c3c', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 2, + }, + }); + + // fitBounds: 모든 피처 범위에 맞춤 + const bounds = new maplibregl.LngLatBounds(); + zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]]))); + track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + sortedHits.forEach((h) => { + if (h.entryPosition) bounds.extend([h.entryPosition[0], h.entryPosition[1]]); + if (h.exitPosition) bounds.extend([h.exitPosition[0], h.exitPosition[1]]); + }); + mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 }); + }); + + mapRef.current = mlMap; return () => { - map.setTarget(undefined); - map.dispose(); - mapRef.current = null; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } }; }, [track, zones, sortedHits, zoneMap]); diff --git a/src/areaSearch/hooks/useZoneDraw.ts b/src/areaSearch/hooks/useZoneDraw.ts index dc19b9c0..bf378b74 100644 --- a/src/areaSearch/hooks/useZoneDraw.ts +++ b/src/areaSearch/hooks/useZoneDraw.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 구역 그리기 OpenLayers Draw 인터랙션 훅 * diff --git a/src/areaSearch/hooks/useZoneEdit.ts b/src/areaSearch/hooks/useZoneEdit.ts index 76b95cb9..6669b03f 100644 --- a/src/areaSearch/hooks/useZoneEdit.ts +++ b/src/areaSearch/hooks/useZoneEdit.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 구역 편집 인터랙션 훅 * diff --git a/src/areaSearch/interactions/BoxResizeInteraction.ts b/src/areaSearch/interactions/BoxResizeInteraction.ts index 2a5b9410..512dc0e0 100644 --- a/src/areaSearch/interactions/BoxResizeInteraction.ts +++ b/src/areaSearch/interactions/BoxResizeInteraction.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 사각형(Box) 리사이즈 커스텀 인터랙션 * diff --git a/src/areaSearch/interactions/CircleResizeInteraction.ts b/src/areaSearch/interactions/CircleResizeInteraction.ts index de4f102f..70a6afd2 100644 --- a/src/areaSearch/interactions/CircleResizeInteraction.ts +++ b/src/areaSearch/interactions/CircleResizeInteraction.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 원(Circle) 리사이즈 커스텀 인터랙션 * diff --git a/src/areaSearch/types/areaSearch.types.ts b/src/areaSearch/types/areaSearch.types.ts index 206f125b..7773c65e 100644 --- a/src/areaSearch/types/areaSearch.types.ts +++ b/src/areaSearch/types/areaSearch.types.ts @@ -2,9 +2,6 @@ * 항적분석(구역 검색) 상수 및 타입 정의 */ -import type Feature from 'ol/Feature'; -import type { Geometry } from 'ol/geom'; - // ========== 분석 탭 ========== export const ANALYSIS_TABS = { @@ -129,7 +126,8 @@ export interface Zone { source: string; coordinates: number[][]; colorIndex: number; - olFeature?: Feature; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OL Feature (기능 미사용, Session G 패스) + olFeature?: any; circleMeta: CircleMeta | null; } diff --git a/src/areaSearch/utils/zoneLayerRefs.ts b/src/areaSearch/utils/zoneLayerRefs.ts index e468c08a..f13830b9 100644 --- a/src/areaSearch/utils/zoneLayerRefs.ts +++ b/src/areaSearch/utils/zoneLayerRefs.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 구역 VectorSource/VectorLayer 모듈 스코프 참조 * useZoneDraw와 useZoneEdit 간 공유 diff --git a/src/main.tsx b/src/main.tsx index 9cd05a74..5711bb0c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,8 +4,6 @@ import App from './App'; // MapLibre GL JS 스타일 import 'maplibre-gl/dist/maplibre-gl.css'; -// OpenLayers 스타일 (측정/구역 OL 오버레이용 — Session H에서 제거) -import 'ol/ol.css'; // 글로벌 스타일 import './scss/global.scss'; diff --git a/src/map/MapContainer.tsx b/src/map/MapContainer.tsx index 591e84bd..49e948cf 100644 --- a/src/map/MapContainer.tsx +++ b/src/map/MapContainer.tsx @@ -28,8 +28,8 @@ import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQuery import useAreaSearchLayer from '../areaSearch/hooks/useAreaSearchLayer'; import useStsLayer from '../areaSearch/hooks/useStsLayer'; -import useZoneDraw from '../areaSearch/hooks/useZoneDraw'; -import useZoneEdit from '../areaSearch/hooks/useZoneEdit'; +// import useZoneDraw from '../areaSearch/hooks/useZoneDraw'; // Session G 패스 (OL 제거) +// import useZoneEdit from '../areaSearch/hooks/useZoneEdit'; // Session G 패스 (OL 제거) import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore'; import { useStsStore } from '../areaSearch/stores/stsStore'; import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types'; @@ -89,8 +89,8 @@ export default function MapContainer() { // 항적분석 레이어 + STS 레이어 + 구역 그리기 + 구역 편집 useAreaSearchLayer(); useStsLayer(); - useZoneDraw(); - useZoneEdit(); + // useZoneDraw(); // Session G 패스 (OL 제거) + // useZoneEdit(); // Session G 패스 (OL 제거) const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted); const stsCompleted = useStsStore((s) => s.queryCompleted); @@ -361,6 +361,7 @@ export default function MapContainer() { // dblclick (상세 모달) const handleDblClick = (e: maplibregl.MapMouseEvent) => { + if (useMapStore.getState().activeMeasureTool) return; const pixel = [e.point.x, e.point.y]; const ship = pickShip(pixel); if (ship) { @@ -372,6 +373,7 @@ export default function MapContainer() { // click (빈 영역 클릭 시 선택/메뉴 해제) const handleClick = (e: maplibregl.MapMouseEvent) => { + if (useMapStore.getState().activeMeasureTool) return; const ship = pickShip([e.point.x, e.point.y]); if (!ship) { useShipStore.getState().clearSelectedShips(); diff --git a/src/map/measure/measure.scss b/src/map/measure/measure.scss index f6c280ee..2f687593 100644 --- a/src/map/measure/measure.scss +++ b/src/map/measure/measure.scss @@ -1,8 +1,10 @@ /* 측정 툴팁 스타일 */ -/* 참조: mda-react-front/src/map/control.css */ -.ol-tooltip { - position: relative; +/* MapLibre Marker wrapper(.maplibregl-marker)가 width 미지정이므로 + 자식 element에 display: inline-block + width: max-content로 크기 제한 */ +.measure-tooltip { + display: inline-block; + width: max-content; padding: 0.4rem 0.8rem; border-radius: 0.4rem; font-size: 1.2rem; @@ -10,21 +12,21 @@ pointer-events: none; } -.ol-tooltip-measure { +.measure-tooltip-active { background: rgba(255, 237, 169, 0.85); color: #333; font-weight: 600; border: 0.1rem solid rgba(200, 180, 100, 0.5); } -.ol-tooltip-static { +.measure-tooltip-static { background: rgba(255, 237, 169, 0.85); color: #333; font-weight: 600; border: 0.1rem solid rgba(200, 180, 100, 0.5); } -.ol-tooltip-segment { +.measure-tooltip-segment { background: rgba(255, 255, 255, 0.8); color: #555; font-size: 1.1rem; @@ -32,7 +34,7 @@ border: 0.1rem solid rgba(180, 180, 180, 0.6); } -.ol-tooltip-segment-static { +.measure-tooltip-segment-static { background: rgba(255, 255, 255, 0.75); color: #666; font-size: 1.1rem; diff --git a/src/map/measure/measure.ts b/src/map/measure/measure.ts index 95cd6a00..7af44d3d 100644 --- a/src/map/measure/measure.ts +++ b/src/map/measure/measure.ts @@ -1,101 +1,216 @@ /** - * 측정 도구 핵심 로직 - * - MeasureSession: OL 객체 생명주기 관리 + * 측정 도구 핵심 로직 (MapLibre GL JS) + * - MeasureSession: MapLibre 객체 생명주기 관리 * - 거리/면적/거리환 설정 함수 * - 포맷 유틸리티 * * 참조: mda-react-front/src/components/nav/rightNav/measure.ts */ -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import Feature from 'ol/Feature'; -import { Draw } from 'ol/interaction'; -import { Overlay } from 'ol'; -import { createBox } from 'ol/interaction/Draw'; -import { unByKey } from 'ol/Observable'; -import { getArea, getLength } from 'ol/sphere'; -import { LineString, type Geometry } from 'ol/geom'; -import type OlMap from 'ol/Map'; -import type { EventsKey } from 'ol/events'; -import type { DrawEvent } from 'ol/interaction/Draw'; +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, MapMouseEvent } from 'maplibre-gl'; +import * as turf from '@turf/turf'; +import type { Feature, FeatureCollection, LineString, Polygon } from 'geojson'; /** 면적 측정 도형 타입 */ -type AreaMeasureShape = 'Polygon' | 'Box' | 'Circle'; +export type AreaMeasureShape = 'Polygon' | 'Box' | 'Circle'; + +/** MapLibre source/layer ID */ +const MEASURE_SOURCE_ID = 'measure-source'; +const MEASURE_FILL_LAYER_ID = 'measure-fill-layer'; +const MEASURE_LINE_LAYER_ID = 'measure-line-layer'; + +const ALL_MEASURE_LAYER_IDS = [ + MEASURE_LINE_LAYER_ID, + MEASURE_FILL_LAYER_ID, +] as const; + +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +/** 이벤트 핸들러 추적 정보 */ +interface TrackedHandler { + event: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 이벤트 핸들러 범용 타입 + handler: (...args: any[]) => void; + target: 'map' | 'document'; +} // ===================== // MeasureSession 클래스 // ===================== /** - * 측정 세션: 생성한 OL 객체(레이어, 인터랙션, 오버레이, 리스너)를 - * 직접 추적하고, dispose() 한 번으로 일괄 정리. + * 측정 세션: MapLibre source/layer, Marker 툴팁, 이벤트 핸들러를 + * 추적하고 dispose() 한 번으로 일괄 정리. */ export class MeasureSession { - map: OlMap; - _layer: VectorLayer> | null; - _interactions: Draw[]; - _overlays: Overlay[]; - _listeners: EventsKey[]; + map: maplibregl.Map; + _markers: maplibregl.Marker[]; + _handlers: TrackedHandler[]; + _currentData: FeatureCollection; + _completedFeatures: Feature[]; + _prevCursor: string; - constructor(map: OlMap) { + constructor(map: maplibregl.Map) { this.map = map; - this._layer = null; - this._interactions = []; - this._overlays = []; - this._listeners = []; + this._markers = []; + this._handlers = []; + this._currentData = EMPTY_FC; + this._completedFeatures = []; + this._prevCursor = map.getCanvas().style.cursor; + + // 커서 변경 + map.getCanvas().style.cursor = 'crosshair'; + + // source/layer 생성 + this.ensureLayers(); + + // style.load 복구 + this._onStyleLoad = this._onStyleLoad.bind(this); + map.on('style.load', this._onStyleLoad); } - /** VectorLayer 생성+등록, source 반환 */ - createLayer(): VectorSource { - const source = new VectorSource({ wrapX: false }); - const layer = new VectorLayer({ source, zIndex: 54 }); - this._layer = layer; - this.map.addLayer(layer); - return source; + /** source/layer 생성 (멱등, style.load 복구용) */ + ensureLayers(): void { + const map = this.map; + + if (!map.getSource(MEASURE_SOURCE_ID)) { + map.addSource(MEASURE_SOURCE_ID, { type: 'geojson', data: EMPTY_FC }); + } + + if (!map.getLayer(MEASURE_FILL_LAYER_ID)) { + map.addLayer({ + id: MEASURE_FILL_LAYER_ID, + type: 'fill', + source: MEASURE_SOURCE_ID, + filter: ['==', '$type', 'Polygon'], + paint: { + 'fill-color': 'rgba(255, 237, 169, 0.3)', + }, + }); + } + + if (!map.getLayer(MEASURE_LINE_LAYER_ID)) { + map.addLayer({ + id: MEASURE_LINE_LAYER_ID, + type: 'line', + source: MEASURE_SOURCE_ID, + paint: { + 'line-color': '#f0c040', + 'line-width': 2, + }, + }); + } } - /** Draw 인터랙션 등록+추적 */ - addInteraction(draw: Draw): Draw { - this.map.addInteraction(draw); - this._interactions.push(draw); - return draw; + /** style.load 복구 핸들러 */ + _onStyleLoad(): void { + this.ensureLayers(); + const source = this.map.getSource(MEASURE_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(this._currentData); + } } - /** 측정 툴팁 Overlay 생성+등록+추적 */ - createTooltip(): Overlay { + /** 확정된 피처를 누적 저장 */ + completeFeatures(features: Feature[]): void { + this._completedFeatures.push(...features); + } + + /** GeoJSON 데이터 갱신 (누적 확정 피처 + 현재 드로잉 피처) */ + setData(fc: FeatureCollection): void { + const merged: FeatureCollection = { + type: 'FeatureCollection', + features: [...this._completedFeatures, ...fc.features], + }; + this._currentData = merged; + const source = this.map.getSource(MEASURE_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(merged); + } + } + + /** 툴팁 Marker 생성 */ + createTooltipMarker(lngLat: [number, number], html: string, className: string): maplibregl.Marker { const el = document.createElement('div'); - el.className = 'ol-tooltip ol-tooltip-measure'; - const overlay = new Overlay({ - element: el, - offset: [0, -15], - positioning: 'bottom-center', - }); - this.map.addOverlay(overlay); - this._overlays.push(overlay); - return overlay; + el.className = className; + el.innerHTML = html; + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -10] }) + .setLngLat(lngLat) + .addTo(this.map); + this._markers.push(marker); + return marker; } - /** 리스너 키 추적 (dispose 시 일괄 해제) */ - addListener(key: EventsKey | undefined): EventsKey | undefined { - if (key) this._listeners.push(key); - return key; + /** 활성 마커를 제거하고 동일 위치에 static 마커를 새로 생성 (확정용) */ + replaceWithStatic(marker: maplibregl.Marker, html: string, className: string): maplibregl.Marker { + const lngLat = marker.getLngLat(); + marker.remove(); + this._markers = this._markers.filter((m) => m !== marker); + return this.createTooltipMarker([lngLat.lng, lngLat.lat], html, className); + } + + /** 맵 이벤트 등록 + 추적 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MapLibre 이벤트 핸들러 범용 + onMap(event: string, handler: (e: any) => void): void { + this.map.on(event as keyof maplibregl.MapEventType, handler); + this._handlers.push({ event, handler, target: 'map' }); + } + + /** document 이벤트 등록 + 추적 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DOM 이벤트 핸들러 범용 + onDocument(event: string, handler: (e: any) => void): void { + document.addEventListener(event, handler); + this._handlers.push({ event, handler, target: 'document' }); + } + + /** 이벤트 핸들러만 제거 + 진행 중 드로잉 데이터 초기화 (세션·레이어·확정 마커 유지) */ + clearHandlers(): void { + this._handlers.forEach(({ event, handler, target }) => { + if (target === 'map') { + this.map.off(event as keyof maplibregl.MapEventType, handler); + } else { + document.removeEventListener(event, handler); + } + }); + this._handlers = []; + // 진행 중 드로잉 피처 제거 (확정 피처만 남김) + this.setData(EMPTY_FC); + } + + /** 확정되지 않은 (active) 마커만 제거 — static 클래스가 없는 마커 */ + removeActiveMarkers(): void { + this._markers = this._markers.filter((m) => { + const cls = m.getElement().className; + if (cls.includes('-static')) return true; + m.remove(); + return false; + }); } /** 모든 추적 객체 일괄 제거 */ dispose(): void { - this._listeners.forEach((key) => unByKey(key)); - this._listeners = []; + this.clearHandlers(); - this._interactions.forEach((i) => this.map.removeInteraction(i)); - this._interactions = []; + // 모든 Marker 제거 (확정 static 포함) + this._markers.forEach((m) => m.remove()); + this._markers = []; - this._overlays.forEach((o) => this.map.removeOverlay(o)); - this._overlays = []; + // style.load 해제 + this.map.off('style.load', this._onStyleLoad); - if (this._layer) { - this.map.removeLayer(this._layer); - this._layer = null; + // 레이어/소스 제거 + ALL_MEASURE_LAYER_IDS.forEach((id) => { + if (this.map.getLayer(id)) this.map.removeLayer(id); + }); + if (this.map.getSource(MEASURE_SOURCE_ID)) { + this.map.removeSource(MEASURE_SOURCE_ID); } + + // 누적 피처 초기화 + this._completedFeatures = []; + + // 커서 복원 + this.map.getCanvas().style.cursor = this._prevCursor; } } @@ -103,9 +218,7 @@ export class MeasureSession { // 포맷 유틸리티 // ===================== -/** - * 거리 포맷: NM (km) - */ +/** 거리 포맷: NM (km) */ export function formatDistance(meters: number): string { const nm = ((meters / 1000) * 0.5399568035).toFixed(1); let sub: string; @@ -117,9 +230,7 @@ export function formatDistance(meters: number): string { return `${nm} NM (${sub})`; } -/** - * 면적 포맷: km² 또는 m² - */ +/** 면적 포맷: km² 또는 m² */ export function formatArea(sqMeters: number): string { if (sqMeters > 10000) { return (Math.round((sqMeters / 1000000) * 100) / 100) + ' km\u00B2'; @@ -127,83 +238,128 @@ export function formatArea(sqMeters: number): string { return (Math.round(sqMeters * 100) / 100) + ' m\u00B2'; } -/** - * 각도 계산 (북쪽 기준 시계방향) - */ -export function getCircleDegree(start: number[], end: number[], cog: number = 0): string { - const x = Number(end[0]) - Number(start[0]); - const y = Number(end[1]) - Number(start[1]); - - const radian = Math.atan2(y, x) * (180 / Math.PI); - let angle = 360 - (radian - 90); - angle = (angle - cog) % 360; - if (angle < 0) angle += 360; - - return angle.toFixed(1); +/** 방위각 계산 (북쪽 기준 0-360°) */ +function calculateBearing(center: [number, number], edge: [number, number]): string { + let bearing = turf.bearing(turf.point(center), turf.point(edge)); + if (bearing < 0) bearing += 360; + return bearing.toFixed(1); } -/** - * 선분별 거리 툴팁 관리자 - * 좌표 배열이 변경될 때마다 선분 개수에 맞춰 툴팁을 생성/업데이트/제거 - */ +// ===================== +// GeoJSON 빌더 +// ===================== + +/** 좌표 배열 → LineString GeoJSON */ +function buildLineFC(coords: [number, number][]): FeatureCollection { + const features: Feature[] = []; + if (coords.length >= 2) { + features.push(turf.lineString([...coords]) as Feature); + } + return { type: 'FeatureCollection', features }; +} + +/** 좌표 배열 → 닫힌 Polygon GeoJSON */ +function buildPolygonFC(coords: [number, number][]): FeatureCollection { + const features: Feature[] = []; + if (coords.length >= 3) { + const closed = [...coords, coords[0]]; + features.push(turf.polygon([closed]) as Feature); + } else if (coords.length >= 2) { + features.push(turf.lineString([...coords]) as Feature); + } + return { type: 'FeatureCollection', features }; +} + +/** 두 코너 → 사각형 Polygon GeoJSON */ +function buildBoxFC(corner1: [number, number], corner2: [number, number]): FeatureCollection { + const minLon = Math.min(corner1[0], corner2[0]); + const maxLon = Math.max(corner1[0], corner2[0]); + const minLat = Math.min(corner1[1], corner2[1]); + const maxLat = Math.max(corner1[1], corner2[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + return { type: 'FeatureCollection', features: [poly as Feature] }; +} + +/** 중심 + 반경 → 원 + 반경선 GeoJSON */ +function buildCircleFC(center: [number, number], edge: [number, number]): FeatureCollection { + const radiusKm = turf.distance(turf.point(center), turf.point(edge), { units: 'kilometers' }); + const circle = turf.circle(center, radiusKm, { steps: 64, units: 'kilometers' }); + const line = turf.lineString([center, edge]); + return { type: 'FeatureCollection', features: [circle as Feature, line as Feature] }; +} + +// ===================== +// SegmentTooltips +// ===================== + +/** 선분별 거리 툴팁 관리 (Marker 기반) */ class SegmentTooltips { session: MeasureSession; - tooltips: Overlay[]; + markers: maplibregl.Marker[]; constructor(session: MeasureSession) { this.session = session; - this.tooltips = []; + this.markers = []; } - /** - * 좌표 배열을 받아 각 선분 중점에 거리 툴팁 배치 - */ - update(coords: number[][]): void { + /** 좌표 배열을 받아 각 선분 중점에 거리 Marker 배치 */ + update(coords: [number, number][]): void { const segCount = coords.length - 1; - // 부족하면 툴팁 추가 생성 - while (this.tooltips.length < segCount) { - const el = document.createElement('div'); - el.className = 'ol-tooltip ol-tooltip-segment'; - const overlay = new Overlay({ - element: el, - offset: [0, -10], - positioning: 'bottom-center', - }); - this.session.map.addOverlay(overlay); - this.session._overlays.push(overlay); - this.tooltips.push(overlay); - } - // 남으면 숨기기 - for (let i = segCount; i < this.tooltips.length; i++) { - this.tooltips[i].setPosition(undefined); + for (let i = segCount; i < this.markers.length; i++) { + this.markers[i].getElement().style.display = 'none'; } - // 각 선분 거리 계산 및 표시 + // 각 선분 처리 (부족하면 해당 위치에 Marker 즉시 생성) for (let i = 0; i < segCount; i++) { - const segLine = new LineString([coords[i], coords[i + 1]]); - const length = getLength(segLine); - const mid: [number, number] = [ + const dist = turf.distance(turf.point(coords[i]), turf.point(coords[i + 1]), { units: 'meters' }); + const midCoord: [number, number] = [ (coords[i][0] + coords[i + 1][0]) / 2, (coords[i][1] + coords[i + 1][1]) / 2, ]; - const el = this.tooltips[i].getElement(); - if (el) { - el.innerHTML = formatDistance(length); + + if (i >= this.markers.length) { + const el = document.createElement('div'); + el.className = 'measure-tooltip measure-tooltip-segment'; + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -6] }) + .setLngLat(midCoord) + .addTo(this.session.map); + this.session._markers.push(marker); + this.markers.push(marker); + } else { + this.markers[i].setLngLat(midCoord); + this.markers[i].getElement().style.display = ''; } - this.tooltips[i].setPosition(mid); + + this.markers[i].getElement().innerHTML = formatDistance(dist); } } - /** 모든 선분 툴팁을 static 스타일로 고정 */ - finalize(): void { - this.tooltips.forEach((overlay) => { - const el = overlay.getElement(); - if (el && overlay.getPosition()) { - el.className = 'ol-tooltip ol-tooltip-segment-static'; - } - }); + /** 드로잉 마커를 모두 제거하고 확정 위치에 static 마커를 새로 생성 */ + finalize(finalCoords: [number, number][]): void { + // 기존 드로잉 마커 제거 + const markerSet = new Set(this.markers); + this.markers.forEach((m) => m.remove()); + this.session._markers = this.session._markers.filter((m) => !markerSet.has(m)); + this.markers = []; + + // 확정 마커 새로 생성 (static 클래스 + 내용 포함) + const segCount = finalCoords.length - 1; + for (let i = 0; i < segCount; i++) { + const dist = turf.distance(turf.point(finalCoords[i]), turf.point(finalCoords[i + 1]), { units: 'meters' }); + const midCoord: [number, number] = [ + (finalCoords[i][0] + finalCoords[i + 1][0]) / 2, + (finalCoords[i][1] + finalCoords[i + 1][1]) / 2, + ]; + const el = document.createElement('div'); + el.className = 'measure-tooltip measure-tooltip-segment-static'; + el.innerHTML = formatDistance(dist); + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -6] }) + .setLngLat(midCoord) + .addTo(this.session.map); + this.session._markers.push(marker); + } } } @@ -214,209 +370,429 @@ class SegmentTooltips { /** * 거리 측정 설정 (LineString) */ -export function setupDistanceMeasure(session: MeasureSession, source: VectorSource): void { - const draw = new Draw({ source, type: 'LineString' }); - session.addInteraction(draw); - - let currentTooltip: Overlay | null = null; +export function setupDistanceMeasure(session: MeasureSession): void { + const points: [number, number][] = []; + let mousePos: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; let segTooltips: SegmentTooltips | null = null; + let isFinalized = false; - draw.on('drawstart', (evt: DrawEvent) => { - const tooltip = session.createTooltip(); - currentTooltip = tooltip; - segTooltips = new SegmentTooltips(session); - const geom = evt.feature.getGeometry()!; + const updateGeometry = () => { + const allCoords = [...points]; + if (mousePos && !isFinalized) allCoords.push(mousePos); + session.setData(buildLineFC(allCoords)); - const key = geom.on('change', (e) => { - const target = e.target as LineString; - const coords = target.getCoordinates(); - const length = getLength(target); - const el = tooltip.getElement(); - if (el) { - el.innerHTML = formatDistance(length); + // 총 거리 계산 + if (allCoords.length >= 2) { + const line = turf.lineString(allCoords); + const totalMeters = turf.length(line, { units: 'meters' }); + const lastCoord = allCoords[allCoords.length - 1]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(lastCoord, '', 'measure-tooltip measure-tooltip-active'); } - tooltip.setPosition(target.getLastCoordinate()); + mainTooltip.setLngLat(lastCoord); + mainTooltip.getElement().innerHTML = formatDistance(totalMeters); + } + }; - // 선분별 거리 표시 (2개 이상 좌표일 때) - if (coords.length >= 2) { - segTooltips!.update(coords); - } - }); - session.addListener(key); + session.onMap('click', (e: MapMouseEvent) => { + if (isFinalized) return; + points.push([e.lngLat.lng, e.lngLat.lat]); + + if (!segTooltips) { + segTooltips = new SegmentTooltips(session); + } + if (points.length >= 2) { + segTooltips.update(points); + } + updateGeometry(); }); - draw.on('drawend', () => { - if (currentTooltip) { - const el = currentTooltip.getElement(); - if (el) { - el.className = 'ol-tooltip ol-tooltip-static'; + session.onMap('mousemove', (e: MapMouseEvent) => { + if (isFinalized) return; + mousePos = [e.lngLat.lng, e.lngLat.lat]; + if (points.length >= 1) { + updateGeometry(); + } + }); + + session.onMap('dblclick', (e: MapMouseEvent) => { + if (isFinalized) return; + e.preventDefault(); + isFinalized = true; + + // dblclick 전 click이 2번 발생해서 중복 포인트 제거 (픽셀 근접 판정) + if (points.length >= 2) { + points.pop(); + if (points.length >= 2) { + const lastPx = session.map.project(points[points.length - 1]); + const prevPx = session.map.project(points[points.length - 2]); + const dx = lastPx.x - prevPx.x; + const dy = lastPx.y - prevPx.y; + if (dx * dx + dy * dy < 25) points.pop(); } - currentTooltip.setOffset([0, -7]); + } + + mousePos = null; + updateGeometry(); + + // 툴팁: 활성 마커 → static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); } if (segTooltips) { - segTooltips.finalize(); + segTooltips.finalize(points); } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildLineFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; + }); + + // 우클릭: 해당 위치까지 그리고 확정 + session.onMap('contextmenu', (e: MapMouseEvent) => { + if (isFinalized || points.length < 1) return; + e.preventDefault(); + + points.push([e.lngLat.lng, e.lngLat.lat]); + isFinalized = true; + mousePos = null; + updateGeometry(); + + if (!segTooltips && points.length >= 2) { + segTooltips = new SegmentTooltips(session); + } + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); + } + if (segTooltips) { + segTooltips.finalize(points); + } + + session.completeFeatures(buildLineFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; }); } /** * 면적 측정 설정 (Polygon / Box / Circle) */ -export function setupAreaMeasure(session: MeasureSession, source: VectorSource, shape: AreaMeasureShape): void { - // 메인 Draw 생성 - let draw: Draw; +export function setupAreaMeasure(session: MeasureSession, shape: AreaMeasureShape): void { if (shape === 'Box') { - draw = new Draw({ source, type: 'Circle', geometryFunction: createBox() }); + setupBoxMeasure(session); } else if (shape === 'Circle') { - draw = new Draw({ source, type: 'Circle' }); + setupCircleMeasure(session); } else { - draw = new Draw({ source, type: 'Polygon' }); + setupPolygonMeasure(session); } - session.addInteraction(draw); +} - // Circle인 경우 반경 표시용 Line Draw 추가 - let lineDraw: Draw | null = null; - let lineTooltip: Overlay | null = null; - if (shape === 'Circle') { - lineTooltip = session.createTooltip(); - lineDraw = new Draw({ source, type: 'LineString' }); - session.addInteraction(lineDraw); +/** 첫 점 근처 클릭 시 폴리곤 닫기 판정 픽셀 임계값 */ +const SNAP_CLOSE_PX = 10; - lineDraw.on('drawstart', (evt: DrawEvent) => { - session.map.addOverlay(lineTooltip!); - const geom = evt.feature.getGeometry()!; - - const key = geom.on('change', (e) => { - const target = e.target as LineString; - const length = getLength(target); - const area = length * length * Math.PI; - const el = lineTooltip!.getElement(); - if (el) { - el.innerHTML = formatArea(area); - } - lineTooltip!.setPosition(target.getFirstCoordinate()); - }); - session.addListener(key); - }); - } - - let currentTooltip: Overlay | null = null; +/** 다각형 면적 측정 */ +function setupPolygonMeasure(session: MeasureSession): void { + const points: [number, number][] = []; + let mousePos: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; let segTooltips: SegmentTooltips | null = null; + let isFinalized = false; - draw.on('drawstart', (evt: DrawEvent) => { - if (shape === 'Polygon' || shape === 'Box') { - currentTooltip = session.createTooltip(); + /** 첫 점과의 픽셀 거리가 SNAP_CLOSE_PX 이내인지 */ + const isNearFirstPoint = (lngLat: [number, number]): boolean => { + if (points.length < 3) return false; + const firstPx = session.map.project(points[0]); + const curPx = session.map.project(lngLat); + const dx = firstPx.x - curPx.x; + const dy = firstPx.y - curPx.y; + return Math.sqrt(dx * dx + dy * dy) <= SNAP_CLOSE_PX; + }; + + /** 폴리곤 확정 */ + const finalize = () => { + isFinalized = true; + mousePos = null; + updateGeometry(); + + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); + } + if (segTooltips) { + const closed = [...points, points[0]]; + segTooltips.finalize(closed); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildPolygonFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; + }; + + const updateGeometry = () => { + const allCoords = [...points]; + if (mousePos && !isFinalized) allCoords.push(mousePos); + session.setData(buildPolygonFC(allCoords)); + + // 면적 계산 (3개 이상일 때) + if (allCoords.length >= 3) { + const closed = [...allCoords, allCoords[0]]; + const poly = turf.polygon([closed]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(centCoord, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(centCoord); + mainTooltip.getElement().innerHTML = formatArea(areaM2); + } + }; + + session.onMap('click', (e: MapMouseEvent) => { + if (isFinalized) return; + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + // 첫 점 근처 클릭 → 폴리곤 닫기 + if (isNearFirstPoint(coord)) { + finalize(); + return; + } + + points.push(coord); + + if (!segTooltips) { segTooltips = new SegmentTooltips(session); } - - const geom = evt.feature.getGeometry()!; - const key = geom.on('change', (e) => { - if (shape === 'Polygon' || shape === 'Box') { - const target = e.target as import('ol/geom/Polygon').default; - const areaValue = getArea(target); - const el = currentTooltip!.getElement(); - if (el) { - el.innerHTML = formatArea(areaValue); - } - currentTooltip!.setPosition(target.getInteriorPoint().getCoordinates()); - - // 선분별 거리 표시 - const coords = target.getCoordinates()[0]; // 외부 링 - if (coords && coords.length >= 2) { - segTooltips!.update(coords); - } - } - }); - session.addListener(key); + if (points.length >= 2) { + segTooltips.update(points); + } + updateGeometry(); }); - draw.on('drawend', () => { - if (shape === 'Polygon' || shape === 'Box') { - if (currentTooltip) { - const el = currentTooltip.getElement(); - if (el) { - el.className = 'ol-tooltip ol-tooltip-static'; - } - currentTooltip.setOffset([0, -7]); - } - if (segTooltips) { - segTooltips.finalize(); + session.onMap('mousemove', (e: MapMouseEvent) => { + if (isFinalized) return; + mousePos = [e.lngLat.lng, e.lngLat.lat]; + if (points.length >= 1) { + updateGeometry(); + } + }); + + session.onMap('dblclick', (e: MapMouseEvent) => { + if (isFinalized) return; + e.preventDefault(); + + // dblclick 전 click 2번으로 인한 중복 제거 (픽셀 근접 판정) + if (points.length >= 2) { + points.pop(); + if (points.length >= 2) { + const lastPx = session.map.project(points[points.length - 1]); + const prevPx = session.map.project(points[points.length - 2]); + const dx = lastPx.x - prevPx.x; + const dy = lastPx.y - prevPx.y; + if (dx * dx + dy * dy < 25) points.pop(); } } - if (shape === 'Circle' && lineDraw) { - lineDraw.finishDrawing(); + + finalize(); + }); + + // 우클릭: 해당 위치를 마지막 점으로 추가 후 폴리곤 확정 + session.onMap('contextmenu', (e: MapMouseEvent) => { + if (isFinalized || points.length < 2) return; + e.preventDefault(); + points.push([e.lngLat.lng, e.lngLat.lat]); + finalize(); + }); +} + +/** 사각형 면적 측정 (2-클릭: 대각선 꼭짓점 2개) */ +function setupBoxMeasure(session: MeasureSession): void { + let corner1: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + let segTooltips: SegmentTooltips | null = null; + + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + if (!corner1) { + corner1 = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildBoxFC(corner1, coord)); + + const minLon = Math.min(corner1[0], coord[0]); + const maxLon = Math.max(corner1[0], coord[0]); + const minLat = Math.min(corner1[1], coord[1]); + const maxLat = Math.max(corner1[1], coord[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + const ring = poly.geometry.coordinates[0] as [number, number][]; + + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, formatArea(areaM2), 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(centCoord, formatArea(areaM2), 'measure-tooltip measure-tooltip-static'); + } + if (!segTooltips) { + segTooltips = new SegmentTooltips(session); + } + segTooltips.finalize(ring); + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildBoxFC(corner1, coord).features); + corner1 = null; + mainTooltip = null; + segTooltips = null; } }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!corner1) return; + const corner2: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + session.setData(buildBoxFC(corner1, corner2)); + + const minLon = Math.min(corner1[0], corner2[0]); + const maxLon = Math.max(corner1[0], corner2[0]); + const minLat = Math.min(corner1[1], corner2[1]); + const maxLat = Math.max(corner1[1], corner2[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(centCoord, '', 'measure-tooltip measure-tooltip-active'); + segTooltips = new SegmentTooltips(session); + } + mainTooltip.setLngLat(centCoord); + mainTooltip.getElement().innerHTML = formatArea(areaM2); + + const ring = poly.geometry.coordinates[0] as [number, number][]; + segTooltips!.update(ring); + }); +} + +/** 원 면적 측정 (클릭 중심 → 클릭 반경) */ +function setupCircleMeasure(session: MeasureSession): void { + let center: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + if (!center) { + center = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildCircleFC(center, coord)); + + const radiusM = turf.distance(turf.point(center), turf.point(coord), { units: 'meters' }); + const areaM2 = Math.PI * radiusM * radiusM; + + const html = `${formatArea(areaM2)}
${formatDistance(radiusM)} (반경)`; + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, html, 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(center, html, 'measure-tooltip measure-tooltip-static'); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildCircleFC(center, coord).features); + center = null; + mainTooltip = null; + } + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!center) return; + const edge: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + session.setData(buildCircleFC(center, edge)); + + const radiusM = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' }); + const areaM2 = Math.PI * radiusM * radiusM; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(center, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(center); + mainTooltip.getElement().innerHTML = `${formatArea(areaM2)}
${formatDistance(radiusM)} (반경)`; + }); } /** - * 거리환 측정 설정 (Circle + Line 이중 Draw) - * 참조: mda-react-front measure.ts getCircleMeasureInteraction + * 거리환 측정 설정 (Circle + Line + 방위각) */ -export function setupRangeRingMeasure(session: MeasureSession, source: VectorSource): void { - // Line Draw (반경 거리 표시) - const lineTooltip = session.createTooltip(); - const lineDraw = new Draw({ source, type: 'LineString' }); +export function setupRangeRingMeasure(session: MeasureSession): void { + let center: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; - lineDraw.on('drawstart', (evt: DrawEvent) => { - session.map.addOverlay(lineTooltip); - const geom = evt.feature.getGeometry()!; + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; - const key = geom.on('change', (e) => { - const target = e.target as LineString; - const length = getLength(target); - const el = lineTooltip.getElement(); - if (el) { - el.innerHTML = formatDistance(length); + if (!center) { + center = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildCircleFC(center, coord)); + + const distM = turf.distance(turf.point(center), turf.point(coord), { units: 'meters' }); + const bearing = calculateBearing(center, coord); + + const html = `${formatDistance(distM)} 각도: ${bearing}°`; + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, html, 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(center, html, 'measure-tooltip measure-tooltip-static'); } - lineTooltip.setPosition(target.getLastCoordinate()); - }); - session.addListener(key); - }); - // Circle Draw (각도 표시) - const circleDraw = new Draw({ source, type: 'Circle' }); - let circleTooltip: Overlay | null = null; - let degree = '0.0'; - - circleDraw.on('drawstart', (evt: DrawEvent) => { - circleTooltip = session.createTooltip(); - - const geom = evt.feature.getGeometry()!; - const key = geom.on('change', () => { - // sketchCoords_: [center, edge] — OL Draw 내부 좌표 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const coords = (evt.target as any).sketchCoords_ as number[][] | undefined; - if (coords && coords[0] && coords[1]) { - degree = getCircleDegree(coords[0], coords[1]); - } - const el = circleTooltip!.getElement(); - if (el) { - el.innerHTML = `각도: ${degree}°`; - } - circleTooltip!.setPosition((geom as import('ol/geom/Circle').default).getCenter()); - }); - session.addListener(key); - }); - - circleDraw.on('drawend', () => { - lineDraw.finishDrawing(); - - if (circleTooltip) { - const el = circleTooltip.getElement(); - if (el) { - el.className = 'ol-tooltip ol-tooltip-static'; - // 최종 툴팁: 거리 + 각도 - const lineEl = lineTooltip.getElement(); - el.innerHTML = (lineEl?.innerHTML ?? '') + ` 각도: ${degree}°`; - } - circleTooltip.setOffset([0, -7]); + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildCircleFC(center, coord).features); + center = null; + mainTooltip = null; } - - session.map.removeOverlay(lineTooltip); }); - // circle → line 순서로 인터랙션 등록 (OL 이벤트 처리 순서) - session.addInteraction(circleDraw); - session.addInteraction(lineDraw); + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!center) return; + const edge: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + session.setData(buildCircleFC(center, edge)); + + const distM = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' }); + const bearing = calculateBearing(center, edge); + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(center, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(center); + mainTooltip.getElement().innerHTML = `${formatDistance(distM)} 각도: ${bearing}°`; + }); } diff --git a/src/map/measure/useMeasure.ts b/src/map/measure/useMeasure.ts index a110886f..80c80cda 100644 --- a/src/map/measure/useMeasure.ts +++ b/src/map/measure/useMeasure.ts @@ -1,19 +1,22 @@ /** * 측정 도구 React 훅 - * - Zustand 상태(activeMeasureTool, areaShape) ↔ OL 인터랙션 연결 + * - Zustand 상태(activeMeasureTool, areaShape) ↔ MapLibre 측정 세션 연결 * - ESC 키로 측정 취소 - * - 도구 전환 시 이전 세션 자동 정리 + * - 도구 전환 시 세션 유지 (핸들러만 교체), ESC/초기화 시 전체 정리 */ import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; import { useMapStore } from '../../stores/mapStore'; import { MeasureSession, setupDistanceMeasure, setupAreaMeasure, setupRangeRingMeasure, + type AreaMeasureShape, } from './measure'; export default function useMeasure(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any const map = useMapStore((s) => s.map); const activeTool = useMapStore((s) => s.activeMeasureTool); const areaShape = useMapStore((s) => s.areaShape); @@ -31,50 +34,60 @@ export default function useMeasure(): void { return () => document.removeEventListener('keydown', handleKeyDown); }, [activeTool, clearMeasure]); - // 도구 활성화/비활성화 + // 컴포넌트 언마운트 시 세션 정리 useEffect(() => { - // 이전 세션 정리 - if (sessionRef.current) { - sessionRef.current.dispose(); - sessionRef.current = null; - } - - if (!map || !activeTool) return; - - // MapLibre 전환 후 OL 측정 도구 호환 불가 — Session F에서 마이그레이션 - if (typeof map.getCanvas === 'function') { - console.warn('[useMeasure] MapLibre 맵 감지 — OL 측정 도구 비활성화 (Session F에서 마이그레이션)'); - return; - } - - // 면적 도구: 도형 선택 전까지 대기 - if (activeTool === 'area' && !areaShape) return; - - const session = new MeasureSession(map); - const source = session.createLayer(); - sessionRef.current = session; - - switch (activeTool) { - case 'distance': - setupDistanceMeasure(session, source); - break; - case 'area': - // mapStore AreaShape → measure AreaMeasureShape 직접 전달 - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- store 타입과 measure 타입 불일치, 런타임 값은 호환 - setupAreaMeasure(session, source, areaShape as any); - break; - case 'rangeRing': - setupRangeRingMeasure(session, source); - break; - default: - break; - } - return () => { if (sessionRef.current) { sessionRef.current.dispose(); sessionRef.current = null; } }; + }, []); + + // 도구 활성화 / 전환 / 비활성화 + useEffect(() => { + // 이전 도구의 핸들러 + 진행 중 마커 제거 (세션·확정 마커 유지) + if (sessionRef.current) { + sessionRef.current.clearHandlers(); + sessionRef.current.removeActiveMarkers(); + } + + // 비활성화 (ESC / 초기화) → 세션 전체 dispose + if (!activeTool) { + if (sessionRef.current) { + sessionRef.current.dispose(); + sessionRef.current = null; + } + return; + } + + if (!map || typeof map.getCanvas !== 'function') return; + + // 면적 도구: 도형 선택 전까지 대기 + if (activeTool === 'area' && !areaShape) return; + + // 세션 생성 또는 재사용 + if (!sessionRef.current) { + sessionRef.current = new MeasureSession(map as maplibregl.Map); + } else { + (map as maplibregl.Map).getCanvas().style.cursor = 'crosshair'; + } + + const session = sessionRef.current; + + switch (activeTool) { + case 'distance': + setupDistanceMeasure(session); + break; + case 'area': + setupAreaMeasure(session, areaShape as AreaMeasureShape); + break; + case 'rangeRing': + setupRangeRingMeasure(session); + break; + default: + break; + } + // cleanup 없음 — 다음 실행 시작부에서 clearHandlers로 처리 }, [map, activeTool, areaShape]); } diff --git a/src/stores/mapStore.ts b/src/stores/mapStore.ts index f7557760..bf947ee1 100644 --- a/src/stores/mapStore.ts +++ b/src/stores/mapStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +import type maplibregl from 'maplibre-gl'; /** * 배경지도 타입 @@ -76,11 +77,9 @@ interface LayerVisibility { } interface MapStoreState { - // 지도 인스턴스 - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OL→MapLibre 마이그레이션 기간 any 사용 (Session H에서 maplibregl.Map으로 복원) - map: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - setMap: (map: any) => void; + // 지도 인스턴스 (MapLibre GL JS) + map: maplibregl.Map | null; + setMap: (map: maplibregl.Map | null) => void; // 배경지도 타입 baseMapType: BaseMapType; diff --git a/src/tracking/utils/resetTrackQuery.ts b/src/tracking/utils/resetTrackQuery.ts index 00775947..f0f266e9 100644 --- a/src/tracking/utils/resetTrackQuery.ts +++ b/src/tracking/utils/resetTrackQuery.ts @@ -7,14 +7,14 @@ import { useTrackQueryStore } from '../stores/trackQueryStore'; import { unregisterTrackQueryLayers } from './trackQueryLayerUtils'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; -import type Map from 'ol/Map'; +import type maplibregl from 'maplibre-gl'; /** * 항적조회 상태 및 레이어 초기화 * - * @param map OpenLayers Map 인스턴스 + * @param map MapLibre GL JS Map 인스턴스 */ -export const resetTrackQuery = (map?: Map): void => { +export const resetTrackQuery = (map?: maplibregl.Map): void => { // 스토어 초기화 useTrackQueryStore.getState().reset(); diff --git a/src/utils/csvDownload.ts b/src/utils/csvDownload.ts index 7f6e1af0..c198f140 100644 --- a/src/utils/csvDownload.ts +++ b/src/utils/csvDownload.ts @@ -2,7 +2,7 @@ * CSV 다운로드 유틸리티 * 참조: mda-react-front/src/widgets/rightMenu/ui/RightMenu.tsx (512-579) */ -import { Polygon } from 'ol/geom'; +import * as turf from '@turf/turf'; import { shipTypeMap } from '../assets/data/shiptype'; import { SHIP_KIND_LABELS, SIGNAL_SOURCE_LABELS } from '../types/constants'; @@ -24,7 +24,7 @@ interface DownloadShip { /** 해구도 캐시 엔트리 */ interface TrenchEntry { zoneName: string; - polygon: Polygon; + polygon: number[][][]; // GeoJSON Polygon coordinates } /** GeoJSON Feature 형태 (largeTrench.json) */ @@ -45,7 +45,7 @@ async function loadTrenchData(): Promise { const geojson = data.default || data; trenchCache = (geojson as { features: TrenchFeature[] }).features.map((f: TrenchFeature) => ({ zoneName: f.properties.zone_name, - polygon: new Polygon(f.geometry.coordinates), + polygon: f.geometry.coordinates, })); return trenchCache; } @@ -69,7 +69,8 @@ async function lookupTrenchNumbers(ships: DownloadShip[]): Promise