침입수 분석결과 지도연계 및 I/I 분석 메뉴 정리

This commit is contained in:
kiminchan
2026-07-01 17:35:58 +09:00
parent c14d536133
commit d5814043d9
19 changed files with 1437 additions and 147 deletions
@@ -5,6 +5,7 @@ import {
ChartColumnBig,
ChevronLeft,
ChevronRight,
Droplets,
PanelsTopLeft,
PencilRuler,
Search,
@@ -33,6 +34,7 @@ const ANALYSIS_POPUP_ITEMS: Array<{
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
{ key: "inftwtResultPopup", label: "침입수 분석", icon: Droplets },
];
export default function AnalysisPopupMenu({
@@ -45,7 +47,6 @@ export default function AnalysisPopupMenu({
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(null);
// GIS 편집 모드가 꺼지면 메뉴 강조 상태도 함께 정리해 사용자가 현재 모드를 오해하지 않게 한다.
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
setActiveKey(null);
@@ -140,4 +141,4 @@ export default function AnalysisPopupMenu({
</div>
</div>
);
}
}
@@ -0,0 +1,325 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
type InftwtResultRow,
} from "./inftwtResultTypes";
const INFTWT_RESULT_PAGE_SIZE = 10;
const INFTWT_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "적용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "anlsInfo", title: "침입수 분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
type InftwtResultFilters = {
ctpvCd: string;
sggCd: string;
sggName: string;
swtrmtFcltNm: string;
aplcnYmFrom: string;
aplcnYmTo: string;
};
const createDefaultAplcnYm = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
return `${year}${month}`;
};
const DEFAULT_FILTERS: InftwtResultFilters = {
ctpvCd: "0",
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
aplcnYmFrom: `${new Date().getFullYear()}01`,
aplcnYmTo: createDefaultAplcnYm(),
};
const postRowSelectMessage = (row: InftwtResultRow) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage(
{
type: INFTWT_RESULT_ROW_SELECT_MESSAGE,
row,
},
window.location.origin,
);
};
export default function InftwtResultPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
const [selectedRow, setSelectedRow] = useState<InftwtResultRow | null>(null);
const [rows, setRows] = useState<InftwtResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: InftwtResultFilters) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(INFTWT_RESULT_PAGE_SIZE),
sort: "inftwtAnlsSn,desc",
});
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
} as InftwtResultRow;
})
.filter((item: InftwtResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setCurrentPage(page);
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
} catch (error) {
console.error("침입수 분석결과 목록 조회 중 오류:", error);
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
const handleDownload = async () => {
if (!selectedRow?.inftwtAnlsSn) {
alert("다운로드할 분석결과를 먼저 선택해주세요.");
return;
}
try {
setIsLoading(true);
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n일련번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
},
});
} catch (error) {
console.error("침입수 분석결과 다운로드 중 오류:", error);
alert("침입수 분석결과 다운로드 중 오류가 발생했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">침입수 분석결과</h1>
</header>
<section className="overflow-y-auto p-4">
<div className="rounded-md border border-slate-200 bg-slate-50 p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-slate-700">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-slate-500">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<InftwtResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={INFTWT_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-slate-200 bg-slate-50 px-4 py-3 text-[1rem] text-slate-600">
{"선택 분석: "}
<span className="font-semibold text-slate-800">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
);
}
@@ -0,0 +1,47 @@
"use client";
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
export const INFTWT_RESULT_OVERLAY_TYPE = "INFTWT_RESULT";
export type InftwtResultRow = {
rowNumber: number;
inftwtAnlsSn: number;
ctpvCd?: string;
ctpvNm: string;
sggCd?: string;
sggNm: string;
swtrmtFcltNm: string;
aplcnYm: string;
drtwSwtcRt?: number | string;
anlsInfo: string;
regDt?: string;
rgtrNm?: string;
scsYn?: string;
};
export type InftwtResultMapViewPipe = {
pplMngNo: string;
fcltMngNo: string;
hasOilq: boolean;
};
export type InftwtResultMapViewResponse = {
analysis: {
inftwtAnlsSn: number;
aplcnYm: string;
ctpvCd?: string;
sggCd?: string;
swtrmtFcltNm?: string;
anlsInfo?: string;
};
focusTargetType: "ZONE" | "PIPE_EXTENT";
focusTargetName: string;
focusLayerType?: "tb_ms_swage_prcs_zone" | "tb_ms_swage_ppl";
focusFid?: string;
focusFallbackApplied?: boolean;
zoneGeometry: Record<string, unknown>;
pipeGeometry: Record<string, unknown>;
pipes: InftwtResultMapViewPipe[];
pipeCount: number;
oilqPipeCount: number;
};
@@ -0,0 +1,456 @@
"use client";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
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 Fill from "ol/style/Fill.js";
import Style from "ol/style/Style.js";
import { intersects as intersectsExtent } from "ol/extent";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
import {
INFTWT_RESULT_OVERLAY_TYPE,
INFTWT_RESULT_ROW_SELECT_MESSAGE,
type InftwtResultMapViewResponse,
type InftwtResultRow,
} from "./inftwtResultTypes";
type UseInftwtResultMapParams = {
gisMaps: MutableRefObject<any | null>;
setFeatureData: (value: any) => void;
setIsOpen: (value: boolean) => void;
setIsSearchModalOpen: (value: boolean) => void;
};
const INFTWT_ZONE_LAYER_ID = "SN_INFTWT_RESULT_ZONE_LAYER";
const INFTWT_PIPE_LAYER_ID = "SN_INFTWT_RESULT_PIPE_LAYER";
const DEFAULT_RESULT_FIT_PADDING: [number, number, number, number] = [120, 120, 120, 120];
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
const zoneStyle = new Style({
fill: new Fill({
color: "rgba(16, 185, 129, 0.06)",
}),
stroke: new Stroke({
color: "rgba(5, 150, 105, 0.72)",
width: 2,
lineDash: [10, 8],
}),
zIndex: 19991,
});
const buildPipeStyle = (hasOilq: boolean) => {
const innerColor = hasOilq ? "rgba(236, 72, 153, 0.98)" : "rgba(14, 165, 233, 0.98)";
const haloWidth = hasOilq ? 11 : 5;
const innerWidth = hasOilq ? 6 : 2.5;
const dashPattern = hasOilq ? [18, 9] : undefined;
return [
new Style({
stroke: new Stroke({
color: "rgba(15, 23, 42, 0.58)",
width: hasOilq ? haloWidth + 4 : haloWidth + 2,
lineCap: "round",
lineJoin: "round",
}),
zIndex: hasOilq ? 20000 : 19999,
}),
new Style({
stroke: new Stroke({
color: "rgba(255, 255, 255, 0.98)",
width: haloWidth,
lineCap: "round",
lineJoin: "round",
}),
zIndex: hasOilq ? 20001 : 20000,
}),
new Style({
stroke: new Stroke({
color: innerColor,
width: innerWidth,
lineCap: "round",
lineJoin: "round",
lineDash: dashPattern,
}),
zIndex: hasOilq ? 20002 : 20001,
}),
];
};
const getFeatureCollection = (payload: any) => {
if (payload?.type === "FeatureCollection") return payload;
if (payload?.data?.type === "FeatureCollection") return payload.data;
return { type: "FeatureCollection", features: [] };
};
const normalizeProjectionName = (value: string) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "EPSG:4326";
if (trimmed.startsWith("EPSG:")) return trimmed;
const match = trimmed.match(/EPSG[:/]{1,2}(\d{4,5})/i) || trimmed.match(/(\d{4,5})$/);
return match?.[1] ? `EPSG:${match[1]}` : trimmed;
};
const resolveFeatureCollectionProjection = (payload: any) => {
const featureCollection = getFeatureCollection(payload);
const crsName = String(featureCollection?.crs?.properties?.name ?? "").trim();
return normalizeProjectionName(crsName || GisApp.TO_PROJ);
};
const getOverlayFlag = (data: any) =>
String(
data?.get?.("analysisOverlayType") ??
data?.values_?.analysisOverlayType ??
data?.analysisOverlayType ??
"",
).trim();
const getOverlayBasePplMngNo = (data: any) =>
String(
data?.get?.("fcltMngNo") ??
data?.values_?.fcltMngNo ??
data?.values_?.fclt_mng_no ??
data?.fcltMngNo ??
data?.fclt_mng_no ??
"",
).trim();
const getOverlayAnalysisPplMngNo = (data: any) =>
String(
data?.get?.("pplMngNo") ??
data?.values_?.pplMngNo ??
data?.pplMngNo ??
"",
).trim();
const getOverlayAnalysisSn = (data: any) =>
Number(
data?.get?.("inftwtAnlsSn") ??
data?.values_?.inftwtAnlsSn ??
data?.inftwtAnlsSn ??
0,
);
const getOverlayAplcnYm = (data: any) =>
String(
data?.get?.("aplcnYm") ??
data?.values_?.aplcnYm ??
data?.aplcnYm ??
"",
).trim();
const getOverlayAnalysisInfo = (data: any) =>
String(
data?.get?.("analysisInfo") ??
data?.values_?.analysisInfo ??
data?.analysisInfo ??
"",
).trim();
const getFirstFeatureId = (payload: any) => {
const featureCollection = getFeatureCollection(payload);
const firstFeature = Array.isArray(featureCollection?.features) ? featureCollection.features[0] : null;
return String(firstFeature?.id ?? "").trim();
};
export const useInftwtResultMap = ({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
}: UseInftwtResultMapParams) => {
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
const zoneSourceRef = useRef<VectorSource | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
const mapViewCacheRef = useRef<Map<number, InftwtResultMapViewResponse>>(new Map());
const [activeMapView, setActiveMapView] = useState<InftwtResultMapViewResponse | null>(null);
const clearLayers = useCallback(() => {
zoneSourceRef.current?.clear?.();
pipeSourceRef.current?.clear?.();
zoneLayerRef.current?.changed?.();
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
}, [gisMaps]);
const ensureZoneLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (zoneLayerRef.current && zoneSourceRef.current) {
return zoneSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 19991,
});
layer.set("id", INFTWT_ZONE_LAYER_ID);
layer.set("lyrNm", "침입수분석-처리구역");
layer.set("lyrTyNm", "tb_ms_swage_prcs_zone");
layer.set("clickPsblYn", "N");
layer.setVisible?.(true);
map.addLayer(layer);
zoneLayerRef.current = layer;
zoneSourceRef.current = source;
return source;
}, [gisMaps]);
const ensurePipeLayer = useCallback(() => {
const map = gisMaps.current;
if (!map) return null;
if (pipeLayerRef.current && pipeSourceRef.current) {
return pipeSourceRef.current;
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
zIndex: 19997,
});
layer.set("id", INFTWT_PIPE_LAYER_ID);
layer.set("lyrNm", "침입수분석-결과관로");
layer.set("lyrTyNm", "tb_ms_swage_ppl");
layer.set("clickPsblYn", "Y");
layer.setVisible?.(true);
map.addLayer(layer);
pipeLayerRef.current = layer;
pipeSourceRef.current = source;
return source;
}, [gisMaps]);
const fitToRenderedFeatures = useCallback(
(mapView?: InftwtResultMapViewResponse | null) => {
const map = gisMaps.current;
const zoneExtent = zoneSourceRef.current?.getExtent?.();
const pipeExtent = pipeSourceRef.current?.getExtent?.();
const pipeFeatures = pipeSourceRef.current?.getFeatures?.() ?? [];
const hasPipeFeatures = pipeFeatures.length > 0;
const hasZoneFeatures = Boolean(zoneSourceRef.current?.getFeatures?.().length);
const preferZone = mapView?.focusTargetType === "ZONE" && hasZoneFeatures;
const zoneExtentIsFinite = Array.isArray(zoneExtent) && zoneExtent.every((value: number) => Number.isFinite(value));
const pipeExtentIsFinite = Array.isArray(pipeExtent) && pipeExtent.every((value: number) => Number.isFinite(value));
const hasPipeInsideZone =
preferZone && zoneExtentIsFinite
? pipeFeatures.some((feature: any) => {
const geometryExtent = feature?.getGeometry?.()?.getExtent?.();
return Array.isArray(geometryExtent)
&& geometryExtent.every((value: number) => Number.isFinite(value))
&& intersectsExtent(zoneExtent as [number, number, number, number], geometryExtent as [number, number, number, number]);
})
: false;
const shouldUseZoneExtent = preferZone && (hasPipeInsideZone || !hasPipeFeatures);
const extent = shouldUseZoneExtent
? zoneExtent
: hasPipeFeatures
? pipeExtent
: hasZoneFeatures
? zoneExtent
: null;
if (!map || !extent || extent.some((value: number) => !Number.isFinite(value))) {
return;
}
map.getView?.().fit?.(extent, {
duration: 350,
padding: DEFAULT_RESULT_FIT_PADDING,
maxZoom: shouldUseZoneExtent ? 15 : 16,
constrainResolution: true,
nearest: true,
});
},
[gisMaps],
);
const moveByRegionCodes = useCallback(
(ctpvCd?: string, sggCd?: string) => {
const normalizedSggCd = String(sggCd ?? "").trim();
if (normalizedSggCd && normalizedSggCd !== "0") {
const sigunguCenter = getSigunguCenter(normalizedSggCd);
if (sigunguCenter) {
moveMapToCenter(gisMaps, sigunguCenter, 12);
return true;
}
}
const normalizedCtpvCd = String(ctpvCd ?? "").trim();
if (normalizedCtpvCd && normalizedCtpvCd !== "0") {
const sidoCenterCoordinate = sidoCenter[normalizedCtpvCd.slice(0, 2)];
if (sidoCenterCoordinate) {
moveMapToCenter(gisMaps, sidoCenterCoordinate, 11);
return true;
}
}
return false;
},
[gisMaps],
);
const moveToStableFocus = useCallback(
async (mapView: InftwtResultMapViewResponse) => {
const activeRow = activeResultRowRef.current;
const movedByRegion = moveByRegionCodes(
activeRow?.ctpvCd ?? mapView.analysis?.ctpvCd,
activeRow?.sggCd ?? mapView.analysis?.sggCd,
);
if (movedByRegion) return;
fitToRenderedFeatures(mapView);
},
[fitToRenderedFeatures, moveByRegionCodes],
);
const renderMapView = useCallback(
async (mapView: InftwtResultMapViewResponse) => {
const zoneSource = ensureZoneLayer();
const pipeSource = ensurePipeLayer();
if (!zoneSource || !pipeSource) return;
clearLayers();
const zoneDataProjection = resolveFeatureCollectionProjection(mapView.zoneGeometry);
const zoneFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.zoneGeometry), {
dataProjection: zoneDataProjection,
featureProjection: GisApp.TO_PROJ,
});
zoneFeatures.forEach((feature: any) => feature.setStyle(zoneStyle));
zoneSource.addFeatures(zoneFeatures);
zoneSource.changed?.();
zoneLayerRef.current?.setVisible?.(true);
zoneLayerRef.current?.changed?.();
const pipeDataProjection = resolveFeatureCollectionProjection(mapView.pipeGeometry);
const pipeFeatures = new GeoJSON().readFeatures(getFeatureCollection(mapView.pipeGeometry), {
dataProjection: pipeDataProjection,
featureProjection: GisApp.TO_PROJ,
});
pipeFeatures.forEach((feature: any) => {
const hasOilq = Boolean(feature.get?.("hasOilq"));
feature.setStyle(buildPipeStyle(hasOilq));
});
pipeSource.addFeatures(pipeFeatures);
pipeSource.changed?.();
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
console.info("침입수 FE overlay 렌더 결과", {
focusTargetType: mapView.focusTargetType,
zoneFeatureCount: zoneFeatures.length,
pipeFeatureCount: pipeFeatures.length,
});
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
);
const handleRowSelect = useCallback(
async (row: InftwtResultRow) => {
try {
activeResultRowRef.current = row;
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(INFTWT_PROC_SEARCH_EVENT, {
detail: {
ctpvCd: row.ctpvCd ?? "0",
sggCd: row.sggCd ?? "0",
swtrmtFcltNm: row.swtrmtFcltNm ?? "",
},
}),
);
}
const cachedMapView = mapViewCacheRef.current.get(row.inftwtAnlsSn);
if (cachedMapView) {
setActiveMapView(cachedMapView);
void renderMapView(cachedMapView);
return;
}
const payload = await getRawJson(`/sewerage-service/api/v1/inftwtAnlsRslt/mapView/${row.inftwtAnlsSn}`);
const mapView = (payload?.data ?? payload) as InftwtResultMapViewResponse;
mapViewCacheRef.current.set(row.inftwtAnlsSn, mapView);
setActiveMapView(mapView);
void renderMapView(mapView);
} catch (error) {
console.error("침입수 분석결과 지도 연계 중 오류:", error);
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
const handlePopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
if (event.data?.type !== INFTWT_RESULT_ROW_SELECT_MESSAGE || !event.data?.row) return;
void handleRowSelect(event.data.row as InftwtResultRow);
};
window.addEventListener("message", handlePopupMessage);
return () => window.removeEventListener("message", handlePopupMessage);
}, [handleRowSelect]);
useEffect(() => {
return () => {
clearLayers();
};
}, [clearLayers]);
const handleOverlayFeatureSelect = useCallback((data: any) => {
if (getOverlayFlag(data) !== INFTWT_RESULT_OVERLAY_TYPE) {
return false;
}
const overlayAnalysisSn = getOverlayAnalysisSn(data);
const basePplMngNo = getOverlayBasePplMngNo(data);
const analysisPplMngNo = getOverlayAnalysisPplMngNo(data);
const currentRow = activeResultRowRef.current;
const popupWindow = openWindowPopup("infilChart", {
pplMngNo: basePplMngNo || analysisPplMngNo.replace(/^L/, ""),
inftwtAnlsSn: overlayAnalysisSn || currentRow?.inftwtAnlsSn || 0,
aplcnYm: getOverlayAplcnYm(data) || currentRow?.aplcnYm || activeMapView?.analysis?.aplcnYm || "",
analysisInfo:
getOverlayAnalysisInfo(data) ||
currentRow?.anlsInfo ||
activeMapView?.analysis?.anlsInfo ||
"",
resultOnly: "Y",
});
if (!popupWindow) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
return true;
}, [activeMapView]);
return {
activeMapView,
clearLayers,
handleOverlayFeatureSelect,
};
};
@@ -11,11 +11,15 @@ import {
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
import type { TInftwtAnls } from './aplcnYms';
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
import * as fn from './aplcnYms';
type ModalOpenProp = {
pplMngNo?: string; // 관로관리번호
pplMngNo?: string;
inftwtAnlsSn?: number;
aplcnYm?: string;
analysisInfo?: string;
resultOnly?: boolean;
};
type DataType = {
@@ -25,8 +29,13 @@ type DataType = {
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const InfilChart = ({
pplMngNo = '',
inftwtAnlsSn = 0,
aplcnYm = '',
analysisInfo = '',
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
@@ -42,6 +51,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const chartForm = CHART_FORM.LINE;
const chartConfig = {
@@ -53,6 +63,28 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const calculateFallbackInftwtQty = (
inftwtAnlsRslt: TInftwtAnlsRslt[],
oilqRows: TOilqData[],
) => {
const predcValues = inftwtAnlsRslt
.map((item) => Number(item.predcOilq))
.filter((value) => Number.isFinite(value));
const oilqValues = oilqRows
.map((item) => Number(item.oilq))
.filter((value) => Number.isFinite(value));
if (predcValues.length === 0 || oilqValues.length === 0) {
return 0;
}
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const dailyQty = (avgOilq - avgPredc) * 24;
return Math.round(dailyQty / 10) * 10;
};
const resetState = () => {
setSelectedAplcnYm('');
setInftwtAnlss([]);
@@ -63,6 +95,63 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
setInftwtQty('0');
};
const loadResultChart = async (targetInftwtAnlsSn: number) => {
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
return [];
}),
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
const normalizedQty = Number(qty ?? Number.NaN);
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
});
}
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
} catch (error) {
console.error('침입수 분석결과 차트 조회 중 오류:', error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
}
};
useEffect(() => {
if (!isResultOnlyMode) return;
setSelectedAplcnYm(aplcnYm);
setSelectedInftwtAnls(inftwtAnlsSn);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
void loadResultChart(inftwtAnlsSn);
}, [aplcnYm, inftwtAnlsSn, isResultOnlyMode, normalizedPplMngNo]);
const handleAplcnYmChange = async (val: string | number) => {
const selectedValue = String(val);
@@ -113,31 +202,7 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
return;
}
try {
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(selectedInftwtAnls, normalizedPplMngNo),
fn.selectOilq(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
return [];
}),
fn.selectPplInftwtQty(selectedInftwtAnls, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
]);
setChartData({
chartData1: fn.getChartDataInftwtAnlsRslt(inftwtAnlsRslt ?? []),
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const normalizedQty = Number(qty ?? 0);
setInftwtQty(normalizedQty.toLocaleString('ko-KR'));
} catch (error) {
console.error('infilRsltChart 에러:', error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
}
await loadResultChart(selectedInftwtAnls);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
@@ -154,14 +219,56 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
const handleReset = () => {
resetState();
}
};
const renderBody = () => (
<div className="text-black">
<div className="mb-4 flex items-start justify-between">
<div className="text-[18px] font-semibold text-[#111827]" />
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용량 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>침입수량 산정결과</span>
</div>
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
@@ -216,60 +323,14 @@ const InfilChart = ({ pplMngNo = '' }: ModalOpenProp) => {
</div>
</section>
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-6 grid grid-cols-2 gap-5">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>상수사용량 적용연월</span>
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
{/* <span className="text-[14px] leading-none">●</span> */}
<span>침입수량 산정결과</span>
</div>
<div className="grid grid-cols-[1fr_auto] items-center gap-7 text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className='w-[66px] flex items-end justify-start gap-2'>
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</div>
</div>
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
</section>
{renderResultPanel()}
</div>
</div>
);
return (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">침입수 분석</h1>
{renderBody()}
<div className="min-h-dvh bg-white p-3">
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</div>
);
};
+21 -1
View File
@@ -78,6 +78,7 @@ import {
validateElapsedYears,
} from "./components/agedPipe/agedPipeUtils";
import { downloadAgedPipeCsv } from "./components/agedPipe/agedPipeExport";
import { useInftwtResultMap } from "./components/inftwtResult/useInftwtResultMap";
import SinkholeDetailPanel from "./components/sinkhole/SinkholeDetailPanel";
import SinkholeLayerSettingPanel from "./components/sinkhole/SinkholeLayerSettingPanel";
import FloodHistoryDetailPanel from "./components/floodHistory/FloodHistoryDetailPanel";
@@ -2451,6 +2452,13 @@ const Map = () => {
const { ctpv, sgg } = useStore();
// GIS 편집은 map/page.tsx 내부 기존 지도 상태를 재사용하되, 세부 상태와 payload 조립은 별도 hook으로 분리한다.
const gisEdit = useGisEdit(treeData);
// 침입수 분석결과 popup과 지도 highlight 흐름은 별도 hook으로 묶어 기존 지도 로직과 충돌을 줄인다.
const inftwtResultMap = useInftwtResultMap({
gisMaps,
setFeatureData,
setIsOpen,
setIsSearchModalOpen,
});
const shouldFocusAgedPipeOnNextSelectRef = useRef(false);
const agedPipePopupWindowRef = useRef<Window | null>(null);
const sinkholePopupWindowRef = useRef<Window | null>(null);
@@ -3574,8 +3582,15 @@ const Map = () => {
// SearchModal, GisContent 등에서 호출
const onFeatureSelect = (data: any) => {
const clickedLayerTypeName = getSelectedFeatureLayerTypeName(data);
// 지도에서 새 피처를 직접 선택하면 지반침하 반경 표에서 만든 임시 관로 포커스는 해제한다.
// 지형에 있는 피처를 직접 선택하면 지반침하 반경 내에서 만든 임시 관로 강조는 해제한다.
clearSinkholeFocusedPipeLayer();
if (inftwtResultMap.handleOverlayFeatureSelect(data)) {
setFeatureData(null);
setIsOpen(false);
setIsSearchModalOpen(false);
return;
}
if (
gisEdit.isEditMode &&
(gisEdit.activeTool === "MOVE" || gisEdit.activeTool === "VERTEX_EDIT") &&
@@ -4084,11 +4099,13 @@ const Map = () => {
const layerTypeName = getSelectedFeatureLayerTypeName(data);
if (layerTypeName === SINKHOLE_LAYER_TYPE) {
clearSinkholeFocusedPipeLayer();
return;
}
if (!data || typeof data.getGeometry !== "function") {
clearSinkholeFocusedPipeLayer();
return;
}
@@ -4108,6 +4125,7 @@ const Map = () => {
}
clearSinkholeFocusedPipeLayer();
select?.selectFeature?.(data, true);
},
[clearSinkholeFocusedPipeLayer, ensureSinkholeFocusedPipeLayer],
@@ -4285,6 +4303,7 @@ const Map = () => {
setSelectedSinkholeNearbyPipes([]);
clearSinkholeSelectionLayer();
clearSinkholeFocusedPipeLayer();
restoreSinkholeDetailOnCloseRef.current = false;
if (getSelectedFeatureLayerTypeName(featureData) === SINKHOLE_LAYER_TYPE) {
setFeatureData(null);
@@ -4644,6 +4663,7 @@ const Map = () => {
const select = GisApp.Module.select as any;
select?.resetSelectedFeature?.();
clearSinkholeFocusedPipeLayer();
}
if (closingTab?.kind === "gisEdit") {
setGisEditDetailChangeId(null);
@@ -51,6 +51,7 @@ type BaseLayout = {
analysisBtn?: string
firstDivBtn?: boolean
selectBox?: SelectBox
selectionComponent?: ReactNode
showDatePicker?: boolean;
ctpvCd: string;
sggCd: string;
@@ -83,6 +84,7 @@ const BaseLayout = ({
graphBtn = false,
analysisBtn,
selectBox,
selectionComponent,
showDatePicker = false,
ctpvCd,
sggCd,
@@ -191,21 +193,24 @@ const BaseLayout = ({
/>
</>
)}
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
sggName={sggName}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}}
onSigunguChange={setSggCd}
setSggName={setSggName}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
setSwtrmtFcltNm={setSwtrmtFcltNm}
setIsLoading={setIsLoading}
/>
{/* 침입수 분석 실행처럼 선택 구조가 다른 화면은 커스텀 selector를 주입하고, 나머지는 기존 시설 selector를 유지한다. */}
{selectionComponent ?? (
<CtpvSggPrcsFcltSelectBox
sido={ctpvCd}
sigungu={sggCd}
sggName={sggName}
onSidoChange={(sidoId: string) => {
setCtpvCd(sidoId);
setSggCd('0');
}}
onSigunguChange={setSggCd}
setSggName={setSggName}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={onSwagePrcsFcltChange}
setSwtrmtFcltNm={setSwtrmtFcltNm}
setIsLoading={setIsLoading}
/>
)}
{selectBox?.searchChk && (
<div className='flex flex-1 justify-end mr-6'>
<Button size="sm" onClick={() => selectBox.onSearchClick?.()}>조회</Button>
@@ -62,7 +62,7 @@ const initialFileInfo: UploadedFileInfo = { file: null, atflCd: null, atflId: nu
const IrSetting = () => {
const [input1, setInput1] = useState('0.8'); // 오수전환율 초기값
const [input2, setInput2] = useState('');
const [select1, setSelect1] = useState<string>('0'); // 하수처리장코드
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState<string>('0'); // ?????? ?? ??
const [formResetKey, setFormResetKey] = useState(0); // FileUploadForm 리셋을 위한 key 상태
const [isLoadingResult, setIsLoadingResult] = useState<boolean>(false); // 로딩 상태
const [ctpvCd, setCtpvCd] = useState<string>('0'); // 시도 코드 상태
@@ -126,7 +126,7 @@ const IrSetting = () => {
setSelectedDate(`${year}${month}`);
setInput1(''); // 입력 필드 초기화
setInput2(''); // 입력 필드 초기화
setSelect1('0');
setSelectedSwagePrcsFclt('0');
setSwtrmtFcltNm('');
setFormResetKey(prevKey => prevKey + 1); // FileUploadForm 재마운트를 위해 key 변경
};
@@ -168,11 +168,12 @@ const IrSetting = () => {
setIsLoadingResult(true); // 로딩 상태 시작
// 기본 선택 항목들 검증 (환경청, 시도, 시군구, 하수처리장)
if (!validateBasicSelectionsWithAlert(select1, {
if (!validateBasicSelectionsWithAlert(selectedSwagePrcsFclt, {
requireEnv: true,
requireSido: true,
requireSigungu: true,
requireTreatmentPlant: true
requireTreatmentPlant: true,
treatmentPlantLabel: '하수처리시설'
})) {
setIsLoadingResult(false);
return;
@@ -302,8 +303,8 @@ const IrSetting = () => {
);
// 각 파싱 결과 접근
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData;
const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData;
const matchingResultData = parsedResults.find(r => r && r.fileName === file2.file?.name)?.parsedData ?? [];
const timePatternData = parsedResults.find(r => r && r.fileName === file3.file?.name)?.parsedData ?? [];
const flowRateData = file4?.file
? parsedResults.find(r => r && r.fileName === file4.file?.name)?.parsedData
: null;
@@ -311,6 +312,23 @@ const IrSetting = () => {
? parsedResults.find(r => r && r.fileName === file5.file?.name)?.parsedData
: null;
if (!swtrmtFcltNm || !swtrmtFcltNm.trim()) {
setIsLoadingResult(false);
alert('하수처리시설명이 비어 있습니다. 시설을 다시 선택해주세요.');
return;
}
if (!Array.isArray(matchingResultData) || matchingResultData.length === 0) {
setIsLoadingResult(false);
alert('수용가-맨홀 매칭결과 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
return;
}
if (!Array.isArray(timePatternData) || timePatternData.length === 0) {
setIsLoadingResult(false);
alert('오수량 시간패턴 파일에서 저장할 데이터를 읽지 못했습니다. 엑셀 헤더를 확인해주세요.');
return;
}
// 새로운 JSON 데이터 생성
const jsonData = {
ctpvCd: ctpvCd !== '0' ? String(ctpvCd).trim() : null,
@@ -364,8 +382,8 @@ const IrSetting = () => {
ctpvCd={ctpvCd}
sggCd={sggCd}
sggName={sggName}
selectedSwagePrcsFclt={select1}
onSwagePrcsFcltChange={setSelect1}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setCtpvCd={setCtpvCd}
setSggCd={setSggCd}
setSggName={setSggName}
@@ -494,4 +512,4 @@ const IrSetting = () => {
)
}
export default IrSetting
export default IrSetting
@@ -100,19 +100,21 @@ export const validateBasicSelections = (
requireSido?: boolean;
requireSigungu?: boolean;
requireTreatmentPlant?: boolean;
treatmentPlantLabel?: string;
} = {}
): SelectionValidationResult => {
const {
requireEnv = true,
requireSido = true,
requireSigungu = true,
requireTreatmentPlant = true
requireTreatmentPlant = true,
treatmentPlantLabel = '하수처리장'
} = options;
const validationConfigs: SelectionValidationConfig[] = [
{
value: select1,
description: '하수처리장',
description: treatmentPlantLabel,
required: requireTreatmentPlant
}
];
@@ -133,6 +135,7 @@ export const validateBasicSelectionsWithAlert = (
requireSido?: boolean;
requireSigungu?: boolean;
requireTreatmentPlant?: boolean;
treatmentPlantLabel?: string;
} = {}
): boolean => {
const result = validateBasicSelections(select1, options);
+27 -21
View File
@@ -11,6 +11,7 @@
*/
import { useEffect, useState } from "react";
import axios from 'axios';
import { AxiosError } from 'axios';
import { CLAIM_NAME, SITE_ID } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
@@ -212,29 +213,34 @@ export const postBlobData = async (
};
export const postMutiPart = async (url: string, data: FormData): Promise<any> => {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,
timeout: 600000, // 10분
});
console.log('postMultiPart response:', res);
return res.data.data;
};
try {
const res = await axios.post(`${BASE_URL}/${url}`, data, {
headers: DEFAULT_HEADERS,
timeout: 600000, // 10분
});
console.log('postMultiPart response:', res);
return res.data.data;
} catch (error) {
if (error instanceof AxiosError) {
const responseBody = error.response?.data;
const serverMessage =
typeof responseBody?.message === 'string' && responseBody.message.trim()
? responseBody.message.trim()
: typeof responseBody?.error?.message === 'string' && responseBody.error.message.trim()
? responseBody.error.message.trim()
: typeof responseBody === 'string' && responseBody.trim()
? responseBody.trim()
: null;
export const putData = async (url: string, data: object): Promise<any> => {
const options: RequestInit = {
method: "PUT",
headers: withSiteIdHeaders({
"Content-Type": "application/json",
}),
body: JSON.stringify(data),
};
const res = await fetch(`${BASE_URL}/${url}`, options);
if (res.status === 401) {
handleUnauthorizedResponse();
const nextError = new Error(
serverMessage ?? `Multipart request failed: ${error.response?.status ?? 'UNKNOWN'} ${error.response?.statusText ?? ''}`.trim(),
) as Error & { status?: number; body?: unknown };
nextError.status = error.response?.status;
nextError.body = responseBody;
throw nextError;
}
throw error;
}
const resData = await res.json();
console.log('putData response:', resData);
return resData.data;
};
export const deleteData = async (url: string, data?: object): Promise<any> => {
@@ -0,0 +1,138 @@
# SN 침입수분석결과 지도연계 유지보수 작업카드
## 1. 작업 개요
- 작업 ID: `SN-INFTWT-RESULT-FE`
- 작업명: 침입수분석결과 지도형 popup 및 하이라이트 연계
- 상태: 검증
- 작성일: 2026-06-30
- 담당 역할: FE
- 관련 역할: DE, BE, QA
- 담당 범위: `apps/sn` 하수관망도 `분석도구`, popup registry, 지도 하이라이트, 결과 전용 popup 모드
- 근거 문서
- `COMMON_DOCS_ROOT/작업계획/SN-침입수분석결과-지도연계유지보수/001-de-SN-침입수분석결과-지도연계유지보수-설계.md`
- `COMMON_DOCS_ROOT/기준문서/010-AI개발작업흐름.md`
- `FE_APP_DOCS_ROOT/기준문서/010-용어사전.md`
- `FE_APP_DOCS_ROOT/기준문서/013-UI레이아웃가이드.md`
## 1-1. 공통 동기화 확인
- 공통 작업현황판 확인 여부: 확인
- 파일점유현황 확인 여부: 확인
- 결정로그 확인 여부: 확인
- 기존 진행 중 작업과의 관계
- `map/page.tsx`, `AnalysisPopupMenu.tsx`, `windowPopup/**`는 최근 지도형 popup 기능과 겹친다.
- 따라서 기존 `노후관로`, `관로분석`, `지반침하` popup 패턴을 그대로 재사용하는 방향을 우선한다.
## 2. 작업 목적
- 기존 업무형 `ii/result` 화면의 침입수 분석결과 조회를 지도형 `분석도구` popup으로 옮긴다.
- 결과 행 선택 시 지도에서 결과 관로를 강조하고, `실측하수량` 보유 관로를 별도 색으로 구분한다.
- 하이라이트 관로 클릭 시 기본 관로 상세가 아니라 `침입수 분석결과` 전용 popup이 열리도록 FE 흐름을 전환한다.
## 3. 수정 대상 파일
| 구분 | 파일 경로 | 수정 여부 | 메모 |
| --- | --- | --- | --- |
| 우측 메뉴 | `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx` | 완료 | `침입수 분석` 메뉴 추가 |
| 지도 메인 | `FE_APP_ROOT/app/(map-layout)/map/page.tsx` | 완료 | popup 열기, 결과 overlay, 클릭 분기, popup message 처리 |
| popup 정의 | `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts` | 완료 | `inftwtResultPopup` 정의 추가 |
| popup 등록 | `FE_APP_ROOT/src/features/windowPopup/registry.tsx` | 완료 | 결과 popup content 연결 |
| 기존 결과 popup | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx` | 완료 | `resultOnly` 모드 추가 |
| 기존 API helper | `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/aplcnYms.ts` | 유지 | 기존 helper 재사용, popup content에서 고정 파라미터만 추가 |
| 기존 업무형 화면 | `FE_APP_ROOT/app/(with-base-layout)/ii/result/page.tsx` | 유지 | 현재 구현은 popup 전용으로 분리하고 기존 화면은 읽기 기준으로 유지 |
| 신규 popup | `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/**` | 완료 | 결과 목록 popup 화면 및 지도 연계 hook 구현 |
## 4. 구현 범위
### 4.1 포함
- `분석도구 > 침입수 분석` popup 진입
- 기존 `ii/result` 조회조건과 결과 목록의 popup 재구성
- 결과 row click -> 부모창 message 전달
- map/page 결과 overlay state와 focus 처리
- 일반 결과 관로 / `실측하수량` 관로 이중 스타일
- overlay 관로 click -> `infilChart` 결과 전용 모드
### 4.2 제외 또는 후속
- 실제 분석 산정 로직 수정
- 신규 업무형 route 추가
- 공통 패키지(`business_components`) 확장
- 다른 앱 공통 popup 체계 변경
## 5. 화면 흐름
1. 사용자가 하수관망도 우측 `분석도구`에서 `침입수 분석`을 클릭한다.
2. 시스템이 `inftwtResultPopup`을 연다.
3. 사용자가 기존 침입수 분석 이력 목록을 조회한다.
4. 사용자가 특정 분석 row를 클릭한다.
5. popup은 부모창으로 `inftwtAnlsSn`과 row 요약을 전달한다.
6. `map/page.tsx`가 분석 결과 overlay를 그리고 focus area로 이동한다.
7. 사용자가 강조된 관로를 클릭한다.
8. 시스템은 `openWindowPopup("infilChart", { pplMngNo, inftwtAnlsSn, resultOnly: "Y" })`를 호출한다.
9. popup은 좌측 조건 없이 결과 그래프와 침입수량만 보여준다.
## 6. FE 구현 메모
- popup UI는 `AgedPipePopupPage`, `PipeAnalysisPopupPage` 패턴을 우선 따른다.
- 새 popup에서 기존 `ii/result/page.tsx` 전체를 그대로 감싸기보다:
- 조회조건/테이블 데이터 조회
- 다운로드 매핑
- row 선택 로직
- 이 3가지를 shared util 또는 hook으로 추출하는 쪽이 안전하다.
- overlay 관로는 기존 피처 클릭과 분리하기 위해 전용 vector layer를 우선 검토한다.
- `실측하수량` 관로 스타일은 `infilChart`의 주황 의미와 맞춘다.
## 7. API 연동
| 기능 | Method | URL | 상태 | FE 메모 |
| --- | --- | --- | --- | --- |
| 분석이력 조회 | `GET` | `/api/v1/inftwtAnls` | 재사용 | popup 목록 조회 |
| 결과 전체 다운로드 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}` | 재사용 | 엑셀 생성 source |
| 지도 연계 요약 | `GET` | `/api/v1/inftwtAnlsRslt/mapView/{inftwtAnlsSn}` | 신규 | focus geometry + highlight 대상 |
| 관로 결과 그래프 | `GET` | `/api/v1/inftwtAnlsRslt/ppl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
| 실측하수량 시계열 | `GET` | `/api/v1/inftwtAnlsRslt/oilqPpl/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
| 침입수량 집계 | `GET` | `/api/v1/inftwtAnlsRslt/pplInftwtQty/{inftwtAnlsSn}/{pplMngNo}` | 재사용 | 결과 전용 popup |
## 8. 검증 계획
- popup 진입/닫기
- 기존 `ii/result`와 동일 조건 조회 결과 일치
- row click 후 map focus 이동
- 결과 관로 / 실측하수량 관로 스타일 구분
- overlay click 시 기본 `FeatureClickEventPopup` 대신 결과 전용 popup 노출
- `양평` 데이터에서 실측하수량 관로 색상 및 그래프 확인
## 9. 롤백 방법
- `AnalysisPopupMenu.tsx`의 `침입수 분석` 메뉴 제거
- `page.tsx`의 결과 overlay state 및 message listener 제거
- `infilChart.tsx`의 `resultOnly` 모드 제거
- 신규 popup registry/definition 제거
## 10. 남은 리스크
- `ii/result`와 popup의 조회/다운로드 로직 공용화 범위를 잘못 잡으면 중복 수정이 생길 수 있다.
- 결과 관로 수가 많은 경우 overlay 피처 수와 클릭 성능을 점검해야 한다.
- 기본 하수관로 상세와 overlay click 분기 우선순위를 잘못 잡으면 사용자 체감이 어색해질 수 있다.
## 11. 2026-07-01 유지보수 보정
- row click 후 FE가 기대하는 `mapView` 계약을 `focusLayerType`, `focusFid`, `focusFallbackApplied`, `pipeCount`, `oilqPipeCount` 중심으로 재정리했다.
- FE는 백엔드가 내려준 결과 관로 GeoJSON을 그대로 하이라이트하되, 지도 이동은 처리구역 1건의 `focus fid`를 우선 사용하고 실패 시 결과 관로 extent fallback만 수행한다.
- 이 보정으로 FE는 더 이상 전체 시계열 결과를 별도로 들고 있지 않고, 결과 popup row 요약 + 경량 mapView 응답만 기준으로 지도 연계를 수행한다.
- 브라우저 수동 검증 전까지 상태는 `검증`을 유지한다.
## 12. 2026-07-01 row click 지도 이동 보정
- row click 직후 FE는 더 이상 `showSearchResultsFromGeoJSON`로 처리구역/관로를 강제 선택하지 않는다.
- `focusTargetType=ZONE`이면 처리구역 overlay extent를 우선 fit 하고, 아니면 결과 관로 extent를 fit 한다.
- 이 보정으로 잘못된 기본 관로 상세 패널이 자동으로 열리는 현상을 막고, 지도 이동과 overlay 하이라이트를 분리한다.
## 13. 2026-07-01 분석 실행 화면 선택구조 유지보수
- `ii/irsetting`만 기존 시설 우선 selector를 그대로 쓰지 않고, 커스텀 `selectionComponent`로 처리구역 우선 selector를 주입한다.
- 새 selector는 시도/시군구 선택 뒤 `GET /api/v1/swagePrcsZones/selectionOptions`를 호출해 `하수처리구역명` 목록을 받고, 선택 시 `하수처리시설명`을 읽기 전용으로 자동 표시한다.
- `ii/rdii`, `ii/result`, 지도 검색 모달 등 기존 `CtpvSggPrcsFcltSelectBox` 사용처는 이번 범위에서 변경하지 않는다.
- 저장 payload는 기존과 동일하게 `swtrmtFcltNm`만 유지해 회귀 범위를 최소화한다.
@@ -0,0 +1,102 @@
# SN 침입수분석결과 지도연계 유지보수 FE 통합구현기록
## 1. 작업 목적
- 하수관망도 `분석도구`에 `침입수 분석` popup을 추가하고, 기존 `ii/result` 결과 조회 흐름을 지도 중심 popup으로 옮긴다.
- 결과 row 선택 시 지도에서 처리구역과 결과 관로를 강조하고, `실측하수량` 보유 관로는 별도 색상으로 구분한다.
- 결과 관로 클릭 시 기존 관로 상세 대신 `침입수 분석결과` 전용 popup을 재사용한다.
## 2. 실제 수정 파일
- `FE_APP_ROOT/app/(map-layout)/map/components/analysisMenu/AnalysisPopupMenu.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/page.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/popupComponent/chart/infilChart.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage.tsx`
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/inftwtResultTypes.ts`
- `FE_APP_ROOT/app/(map-layout)/map/components/inftwtResult/useInftwtResultMap.ts`
- `FE_APP_ROOT/src/features/windowPopup/model/popupDefinitions.ts`
- `FE_APP_ROOT/src/features/windowPopup/registry.tsx`
- `FE_APP_ROOT/src/features/windowPopup/ui/InfilChartPopupContent.tsx`
- `FE_APP_ROOT/src/features/windowPopup/ui/InftwtResultPopupContent.tsx`
## 3. 구현 내용
- `AnalysisPopupMenu`에 `침입수 분석` 메뉴를 추가했다.
- window popup 체계에 `inftwtResultPopup`을 등록했다.
- 신규 `InftwtResultPopupPage`를 추가해 기존 침입수 분석이력 조회와 결과 다운로드를 popup 안에서 수행하도록 구성했다.
- 결과 row 클릭 시 `SN_INFTWT_RESULT_ROW_SELECT` message를 부모창으로 전달하도록 구현했다.
- `useInftwtResultMap` hook을 추가해:
- 신규 `mapView` endpoint 호출
- 처리구역 geometry overlay
- 결과 관로 overlay
- `실측하수량` 보유 관로 주황색 강조
- overlay 관로 클릭 시 결과 전용 `infilChart` popup 분기
- 를 한 곳에서 처리하게 했다.
- 기존 `infilChart`는 그대로 두지 않고 `resultOnly` 모드를 추가했다.
- 기본 모드: 기존처럼 사용자가 상수사용월/분석정보를 선택
- 결과 전용 모드: `inftwtAnlsSn`, `aplcnYm`, `analysisInfo`, `pplMngNo`를 받아 좌측 조건 없이 결과만 표시
## 4. 지도 연계 기준
- 현재 구현은 처리분구 geometry 원천이 명확하지 않아 `처리구역` geometry를 우선 focus source로 사용한다.
- 처리구역 geometry가 없으면 결과 관로 extent로 fallback 한다.
- 일반 결과 관로는 파란색, `실측하수량` 보유 관로는 주황색으로 표시한다.
- 후속 UI 보정으로 `침입수 분석` 메뉴를 분석도구 최하단으로 이동했고, popup 조회조건의 `상수사용기간`은 기존 `DateMonthPicker` 패턴으로 교체했으며, 하수처리시설은 텍스트 입력 대신 기존 `CtpvSggPrcsFcltSelectBox` 기반 선택으로 바꿨다.
- 지도 이동 안정화를 위해 결과 row 선택 후에는 처리구역 첫 feature id 또는 첫 번째 결과 관로 fid를 기존 `showSearchResultsFromGeoJSON` 경로로 우선 사용하고, overlay geometry는 GeoJSON `crs` 값을 읽어 동적으로 projection을 해석하도록 보강했다.
## 5. 검증'
- 코드 기준 정적 확인
- popup 정의/registry 연결 확인
- `map/page.tsx` message listener 및 overlay click 분기 확인
- 자동 검증
- FE TypeScript 검증은 저장소 `pnpm` lockfile 정책 문제로 수행하지 못했다.
- 오류 내용: `ERR_PNPM_LOCKFILE_CONFIG_MISMATCH`
## 6. 남은 이슈
- 실제 브라우저에서 popup row 선택 후 지도 focus, 색상 구분, overlay 관로 click 동작을 수동으로 확인해야 한다.
- `양평` 데이터 기준 `실측하수량` 관로가 기대대로 주황색으로 분리되는지 검증이 필요하다.
- 처리분구 geometry 원천이 확정되면 `useInftwtResultMap` 내부 focus 규칙을 확장해야 한다.
## 7. 2026-07-01 속도/안정화 보정
- `useInftwtResultMap`이 `focusLayerType`, `focusFid`를 우선 사용해 기존 `showSearchResultsFromGeoJSON` 이동 경로를 재사용하도록 보정했다.
- 결과 관로 하이라이트는 백엔드가 조합한 dedup `pplMngNo` 기준 GeoJSON만 사용하고, 실패 시에만 관로 extent fallback을 수행한다.
- FE 정적 검증은 로컬 `tsc` 실행까지 시도했지만 `apps/sn/tsconfig.json`의 `ignoreDeprecations` 값이 현재 TypeScript 버전과 맞지 않아 미완료였다.
## 8. 2026-07-01 row click 오동작 보정
- `useInftwtResultMap`의 지도 이동 기준을 `showSearchResultsFromGeoJSON` 선택 흐름에서 overlay extent fit 기준으로 바꿨다.
- `focusTargetType=ZONE`이면 처리구역 extent를 우선 사용하고, 아니면 결과 관로 extent를 사용하도록 수정했다.
- 이 보정으로 row click 직후 잘못된 기본 관로 상세가 자동으로 열리는 현상을 줄인다.
## 9. 2026-07-01 분석 실행 화면 선택구조 보정
- `ii/BaseLayout.tsx`에 `selectionComponent` 주입 지점을 추가해, 화면별로 기본 시설 selector를 교체할 수 있게 했다.
- `ii/irsetting/page.tsx`는 새 `CtpvSggSwtrmtZoneSelectBox`를 사용하도록 바꿨다.
- 새 selector는 `하수처리구역명`을 먼저 선택하고, 대응 `하수처리시설명`은 읽기 전용으로 자동 표시한다.
- 선택 검증 유틸은 기존 로직을 유지하되 `treatmentPlantLabel` 옵션만 추가해, `irsetting`에서는 안내 문구가 `하수처리구역을 선택해주세요.`로 나오게 맞췄다.
- FE 타입 검증은 `tsc --ignoreDeprecations 5.0` 기준 새 변경 파일 오류 없이 통과했고, 남은 오류는 기존 지도/공통 차트 파일 선행 이슈로 분리했다.
## 10. 2026-07-01 분석 실행 500 디버깅 보강
- postMutiPart는 Axios 500 응답의 본문 메시지를 추출해 상위 화면으로 다시 던지도록 바꿨다.
- ii/irsetting은 자동 표시된 시설명, 매칭결과 배열, 시간패턴 배열이 비어 있으면 서버 호출 전에 사용자에게 즉시 알린다.
## 11. 2026-07-01 분석도구 popup 조회조건 처리구역 우선 전환
- InftwtResultPopupPage의 조회조건 selector도 CtpvSggSwtrmtZoneSelectBox로 교체했다.
- 팝업 목록 조회는 저장 구조를 유지하기 위해 여전히 swtrmtFcltNm으로 필터링하되, 사용자가 선택하는 순서는 하수처리구역명 -> 하수처리시설명 자동 표시로 맞췄다.
## 12. 2026-07-01 popup 하이라이트 렌더 보강
- useInftwtResultMap은 처리구역 focus 시 현재 처리구역 extent 안에 결과 관로가 하나도 없으면 결과 관로 extent로 자동 fallback 하도록 보강했다.
- zone/pipe source와 layer에 changed, setVisible(true),
enderSync를 호출해 overlay가 바로 그려지도록 보강했다.
- 브라우저 콘솔에는 침입수 FE overlay 렌더 결과 로그를 남겨 zone/pipe feature count를 확인할 수 있게 했다.
## 13. 2026-07-01 시설명 기준 복귀와 시도/시군구 조회 이동
- InftwtResultPopupPage 조회조건은 처리구역 selector를 제거하고 다시 CtpvSggPrcsFcltSelectBox 기반 하수처리시설명 선택 구조로 복귀했다.
- 팝업 다운로드 버튼은 상단 헤더가 아니라 조회 버튼 옆으로 이동시켜, 실제 결과 조회와 내려받기 동선을 한 줄에 맞췄다.
- row click 직후 지도는 처리구역 extent fit 대신 SN_INFTWT_PROC_SEARCH 이벤트로 메인 상단 검색바의 시도/시군구/시설명 상태를 동기화하고, 검색바와 같은 시도/시군구 중심 이동 규칙을 우선 사용한다.
- 처리구역 우선 selector 실험용 CtpvSggSwtrmtZoneSelectBox.tsx는 현재 구현에서 더 이상 참조되지 않아 정리했다.
## 14. 2026-07-01 I/I 분석 legacy 결과 탭 임시 숨김
- 사용자 요청 기준으로 좌측 `I/I 분석` 메뉴의 `침입수 분석결과` 항목은 현재 화면에서 숨겼다.
- 숨김 방식은 공통 LNB 데이터 구조를 다시 건드리지 않고, `FE_APP_ROOT/src/app/styles/components.css`에서 해당 서브메뉴 항목만 표시하지 않도록 제한했다.
- 이 보정은 메뉴 노출만 감추는 수준이며, 기존 `ii/result` route와 화면 코드는 유지한다.
+4
View File
@@ -18,3 +18,7 @@
@apply w-[200px] h-[40px] px-3 py-2 border border-[#d1d5db] rounded-md bg-white text-sm cursor-pointer;
}
}
/* `I/I 분석 > 침입수 분석결과`는 나중에 다시 사용할 수 있도록 라우트는 유지하고 LNB에서만 임시 숨김 처리한다. */
[data-sidebar="menu"] > *:nth-child(2) [data-sidebar="menu-sub"] > li:nth-child(2) {
display: none;
}
@@ -11,4 +11,4 @@ export class MenuApiService implements MenuService {
}
}
export const MenuApi = new MenuApiService();
export const MenuApi = new MenuApiService();
@@ -46,11 +46,21 @@ export const windowPopupDefinitions = {
width: 1280,
height: 980,
},
inftwtResultPopup: {
key: "inftwtResultPopup",
title: "침입수 분석결과",
width: 1280,
height: 900,
},
infilChart: {
key: "infilChart",
title: "침입수 분석",
width: 1360,
height: 980,
width: 940,
height: 860,
placement: "browser-left",
alignTop: true,
offsetLeft: 0,
offsetTop: 70,
requiredParams: ["pplMngNo"],
},
rdiiChart: {
@@ -4,6 +4,7 @@ import AgedPipePopupContent from "./ui/AgedPipePopupContent";
import FloodPreTablePopupContent from "./ui/FloodPreTablePopupContent";
import FloodPreUploadPopupContent from "./ui/FloodPreUploadPopupContent";
import InfilChartPopupContent from "./ui/InfilChartPopupContent";
import InftwtResultPopupContent from "./ui/InftwtResultPopupContent";
import OldPpUploadPopupContent from "./ui/OldPpUploadPopupContent";
import OldPpTablePopupContent from "./ui/OldPpTablePopupContent";
import RdiiChartPopupContent from "./ui/RdiiChartPopupContent";
@@ -60,6 +61,10 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.mapSearch,
component: SearchModalPopupContent,
},
inftwtResultPopup: {
...windowPopupDefinitions.inftwtResultPopup,
component: InftwtResultPopupContent,
},
infilChart: {
...windowPopupDefinitions.infilChart,
component: InfilChartPopupContent,
@@ -112,4 +117,4 @@ export const windowPopupRegistry = {
...windowPopupDefinitions.flowRiskPopup,
component: WaterRiskPopupContent,
},
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
} as const satisfies Record<WindowPopupKey, WindowPopupRegistryItem>;
@@ -8,6 +8,10 @@ export default function InfilChartPopupContent({
searchParams,
}: WindowPopupContentProps) {
const pplMngNo = getPopupParamValue(searchParams.pplMngNo);
const resultOnly = getPopupParamValue(searchParams.resultOnly) === "Y";
const aplcnYm = getPopupParamValue(searchParams.aplcnYm);
const analysisInfo = getPopupParamValue(searchParams.analysisInfo);
const inftwtAnlsSn = Number(getPopupParamValue(searchParams.inftwtAnlsSn) || "0");
if (!pplMngNo) {
return (
@@ -18,5 +22,13 @@ export default function InfilChartPopupContent({
);
}
return <InfilChart pplMngNo={pplMngNo} />;
}
return (
<InfilChart
pplMngNo={pplMngNo}
inftwtAnlsSn={Number.isFinite(inftwtAnlsSn) ? inftwtAnlsSn : 0}
aplcnYm={aplcnYm}
analysisInfo={analysisInfo}
resultOnly={resultOnly}
/>
);
}
@@ -0,0 +1,19 @@
"use client";
import { useEffect, useState } from "react";
import InftwtResultPopupPage from "@/app/(map-layout)/map/components/inftwtResult/InftwtResultPopupPage";
export default function InftwtResultPopupContent() {
const [isMounted, setIsMounted] = useState(false);
useEffect(() => {
setIsMounted(true);
}, []);
if (!isMounted) {
return <div className="h-dvh bg-white" suppressHydrationWarning />;
}
return <InftwtResultPopupPage />;
}
@@ -3,7 +3,7 @@ import { SelectBox, type SelectDataType } from "../../../ui";
import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
import GisApp from "../lib/gis.core";
import { moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
import { getSigunguCenter, moveMapToCenter, sidoCenter, sigunguCenter } from "./utils";
// import { getGeometryCenter } from "./utils";
const geoserverUrl =
@@ -13,6 +13,7 @@ const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt: geoserverUrl,
};
const DEFAULT_SWAGE_PRCS_FCLT_LABEL = "하수처리시설";
const INFTWT_PROC_SEARCH_EVENT = "SN_INFTWT_PROC_SEARCH";
type FacilityLayerFilter = {
label: string;
@@ -52,6 +53,7 @@ export const ProcSearchBar = ({
{ text: facilityLayerFilter?.label ?? DEFAULT_SWAGE_PRCS_FCLT_LABEL, val: "0" },
]);
const [facilityValue, setFacilityValue] = useState("0");
const [pendingExternalFacilityName, setPendingExternalFacilityName] = useState("");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0] ? facilityTypes[0].val : "0",
@@ -67,9 +69,9 @@ export const ProcSearchBar = ({
const list = (res.features || [])
.map((f: any) => ({
text: f.properties.mctpv_korn_nm,
val: f.properties.mctpv_cd,
center: sidoCenter[f.properties.mctpv_cd?.trim()] //getGeometryCenter(f.geometry) ?? undefined,
text: String(f.properties.mctpv_korn_nm ?? "").trim(),
val: String(f.properties.mctpv_cd ?? "").trim(),
center: sidoCenter[String(f.properties.mctpv_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
}))
.sort((a: any, b: any) => {
// 커스텀 순서 정의
@@ -118,9 +120,9 @@ export const ProcSearchBar = ({
const list = (res.features || [])
.map((f: any) => ({
text: f.properties.sgg_korn_nm,
val: f.properties.sgg_cd,
center: sigunguCenter[f.properties.sgg_cd?.trim()] //getGeometryCenter(f.geometry) ?? undefined,
text: String(f.properties.sgg_korn_nm ?? "").trim(),
val: String(f.properties.sgg_cd ?? "").trim(),
center: sigunguCenter[String(f.properties.sgg_cd ?? "").trim()] //getGeometryCenter(f.geometry) ?? undefined,
}))
.sort((a: any, b: any) => a.text.localeCompare(b.text, "ko"));
@@ -241,6 +243,17 @@ export const ProcSearchBar = ({
setFacilityValue("0");
}, [defaultFacilityData, facilityLayerFilter?.cqlFilter, facilityLayerFilter?.disabled, facilityType, fetchFacilitiesBySggNm, sidoData, sidoValue, sigunData, sigunValue]);
useEffect(() => {
if (!pendingExternalFacilityName) return;
const matchedFacility = facilityData.find(
(item) => String(item.text ?? "").trim() === pendingExternalFacilityName,
);
setFacilityValue(matchedFacility?.val ?? "0");
setPendingExternalFacilityName("");
}, [facilityData, pendingExternalFacilityName]);
const initializeSigunData = useCallback(
(nextSidoCd: string) => {
if (nextSidoCd === "0") {
@@ -251,7 +264,7 @@ export const ProcSearchBar = ({
const prefix = nextSidoCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData],
@@ -323,6 +336,50 @@ export const ProcSearchBar = ({
moveMapToCenter(gisMaps, selectedSido.center, sidoSearchZoom);
}, [gisMaps, sidoValue, sigunValue, sidoData, filteredSigunData, sigunData, sidoSearchZoom]);
useEffect(() => {
if (typeof window === "undefined") return;
const handleExternalSearch = (rawEvent: Event) => {
const event = rawEvent as CustomEvent<{
ctpvCd?: string;
sggCd?: string;
swtrmtFcltNm?: string;
}>;
const nextCtpvCd = String(event.detail?.ctpvCd ?? "0").trim() || "0";
const nextSggCd = String(event.detail?.sggCd ?? "0").trim() || "0";
const nextSwtrmtFcltNm = String(event.detail?.swtrmtFcltNm ?? "").trim();
const nextSidoCenter = sidoCenter[nextCtpvCd.slice(0, 2)];
const nextSigunguCenter = getSigunguCenter(nextSggCd);
setSidoValue(nextCtpvCd);
initializeSigunData(nextCtpvCd);
if (nextCtpvCd !== "0") {
const prefix = nextCtpvCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "시군구", val: "0" }, ...filtered]);
}
setSigunValue(nextSggCd !== "0" ? nextSggCd : "0");
setFacilityValue("0");
setPendingExternalFacilityName(nextSwtrmtFcltNm);
if (nextSggCd !== "0" && nextSigunguCenter) {
moveMapToCenter(gisMaps, nextSigunguCenter, 12);
return;
}
if (nextCtpvCd !== "0" && nextSidoCenter) {
moveMapToCenter(gisMaps, nextSidoCenter, sidoSearchZoom);
}
};
window.addEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
return () => {
window.removeEventListener(INFTWT_PROC_SEARCH_EVENT, handleExternalSearch as EventListener);
};
}, [gisMaps, initializeSigunData, sigunData, sidoSearchZoom]);
const handleFeatureFocus = async (data: any) => {
const layerNm = String(data?.layerNm ?? "").trim();
let featureId = String(data?.fid ?? "").trim();
@@ -382,3 +439,4 @@ export const ProcSearchBar = ({
</div>
);
};