release: Phase 3 완료 (React 19 + MapLibre GL JS 전환) #2
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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",
|
||||
|
||||
@ -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 });
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 관심구역 폴리곤
|
||||
*/
|
||||
function buildZoneGeoJSON(zones: Zone[]): FeatureCollection<Polygon> {
|
||||
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];
|
||||
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);
|
||||
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 }),
|
||||
properties: {
|
||||
fillColor: `rgba(${color.fill.join(',')})`,
|
||||
outlineColor: `rgba(${color.stroke.join(',')})`,
|
||||
labelText: `${zone.name}구역`,
|
||||
labelColor: color.label || '#fff',
|
||||
},
|
||||
};
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
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,
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 2개 항적 LineString
|
||||
*/
|
||||
function buildTrackGeoJSON(tracks: ProcessedTrack[]): FeatureCollection<LineString> {
|
||||
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,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}));
|
||||
return feature;
|
||||
};
|
||||
}
|
||||
|
||||
function createContactMarkers(contacts: StsContact[]): Feature[] {
|
||||
const features: Feature[] = [];
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 접촉 중심 마커
|
||||
*/
|
||||
function buildContactGeoJSON(contacts: StsContact[]): FeatureCollection<Point> {
|
||||
const features: Feature<Point>[] = [];
|
||||
|
||||
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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<OlMap | null>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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();
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [group, vessel1Track, vessel2Track, zones]);
|
||||
|
||||
|
||||
@ -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 });
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 관심구역 폴리곤
|
||||
*/
|
||||
function buildZoneGeoJSON(zones: Zone[]): FeatureCollection<Polygon> {
|
||||
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];
|
||||
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);
|
||||
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 }),
|
||||
properties: {
|
||||
fillColor: `rgba(${color.fill.join(',')})`,
|
||||
outlineColor: `rgba(${color.stroke.join(',')})`,
|
||||
labelText: `${zone.name}구역`,
|
||||
labelColor: color.label || '#fff',
|
||||
},
|
||||
};
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
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,
|
||||
}),
|
||||
}));
|
||||
return feature;
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 항적 LineString
|
||||
*/
|
||||
function buildTrackGeoJSON(track: ProcessedTrack): FeatureCollection<LineString> {
|
||||
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,
|
||||
},
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
function createMarkerFeatures(sortedHits: HitDetail[]): Feature[] {
|
||||
const features: Feature[] = [];
|
||||
/**
|
||||
* MapLibre GeoJSON 빌더: 입퇴점 마커 (IN/OUT 분리)
|
||||
*/
|
||||
function buildMarkerGeoJSON(sortedHits: HitDetail[]): {
|
||||
inPoints: FeatureCollection<Point>;
|
||||
outPoints: FeatureCollection<Point>;
|
||||
} {
|
||||
const inFeatures: Feature<Point>[] = [];
|
||||
const outFeatures: Feature<Point>[] = [];
|
||||
|
||||
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<number, MarkerItem[]> = {};
|
||||
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<HTMLDivElement>(null);
|
||||
const mapRef = useRef<OlMap | null>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(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();
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove();
|
||||
mapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [track, zones, sortedHits, zoneMap]);
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스)
|
||||
/**
|
||||
* 구역 그리기 OpenLayers Draw 인터랙션 훅
|
||||
*
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스)
|
||||
/**
|
||||
* 구역 편집 인터랙션 훅
|
||||
*
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스)
|
||||
/**
|
||||
* 사각형(Box) 리사이즈 커스텀 인터랙션
|
||||
*
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스)
|
||||
/**
|
||||
* 원(Circle) 리사이즈 커스텀 인터랙션
|
||||
*
|
||||
|
||||
@ -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<Geometry>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- OL Feature (기능 미사용, Session G 패스)
|
||||
olFeature?: any;
|
||||
circleMeta: CircleMeta | null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스)
|
||||
/**
|
||||
* 구역 VectorSource/VectorLayer 모듈 스코프 참조
|
||||
* useZoneDraw와 useZoneEdit 간 공유
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -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]);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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<TrenchEntry[]> {
|
||||
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<Map<number, s
|
||||
|
||||
let found = false;
|
||||
for (const { zoneName, polygon } of trenchData) {
|
||||
if (polygon.intersectsCoordinate([lon, lat])) {
|
||||
// Turf.js booleanPointInPolygon으로 좌표 검사
|
||||
if (turf.booleanPointInPolygon([lon, lat], turf.polygon(polygon))) {
|
||||
result.set(idx, zoneName);
|
||||
found = true;
|
||||
break;
|
||||
|
||||
106
yarn.lock
106
yarn.lock
@ -998,11 +998,6 @@
|
||||
"@parcel/watcher-win32-ia32" "2.5.6"
|
||||
"@parcel/watcher-win32-x64" "2.5.6"
|
||||
|
||||
"@petamoriken/float16@^3.4.7":
|
||||
version "3.9.3"
|
||||
resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.9.3.tgz#84acef4816db7e4c2fe1c4e8cf902bcbc0440ac3"
|
||||
integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==
|
||||
|
||||
"@probe.gl/env@4.1.0", "@probe.gl/env@^4.0.8", "@probe.gl/env@^4.1.0":
|
||||
version "4.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@probe.gl/env/-/env-4.1.0.tgz#c2af9030a8711f2d98590850aa47a5f58feef211"
|
||||
@ -3080,36 +3075,11 @@ color-convert@^2.0.1:
|
||||
dependencies:
|
||||
color-name "~1.1.4"
|
||||
|
||||
color-name@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693"
|
||||
integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==
|
||||
|
||||
color-name@~1.1.4:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
color-parse@^2.0.0:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/color-parse/-/color-parse-2.0.2.tgz#37b46930424924060988edf25b24e6ffb4a1dc3f"
|
||||
integrity sha512-eCtOz5w5ttWIUcaKLiktF+DxZO1R9KLNY/xhbV6CkhM7sR3GhVghmt6X6yOnzeaM24po+Z9/S1apbXMwA3Iepw==
|
||||
dependencies:
|
||||
color-name "^2.0.0"
|
||||
|
||||
color-rgba@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-3.0.0.tgz#77090bdcdb2951c1735e20099ddd50401675375b"
|
||||
integrity sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg==
|
||||
dependencies:
|
||||
color-parse "^2.0.0"
|
||||
color-space "^2.0.0"
|
||||
|
||||
color-space@^2.0.0, color-space@^2.0.1:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/color-space/-/color-space-2.3.2.tgz#d8c72bab09ef26b98abebc58bc1586ce3073033d"
|
||||
integrity sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA==
|
||||
|
||||
combined-stream@^1.0.8:
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
|
||||
@ -3246,7 +3216,7 @@ dunder-proto@^1.0.1:
|
||||
es-errors "^1.3.0"
|
||||
gopd "^1.2.0"
|
||||
|
||||
earcut@^2.2.3, earcut@^2.2.4:
|
||||
earcut@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a"
|
||||
integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==
|
||||
@ -3549,20 +3519,6 @@ geokdbush@^2.0.1:
|
||||
dependencies:
|
||||
tinyqueue "^2.0.3"
|
||||
|
||||
geotiff@^2.0.7:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/geotiff/-/geotiff-2.1.3.tgz#993f40f2aa6aa65fb1e0451d86dd22ca8e66910c"
|
||||
integrity sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==
|
||||
dependencies:
|
||||
"@petamoriken/float16" "^3.4.7"
|
||||
lerc "^3.0.0"
|
||||
pako "^2.0.4"
|
||||
parse-headers "^2.0.2"
|
||||
quick-lru "^6.1.1"
|
||||
web-worker "^1.2.0"
|
||||
xml-utils "^1.0.2"
|
||||
zstddec "^0.1.0"
|
||||
|
||||
get-intrinsic@^1.2.6:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
|
||||
@ -3822,11 +3778,6 @@ ktx-parse@^0.7.0:
|
||||
resolved "https://registry.yarnpkg.com/ktx-parse/-/ktx-parse-0.7.1.tgz#d41514256d7d63acb8ef6ae62dc66f16efc1c39c"
|
||||
integrity sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ==
|
||||
|
||||
lerc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/lerc/-/lerc-3.0.0.tgz#36f36fbd4ba46f0abf4833799fff2e7d6865f5cb"
|
||||
integrity sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==
|
||||
|
||||
levn@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||
@ -3989,23 +3940,6 @@ node-releases@^2.0.27:
|
||||
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e"
|
||||
integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==
|
||||
|
||||
ol-ext@^4.0.10:
|
||||
version "4.0.37"
|
||||
resolved "https://registry.yarnpkg.com/ol-ext/-/ol-ext-4.0.37.tgz#8da5c4097322e56f99b45537ca353c242d1c9b88"
|
||||
integrity sha512-RxzdgMWnNBDP9VZCza3oS3rl1+OCl+1SJLMjt7ATyDDLZl/zzrsQELfJ25WAL6HIWgjkQ2vYDh3nnHFupxOH4w==
|
||||
|
||||
ol@^9.2.4:
|
||||
version "9.2.4"
|
||||
resolved "https://registry.yarnpkg.com/ol/-/ol-9.2.4.tgz#07dcefdceb66ddbde13089bca136f4d4852b772b"
|
||||
integrity sha512-bsbu4ObaAlbELMIZWnYEvX4Z9jO+OyCBshtODhDKmqYTPEfnKOX3RieCr97tpJkqWTZvyV4tS9UQDvHoCdxS+A==
|
||||
dependencies:
|
||||
color-rgba "^3.0.0"
|
||||
color-space "^2.0.1"
|
||||
earcut "^2.2.3"
|
||||
geotiff "^2.0.7"
|
||||
pbf "3.2.1"
|
||||
rbush "^3.0.1"
|
||||
|
||||
optionator@^0.9.3:
|
||||
version "0.9.4"
|
||||
resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734"
|
||||
@ -4037,11 +3971,6 @@ pako@1.0.11, pako@~1.0.2:
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf"
|
||||
integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==
|
||||
|
||||
pako@^2.0.4:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86"
|
||||
integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==
|
||||
|
||||
parent-module@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
|
||||
@ -4049,11 +3978,6 @@ parent-module@^1.0.0:
|
||||
dependencies:
|
||||
callsites "^3.0.0"
|
||||
|
||||
parse-headers@^2.0.2:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.6.tgz#7940f0abe5fe65df2dd25d4ce8800cb35b49d01c"
|
||||
integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
@ -4064,14 +3988,6 @@ path-key@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
|
||||
|
||||
pbf@3.2.1:
|
||||
version "3.2.1"
|
||||
resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a"
|
||||
integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ==
|
||||
dependencies:
|
||||
ieee754 "^1.1.12"
|
||||
resolve-protobuf-schema "^2.1.0"
|
||||
|
||||
pbf@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.3.0.tgz#1790f3d99118333cc7f498de816028a346ef367f"
|
||||
@ -4156,11 +4072,6 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
quick-lru@^6.1.1:
|
||||
version "6.1.2"
|
||||
resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.2.tgz#e9a90524108629be35287d0b864e7ad6ceb3659e"
|
||||
integrity sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==
|
||||
|
||||
quickselect@^1.0.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-1.1.1.tgz#852e412ce418f237ad5b660d70cffac647ae94c2"
|
||||
@ -4542,11 +4453,6 @@ vite@^7.3.1:
|
||||
optionalDependencies:
|
||||
fsevents "~2.3.3"
|
||||
|
||||
web-worker@^1.2.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5"
|
||||
integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==
|
||||
|
||||
wgsl_reflect@^1.2.0:
|
||||
version "1.2.3"
|
||||
resolved "https://registry.yarnpkg.com/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz#41985a661efdd00047e771ad7aa06ab131926a55"
|
||||
@ -4564,11 +4470,6 @@ word-wrap@^1.2.5:
|
||||
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||
|
||||
xml-utils@^1.0.2:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/xml-utils/-/xml-utils-1.10.2.tgz#436b39ccc25a663ce367ea21abb717afdea5d6b1"
|
||||
integrity sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==
|
||||
|
||||
yallist@^3.0.2:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
|
||||
@ -4594,11 +4495,6 @@ zstd-codec@^0.1:
|
||||
resolved "https://registry.yarnpkg.com/zstd-codec/-/zstd-codec-0.1.5.tgz#c180193e4603ef74ddf704bcc835397d30a60e42"
|
||||
integrity sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g==
|
||||
|
||||
zstddec@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/zstddec/-/zstddec-0.1.0.tgz#7050f3f0e0c3978562d0c566b3e5a427d2bad7ec"
|
||||
integrity sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==
|
||||
|
||||
zustand@^5:
|
||||
version "5.0.11"
|
||||
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494"
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user