Files
frontend/apps/sn/app/(map-layout)/map/lib/use-water-risk-layer.ts
T

483 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],
);
// 지도카드는 실제 GIS geometry까지 결합된 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;
setSelectedWaterRiskApiResult(resultRow);
setSelectedWaterRiskMapView(mapView);
setWaterRiskSession((current) => ({
...current,
selectedAreaId: areaId,
}));
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,
};
}