392 lines
12 KiB
TypeScript
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;
|