493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
"use client";
|
|
import { beginMapNavigation } from "@/features/map-search/lib/map-navigation";
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import Feature from "ol/Feature.js";
|
|
import GeoJSON from "ol/format/GeoJSON.js";
|
|
import VectorLayer from "ol/layer/Vector.js";
|
|
import VectorSource from "ol/source/Vector.js";
|
|
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/window-popup/model/types";
|
|
import {
|
|
EMPTY_WATER_RISK_SESSION_SNAPSHOT,
|
|
type WaterRiskMapView,
|
|
type WaterRiskResultRow,
|
|
type WaterRiskSessionSnapshot,
|
|
} from "../components/water-risk/water-risk-types";
|
|
import {
|
|
WATER_RISK_AREA_LAYER_ID,
|
|
WATER_RISK_PIPE_LAYER_ID,
|
|
WATER_RISK_MANHOLE_LAYER_ID,
|
|
} from "../model/map-constants";
|
|
import {
|
|
createWaterRiskAreaStyle,
|
|
createWaterRiskPipeStyle,
|
|
createWaterRiskManholeStyle,
|
|
} from "./water-risk-styles";
|
|
import type { MutableRefObject } from "react";
|
|
|
|
type Options = {
|
|
gisMaps: MutableRefObject<any>;
|
|
mapReady: boolean;
|
|
onSelectResult: () => void;
|
|
};
|
|
|
|
/** 통수위험 결과 레이어·정보 카드·팝업 수명을 소유한다. 부모 화면 선택 정리는 명령 callback으로 요청한다. */
|
|
export function useWaterRiskLayer({
|
|
gisMaps,
|
|
mapReady,
|
|
onSelectResult,
|
|
}: Options) {
|
|
/** 팝업의 조회 시작부터 지도 이동까지 같은 요청 세대를 사용한다. */
|
|
const selectionNavigationRef = useRef<ReturnType<typeof beginMapNavigation> | null>(null);
|
|
const selectionRequestRef = useRef<string | undefined>(undefined);
|
|
const waterRiskPopupWindowRef = useRef<Window | null>(null);
|
|
|
|
const waterRiskPopupCloseWatcherRef = useRef<number | null>(null);
|
|
|
|
const waterRiskAreaLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
|
|
const waterRiskAreaSourceRef = useRef<VectorSource | null>(null);
|
|
|
|
const waterRiskPipeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
|
|
const waterRiskPipeSourceRef = useRef<VectorSource | null>(null);
|
|
|
|
const waterRiskManholeLayerRef = useRef<VectorLayer<any> | null>(null);
|
|
|
|
const waterRiskManholeSourceRef = useRef<VectorSource | null>(null);
|
|
|
|
const [waterRiskSession, setWaterRiskSession] =
|
|
useState<WaterRiskSessionSnapshot>(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
|
|
|
|
// 팝업에서 실제 API로 조회한 결과 행과 GeoJSON을 지도·정보카드의 단일 원본으로 보관한다.
|
|
const [selectedWaterRiskApiResult, setSelectedWaterRiskApiResult] =
|
|
useState<WaterRiskResultRow | null>(null);
|
|
|
|
const [selectedWaterRiskMapView, setSelectedWaterRiskMapView] =
|
|
useState<WaterRiskMapView | null>(null);
|
|
|
|
const [waterRiskInfoAnchor, setWaterRiskInfoAnchor] = useState<
|
|
[number, number] | null
|
|
>(null);
|
|
|
|
const [waterRiskInfoCardStyle, setWaterRiskInfoCardStyle] = useState<Record<
|
|
string,
|
|
number
|
|
> | null>(null);
|
|
|
|
const waterRiskSessionSnapshot = useMemo<WaterRiskSessionSnapshot>(
|
|
() => waterRiskSession,
|
|
[waterRiskSession],
|
|
);
|
|
|
|
const syncWaterRiskPopup = useCallback(() => {
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (!popup || popup.closed) {
|
|
waterRiskPopupWindowRef.current = null;
|
|
return;
|
|
}
|
|
|
|
popup.postMessage(
|
|
{
|
|
type: WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SYNC,
|
|
payload: waterRiskSessionSnapshot,
|
|
},
|
|
window.location.origin,
|
|
);
|
|
}, [waterRiskSessionSnapshot]);
|
|
|
|
const selectedWaterRiskResultRow = useMemo(
|
|
() => selectedWaterRiskApiResult,
|
|
[selectedWaterRiskApiResult],
|
|
);
|
|
|
|
// 지도카드는 mapView 위험상태를 우선하고, mapView 응답 전에는 목록 상태를 쓴다.
|
|
// 2026-10-20부터 mapView 위험상태는 목록과 같은 대표결과 판정값이므로 두 값이 다르지 않다.
|
|
const selectedWaterRiskDisplayStatus = useMemo<
|
|
WaterRiskResultRow["riskStatus"]
|
|
>(
|
|
() =>
|
|
selectedWaterRiskMapView?.riskStatus ??
|
|
selectedWaterRiskResultRow?.riskStatus ??
|
|
"NORMAL",
|
|
[
|
|
selectedWaterRiskMapView?.riskStatus,
|
|
selectedWaterRiskResultRow?.riskStatus,
|
|
],
|
|
);
|
|
|
|
/** 선택 영역 오른쪽에 중점관리지역 강우·침수 요약카드를 배치한다. */
|
|
const syncWaterRiskInfoCardPosition = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map || !waterRiskInfoAnchor) {
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
const [mapWidth = 0, mapHeight = 0] = map.getSize?.() ?? [];
|
|
const [pixelX = 0, pixelY = 0] =
|
|
map.getPixelFromCoordinate?.(waterRiskInfoAnchor) ?? [];
|
|
const cardWidth = 244;
|
|
const cardHeight = 190;
|
|
const left = pixelX + 22;
|
|
const top = pixelY - cardHeight / 2;
|
|
|
|
if (
|
|
pixelX < 0 ||
|
|
pixelY < 0 ||
|
|
pixelX > mapWidth ||
|
|
pixelY > mapHeight ||
|
|
left > mapWidth ||
|
|
top > mapHeight ||
|
|
left + cardWidth < 0 ||
|
|
top + cardHeight < 0
|
|
) {
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
setWaterRiskInfoCardStyle({ left, top });
|
|
}, [waterRiskInfoAnchor]);
|
|
|
|
/** 정보 카드만 닫고 지도 결과·범례는 유지하며, 다음 결과 선택 시 다시 표시한다. */
|
|
const dismissWaterRiskInfoCard = useCallback(() => {
|
|
setWaterRiskInfoAnchor(null);
|
|
setWaterRiskInfoCardStyle(null);
|
|
}, []);
|
|
|
|
const clearWaterRiskPopupCloseWatcher = useCallback(() => {
|
|
if (waterRiskPopupCloseWatcherRef.current !== null) {
|
|
window.clearInterval(waterRiskPopupCloseWatcherRef.current);
|
|
waterRiskPopupCloseWatcherRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const ensureWaterRiskAreaLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskAreaLayerRef.current && waterRiskAreaSourceRef.current) {
|
|
const layers = map.getLayers?.().getArray?.() ?? [];
|
|
if (!layers.includes(waterRiskAreaLayerRef.current)) {
|
|
map.addLayer(waterRiskAreaLayerRef.current);
|
|
}
|
|
waterRiskAreaLayerRef.current.setZIndex?.(9996);
|
|
return waterRiskAreaSourceRef.current;
|
|
}
|
|
|
|
// 통수위험 overlay 초기화는 자신의 stale layer만 정리해 GIS 편집 preview를 보존한다.
|
|
const layers = map.getLayers?.().getArray?.() ?? [];
|
|
layers.forEach((existingLayer: any) => {
|
|
if (existingLayer?.get?.("id") === WATER_RISK_AREA_LAYER_ID) {
|
|
map.removeLayer(existingLayer);
|
|
}
|
|
});
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9996,
|
|
});
|
|
|
|
layer.set("id", WATER_RISK_AREA_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 중점관리지역");
|
|
layer.set("lyrTyNm", WATER_RISK_AREA_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskAreaLayerRef.current = layer;
|
|
waterRiskAreaSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const ensureWaterRiskPipeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskPipeLayerRef.current && waterRiskPipeSourceRef.current) {
|
|
return waterRiskPipeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({
|
|
source,
|
|
zIndex: 9998,
|
|
});
|
|
|
|
layer.set("id", WATER_RISK_PIPE_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 유출관로");
|
|
layer.set("lyrTyNm", WATER_RISK_PIPE_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskPipeLayerRef.current = layer;
|
|
waterRiskPipeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const ensureWaterRiskManholeLayer = useCallback(() => {
|
|
const map = gisMaps.current;
|
|
if (!map) return null;
|
|
|
|
if (waterRiskManholeLayerRef.current && waterRiskManholeSourceRef.current) {
|
|
return waterRiskManholeSourceRef.current;
|
|
}
|
|
|
|
const source = new VectorSource();
|
|
const layer = new VectorLayer({ source, zIndex: 9999 });
|
|
layer.set("id", WATER_RISK_MANHOLE_LAYER_ID);
|
|
layer.set("lyrNm", "통수위험 맨홀 Flooding");
|
|
layer.set("lyrTyNm", WATER_RISK_MANHOLE_LAYER_ID);
|
|
map.addLayer(layer);
|
|
waterRiskManholeLayerRef.current = layer;
|
|
waterRiskManholeSourceRef.current = source;
|
|
return source;
|
|
}, []);
|
|
|
|
const clearWaterRiskLayers = useCallback(() => {
|
|
waterRiskAreaSourceRef.current?.clear();
|
|
waterRiskPipeSourceRef.current?.clear();
|
|
waterRiskManholeSourceRef.current?.clear();
|
|
}, []);
|
|
|
|
/** 통수위험 팝업 종료 시 지도 강조·범례·정보카드 선택상태를 모두 초기화한다. */
|
|
const clearWaterRiskSelection = useCallback(() => {
|
|
clearWaterRiskLayers();
|
|
setWaterRiskSession(EMPTY_WATER_RISK_SESSION_SNAPSHOT);
|
|
setSelectedWaterRiskApiResult(null);
|
|
setSelectedWaterRiskMapView(null);
|
|
setWaterRiskInfoAnchor(null);
|
|
setWaterRiskInfoCardStyle(null);
|
|
}, [clearWaterRiskLayers]);
|
|
|
|
const renderWaterRiskSelection = useCallback(
|
|
(areaId: string | null, mapView: WaterRiskMapView | null) => {
|
|
const map = gisMaps.current;
|
|
if (!map || !selectionNavigationRef.current?.isCurrent()) return;
|
|
|
|
const areaSource = ensureWaterRiskAreaLayer();
|
|
if (!areaSource) return;
|
|
|
|
areaSource.clear();
|
|
const pipeSource = ensureWaterRiskPipeLayer();
|
|
pipeSource?.clear();
|
|
const manholeSource = ensureWaterRiskManholeLayer();
|
|
manholeSource?.clear();
|
|
|
|
if (!areaId || !mapView || mapView.areaId !== areaId) return;
|
|
|
|
const geoJson = new GeoJSON();
|
|
const projection = mapView.projection || "EPSG:5179";
|
|
let focusGeometry: any | null = null;
|
|
|
|
if (mapView.areaGeometry) {
|
|
const geometry = geoJson.readGeometry(mapView.areaGeometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const areaFeature = new Feature({
|
|
geometry,
|
|
areaId: mapView.areaId,
|
|
areaName: mapView.areaName,
|
|
riskStatus: mapView.riskStatus,
|
|
});
|
|
areaFeature.setStyle(createWaterRiskAreaStyle(areaFeature));
|
|
areaSource.addFeature(areaFeature);
|
|
focusGeometry = geometry;
|
|
}
|
|
|
|
mapView.pipes.forEach((pipe) => {
|
|
if (!pipeSource || !pipe.geometry) return;
|
|
const geometry = geoJson.readGeometry(pipe.geometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const pipeFeature = new Feature({
|
|
geometry,
|
|
pipeId: pipe.pipeId,
|
|
displayLabel: pipe.displayLabel || pipe.pipeId,
|
|
capacityRatio: pipe.capacityRatio ?? 0,
|
|
});
|
|
pipeFeature.setStyle(createWaterRiskPipeStyle(pipeFeature));
|
|
pipeSource.addFeature(pipeFeature);
|
|
if (!focusGeometry) focusGeometry = geometry;
|
|
});
|
|
|
|
mapView.manholes.forEach((manhole) => {
|
|
if (!manholeSource || !manhole.geometry) return;
|
|
const geometry = geoJson.readGeometry(manhole.geometry as any, {
|
|
dataProjection: projection,
|
|
featureProjection: "EPSG:5179",
|
|
});
|
|
const manholeFeature = new Feature({
|
|
geometry,
|
|
manholeId: manhole.manholeId,
|
|
displayLabel: manhole.displayLabel || manhole.manholeId,
|
|
floodingFlow: manhole.floodingFlow ?? 0,
|
|
});
|
|
manholeFeature.setStyle(createWaterRiskManholeStyle(manholeFeature));
|
|
manholeSource.addFeature(manholeFeature);
|
|
if (!focusGeometry) focusGeometry = geometry;
|
|
});
|
|
|
|
if (!focusGeometry) return;
|
|
const extent = focusGeometry.getExtent();
|
|
const [, minY = 0, maxX = 0, maxY = 0] = extent;
|
|
setWaterRiskInfoAnchor([maxX, (minY + maxY) / 2]);
|
|
if (!selectionNavigationRef.current?.commit(extent)) return;
|
|
map.getView().fit(extent, {
|
|
duration: 300,
|
|
padding: [120, 120, 120, 120],
|
|
maxZoom: 15,
|
|
constrainResolution: true,
|
|
nearest: true,
|
|
});
|
|
},
|
|
[
|
|
ensureWaterRiskAreaLayer,
|
|
ensureWaterRiskManholeLayer,
|
|
ensureWaterRiskPipeLayer,
|
|
],
|
|
);
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current) return;
|
|
if (!waterRiskSession.selectedAreaId) {
|
|
clearWaterRiskLayers();
|
|
setWaterRiskInfoAnchor(null);
|
|
setWaterRiskInfoCardStyle(null);
|
|
return;
|
|
}
|
|
|
|
renderWaterRiskSelection(
|
|
waterRiskSession.selectedAreaId,
|
|
selectedWaterRiskMapView,
|
|
);
|
|
}, [
|
|
clearWaterRiskLayers,
|
|
mapReady,
|
|
renderWaterRiskSelection,
|
|
selectedWaterRiskMapView,
|
|
waterRiskSession.selectedAreaId,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
if (!selectedWaterRiskMapView) setWaterRiskInfoAnchor(null);
|
|
}, [selectedWaterRiskMapView]);
|
|
|
|
useEffect(() => {
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (!popup || popup.closed) return;
|
|
syncWaterRiskPopup();
|
|
}, [syncWaterRiskPopup, waterRiskSession]);
|
|
|
|
useEffect(() => {
|
|
if (!mapReady || !gisMaps.current || !waterRiskInfoAnchor) return;
|
|
|
|
const map = gisMaps.current;
|
|
const syncDuringMapRender = () => syncWaterRiskInfoCardPosition();
|
|
syncWaterRiskInfoCardPosition();
|
|
map.on?.("postrender", syncDuringMapRender);
|
|
map.on?.("moveend", syncDuringMapRender);
|
|
|
|
return () => {
|
|
map.un?.("postrender", syncDuringMapRender);
|
|
map.un?.("moveend", syncDuringMapRender);
|
|
};
|
|
}, [mapReady, syncWaterRiskInfoCardPosition, waterRiskInfoAnchor]);
|
|
|
|
useEffect(() => {
|
|
const handleWaterRiskPopupMessage = (event: MessageEvent) => {
|
|
if (event.origin !== window.location.origin) return;
|
|
|
|
const data = event.data;
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_READY) {
|
|
syncWaterRiskPopup();
|
|
return;
|
|
}
|
|
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_SELECT_RESULT) {
|
|
if (data.payload?.phase === "loading") {
|
|
selectionRequestRef.current = data.payload.requestId;
|
|
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
|
|
return;
|
|
}
|
|
if (data.payload?.requestId) {
|
|
if (data.payload.requestId !== selectionRequestRef.current ||
|
|
!selectionNavigationRef.current?.isCurrent()) return;
|
|
} else {
|
|
selectionNavigationRef.current = beginMapNavigation(gisMaps.current);
|
|
}
|
|
const areaId = String(data.payload?.areaId ?? "").trim();
|
|
if (!areaId) return;
|
|
const resultRow = data.payload?.resultRow as
|
|
| WaterRiskResultRow
|
|
| undefined;
|
|
const mapView = data.payload?.mapView as WaterRiskMapView | undefined;
|
|
if (!resultRow || !mapView || mapView.areaId !== areaId) return;
|
|
// 지도 응답이 누른 행과 다른 실행이면 표시하지 않는다(실행 ID 기준 조회의 안전장치).
|
|
if (
|
|
mapView.executionId &&
|
|
resultRow.executionId &&
|
|
mapView.executionId !== resultRow.executionId
|
|
) {
|
|
return;
|
|
}
|
|
setSelectedWaterRiskApiResult(resultRow);
|
|
setSelectedWaterRiskMapView(mapView);
|
|
setWaterRiskSession((current) => ({
|
|
...current,
|
|
selectedAreaId: areaId,
|
|
selectedExecutionId: resultRow.executionId || null,
|
|
}));
|
|
onSelectResult();
|
|
return;
|
|
}
|
|
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.WATER_RISK_CLOSED) {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = null;
|
|
clearWaterRiskSelection();
|
|
return;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("message", handleWaterRiskPopupMessage);
|
|
return () => {
|
|
window.removeEventListener("message", handleWaterRiskPopupMessage);
|
|
};
|
|
}, [
|
|
clearWaterRiskPopupCloseWatcher,
|
|
clearWaterRiskSelection,
|
|
onSelectResult,
|
|
syncWaterRiskPopup,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
const popup = waterRiskPopupWindowRef.current;
|
|
if (popup && !popup.closed) {
|
|
popup.close();
|
|
}
|
|
waterRiskPopupWindowRef.current = null;
|
|
};
|
|
}, [clearWaterRiskPopupCloseWatcher]);
|
|
const watchPopup = useCallback(
|
|
(popup: Window) => {
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = popup;
|
|
waterRiskPopupCloseWatcherRef.current = window.setInterval(() => {
|
|
if (!popup.closed) return;
|
|
clearWaterRiskPopupCloseWatcher();
|
|
waterRiskPopupWindowRef.current = null;
|
|
clearWaterRiskSelection();
|
|
}, 300);
|
|
},
|
|
[clearWaterRiskPopupCloseWatcher, clearWaterRiskSelection],
|
|
);
|
|
useEffect(() => () => clearWaterRiskLayers(), [clearWaterRiskLayers]);
|
|
return {
|
|
selectedWaterRiskResultRow,
|
|
waterRiskInfoCardStyle,
|
|
selectedWaterRiskMapView,
|
|
selectedWaterRiskDisplayStatus,
|
|
dismissWaterRiskInfoCard,
|
|
watchPopup,
|
|
};
|
|
}
|