Files
frontend/apps/sn/app/(map-layout)/map/components/popupComponent/SwtrmtPrcsZoneAnalysis.tsx
T

392 lines
12 KiB
TypeScript

/**
* 화면명: 처리구역내 관로분석정보
* 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다.
*/
"use client";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableRow,
} from "@common/components";
import { useGisContext } from "@common/business_components/ui/map/Gis";
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 Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import {
fetchSwtrmtPrcsZoneAnalysis,
getSwtrmtPrcsZoneId,
} from "./swtrmtPrcsZoneAnalysisApi";
import type {
RepairHistoryRow,
SwtrmtPrcsZoneAnalysisMetric,
SwtrmtPrcsZoneAnalysisResponse,
} from "./swtrmtPrcsZoneAnalysisApi";
const analysisRows: {
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>;
title: string;
}[] = [
{ key: "total", title: "전체" },
{ key: "combined", title: "합류관" },
{ key: "rain", title: "우수관" },
{ key: "sewage", title: "오수관" },
];
const repairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(0, 229, 255, 0.95)", width: 5 }),
zIndex: 10020,
});
const selectedRepairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(255, 82, 82, 1)", width: 8 }),
zIndex: 10021,
});
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "")
return "데이터 없음";
const numberValue =
typeof value === "number"
? value
: Number(String(value).replaceAll(",", ""));
if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) {
const formatted = numberValue.toLocaleString("ko-KR", {
maximumFractionDigits: 2,
});
return unit ? `${formatted}${unit}` : formatted;
}
return String(value);
};
const getMetric = (
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined,
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>,
): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {};
const metricGroups: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
}[] = [
{ title: "평균경과년도(년)", field: "avgInstlYear" },
{ title: "최장경과년도(년)", field: "oldestInstlYear" },
{ title: "최신경과년도(년)", field: "latestInstlYear" },
];
const MetricVerticalGrid = ({
title,
field,
analysis,
}: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined;
}) => (
<div className="mb-4 last:mb-0">
<div className="mx-3 mb-2 mt-1 text-xl font-medium text-label">{title}</div>
<Table variant="secondary">
<TableBody className="text-center">
{analysisRows.map((row) => {
const metric = getMetric(analysis, row.key);
return (
<TableRow key={`${title}-${row.key}`}>
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-[13px] font-bold text-label">
{row.title}
</TableCell>
<TableCell className="text-[13px]">
{formatValue(metric[field])}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */
const SwtrmtPrcsZoneAnalysis = ({
featureData,
rawFeatureData,
}: {
featureData: any;
rawFeatureData?: any;
}) => {
const alert = useAlertStore((state) => state.setMessage);
const { gisMap } = useGisContext() as unknown as { gisMap: any };
// 기존 처리구역 관로분석 정보의 조회 상태다.
const [analysisData, setAnalysisData] =
useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
const repairLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const repairPopupRef = useRef<Window | null>(null);
const requestKey = useMemo(() => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
return [
rawFeatureData?.getId?.(),
rawFeatureData?.id_,
rawFeatureData?.fid,
rawValues?.fid,
featureData?.fid,
rawValues?.swtrmt_fclt_cd,
rawValues?.swtrmtFcltCd,
featureData?.swtrmt_fclt_cd,
featureData?.swtrmtFcltCd,
]
.filter(Boolean)
.join("|");
}, [featureData, rawFeatureData]);
const zoneId = useMemo(
() => getSwtrmtPrcsZoneId(rawFeatureData, featureData),
[featureData, rawFeatureData, requestKey],
);
/** 이전 보수이력 조회에서 만든 임시 벡터 레이어를 제거한다. */
const clearRepairLayer = useCallback(() => {
const layer = repairLayerRef.current;
if (layer && gisMap) gisMap.removeLayer(layer);
layer?.getSource()?.clear();
repairLayerRef.current = null;
}, [gisMap]);
/** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */
const replaceRepairLayer = useCallback(
(rows: RepairHistoryRow[]) => {
if (!gisMap) return;
clearRepairLayer();
const format = new GeoJSON();
const projection = gisMap.getView()?.getProjection();
const features = rows.flatMap((row) => {
if (!row.geometryJson) return [];
try {
const geometry = format.readGeometry(JSON.parse(row.geometryJson), {
dataProjection: "EPSG:3857",
featureProjection: projection,
});
const feature = new Feature({ geometry, repairFid: row.fid });
feature.setId(`repair-history-${row.fid}`);
return [feature];
} catch (error) {
console.warn("보수이력 관로 도형을 읽지 못했습니다.", row.fid, error);
return [];
}
});
if (features.length === 0) return;
const layer = new VectorLayer({
source: new VectorSource({ features }),
style: (feature) =>
feature.get("repairSelected")
? selectedRepairHistoryStyle
: repairHistoryStyle,
});
layer.set("repairHistoryLayer", true);
layer.setZIndex(10020);
gisMap.addLayer(layer);
repairLayerRef.current = layer;
},
[clearRepairLayer, gisMap],
);
/** 팝업에서 클릭한 관로를 붉게 선택하고 부모 지도를 해당 범위로 이동한다. */
const focusRepairFeature = useCallback(
(fid: string) => {
const features = repairLayerRef.current?.getSource()?.getFeatures() ?? [];
const selectedFeature = features.find(
(feature) => String(feature.get("repairFid")) === String(fid),
);
if (!selectedFeature || !gisMap) return;
features.forEach((feature) =>
feature.set("repairSelected", feature === selectedFeature),
);
repairLayerRef.current?.changed();
const extent = selectedFeature.getGeometry()?.getExtent();
if (extent) {
gisMap.getView().fit(extent, {
padding: [100, 100, 100, 460],
duration: 350,
maxZoom: 18,
});
}
},
[gisMap],
);
useEffect(() => {
let isMounted = true;
setIsLoading(true);
setErrorMessage("");
const loadAnalysis = async () => {
try {
const data = await fetchSwtrmtPrcsZoneAnalysis(
rawFeatureData,
featureData,
);
if (!isMounted) return;
if (!data) {
setAnalysisData(null);
setErrorMessage(
"처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다.",
);
return;
}
setAnalysisData(data);
} catch (error) {
if (!isMounted) return;
setAnalysisData(null);
setErrorMessage(
error instanceof Error
? error.message
: "관로분석정보를 불러오지 못했습니다.",
);
} finally {
if (isMounted) setIsLoading(false);
}
};
void loadAnalysis();
return () => {
isMounted = false;
};
}, [featureData, rawFeatureData, requestKey]);
useEffect(() => {
const handleRepairPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const messageZoneId = String(data?.payload?.zoneId ?? "").trim();
if (!messageZoneId || messageZoneId !== zoneId) return;
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS
) {
replaceRepairLayer(
Array.isArray(data.payload?.rows) ? data.payload.rows : [],
);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) {
focusRepairFeature(String(data.payload?.fid ?? ""));
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) {
clearRepairLayer();
}
};
window.addEventListener("message", handleRepairPopupMessage);
return () =>
window.removeEventListener("message", handleRepairPopupMessage);
}, [clearRepairLayer, focusRepairFeature, replaceRepairLayer, zoneId]);
useEffect(() => {
clearRepairLayer();
}, [clearRepairLayer, requestKey]);
useEffect(() => () => clearRepairLayer(), [clearRepairLayer]);
useEffect(() => {
const timerId = window.setInterval(() => {
if (repairPopupRef.current?.closed) {
repairPopupRef.current = null;
clearRepairLayer();
}
}, 500);
return () => window.clearInterval(timerId);
}, [clearRepairLayer]);
/** 선택 처리구역을 고정한 기존 공통 윈도우 팝업을 연다. */
const handleOpenRepairHistory = () => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없어 보수이력을 열 수 없습니다.");
return;
}
clearRepairLayer();
const popup = openWindowPopup("repairHistoryPopup", {
zoneId,
zoneName:
analysisData?.zone?.swtrmtZoneNm ??
featureData?.swtrmt_zone_nm ??
featureData?.swtrmtZoneNm,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
return;
}
repairPopupRef.current = popup;
};
return (
<>
<div className="card-header flex items-center justify-between">
<h3 className="mx-3 text-2xl font-medium text-label">
처리구역내 관로분석정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 bg-primary text-primary-foreground"
onClick={handleOpenRepairHistory}
data-testid="repair-history-open"
>
보수이력
</Button>
</div>
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-1 !p-0">
<div className="px-3 pb-3 pt-1">
{errorMessage ? (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">
{errorMessage}
</div>
) : null}
{isLoading ? (
<div
role="status"
className="flex min-h-[12rem] items-center justify-center rounded-md border border-border bg-background text-[1.2rem] text-muted-foreground"
>
관로분석정보를 조회하고 있습니다.
</div>
) : (
metricGroups.map((group) => (
<MetricVerticalGrid
key={group.title}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
/>
))
)}
</div>
</div>
</>
);
};
export default SwtrmtPrcsZoneAnalysis;