Merge branch 'iipbs/common/main' into iipbs/fo/dev

This commit is contained in:
2025-09-02 16:46:16 +09:00
7 changed files with 54 additions and 375 deletions
@@ -109,6 +109,35 @@ export default function LineBarchart({
const lineMaxValue = getMaxValue(chartData, 'lineVal');
const barMaxValue = getMaxValue(chartData, 'barVal');
/**
* 최대값과 구간 수에 따른 범례 만들기
* maxValue : 최대값, segments : 구간 수 (구간수가 5면 눈금은 6)
*/
const makeNiceScale = (maxValue: number, segments = 5) => {
// 최대값이 이상할 경우 0, 0으로 반환
if (!isFinite(maxValue) || maxValue <= 0) {
return { max: 0, ticks: [0] };
}
const exponent = Math.floor(Math.log10(maxValue));
const base = Math.pow(10, exponent);
const fraction = maxValue / base;
let niceFraction: number;
if (fraction <= 1) niceFraction = 1;
else if (fraction <= 2) niceFraction = 3;
else if (fraction <= 5) niceFraction = 5;
else niceFraction = 10;
const niceMax = niceFraction * base; // round up to 1/2/5 * 10^k
const step = niceMax / segments;
const ticks = Array.from({ length: segments + 1 }, (_, i) => Number((i * step).toFixed(10)));
return { max: niceMax, ticks };
};
const leftScale = makeNiceScale(lineMaxValue, 5);
const rightScale = makeNiceScale(barMaxValue, 5);
const getTicks = (data : any []) => {
const monthSet = new Set<string>();
const rtnData : string[] = [];
@@ -182,23 +211,26 @@ export default function LineBarchart({
{yFieldText && <YAxis
yAxisId="left"
className="text-[12px] font-bold"
tickCount={6}
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
domain={[0, leftScale.max]}
ticks={leftScale.ticks}
orientation="left"
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
/>}
{/* 오른쪽 Y축: barVal */}
{yFieldText2 && <YAxis
yAxisId="right"
className="text-[12px] font-bold"
tickCount={6}
orientation="right"
tickFormatter={(value) =>
typeof value === "number" ? value.toLocaleString() : value
}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="right" />}
domain={[0, rightScale.max]}
ticks={rightScale.ticks}
label={(props) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
reversed={true}
/>}
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
@@ -52,7 +52,6 @@ function Gis({
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
orderedLayers = [],
checkInterval = 3000,
height: height = "h-[calc(100vh-5rem)]",
onOpenPopup = (data) => { },
@@ -290,7 +289,6 @@ function Gis({
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
targetLayerTypes={targetLayerTypes}
orderedLayers={orderedLayers}
checkInterval={checkInterval}
onProgressChange={handleProgressChange}
/>
@@ -39,7 +39,6 @@ export default React.memo(function GisTree({
isPlaying = false,
setIsPlaying = () => { },
targetLayerTypes = [],
orderedLayers = [],
checkInterval = 3000,
onProgressChange = () => { },
}) {
@@ -313,14 +312,15 @@ export default React.memo(function GisTree({
// orderedLayers 순서를 우선 적용하면서, 타입 필터에 맞는 타겟 노드만 선별
const getTargetNodesInOrder = () => {
const orderedNodes = [];
orderedLayers.forEach((lyrSn) => {
const node = nodes.find(
(n) => n.id === lyrSn && layerTypes.includes(n.data?.lyrTyNm)
);
if (node) orderedNodes.push(node);
const target = nodes.filter((n) => n.data?.actlLyrYn === 'N' && layerTypes.includes(n.data?.lyrTyNm));
// drawOrdr 값을 기준으로 오름차순 정렬
target.sort((a, b) => {
const aOrder = a.data?.drawOrdr || 0;
const bOrder = b.data?.drawOrdr || 0;
return aOrder - bOrder;
});
return orderedNodes;
return target;
};
const targetNodes = getTargetNodesInOrder();
@@ -510,8 +510,8 @@ export default React.memo(function GisTree({
{/* 사이드 패널 */}
<div
className={`absolute left-0 top-0 z-40 flex h-full w-[250px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300 ${visible
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
? "translate-x-0 opacity-100"
: "pointer-events-none -translate-x-full opacity-0"
}`}
>
<div
@@ -1,341 +0,0 @@
"use client";
import { SelectBox, type SelectDataType } from "../../../ui";
import { Button } from "@common/components/ui";
import { useCallback, useEffect, useState } from "react";
const geoserverUrl =
"/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&outputFormat=application/json";
const FACILITY_TYPE_URL_MAP: Record<string, string> = {
tb_il_swage_prcs_fclt: geoserverUrl, //하수처리시설
tb_wr_bdst_ruse_fclt:
"/reuse-service/reuseFc/pubDetailList/{year}/{sggCd}/1/1000", //재이용시설 post
tb_wr_rnwtr_utztn_fclt: "/reuse-service/reuseFc/rainyInfoList/1/1000/{sggCd}", //빗물이용시설 get
tb_wr_rswtr_fclt: "/reuse-service/reuseFc/waterreuseFc/1/1000/{sggCd}", //중수도시설 get
tb_wr_htdfl_fclt: "/reuse-service/drain/facilityList/1/1000/{sggCd}", //온배수시설 get
tb_wr_ruse_fclt_dmd_co: "/reuse-service/reuseFc/request/1/1000/{sggCd}", //수요처 get
tb_wr_tmpr_dmd_co:
"/reuse-service/reuseFc/request/temp/1/1000/{sidoNm} {sggNm}", //잠재수요처 get
};
interface SearchBarProps {
onFeatureSelect: (feature: any) => void;
gisMaps: React.RefObject<any>;
facilityTypes: { text: string; val: string }[];
}
export const ComboSearchBar = ({
onFeatureSelect,
gisMaps,
facilityTypes,
}: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [sidoData, setSidoData] = useState<SelectDataType[]>([
{ text: "선택", val: "0" },
]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([
{ text: "선택", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "선택", val: "0" },
]);
const [facilityData, setFacilityData] = useState<SelectDataType[]>([
{ text: "선택", val: "0" },
]);
const [facilityValue, setFacilityValue] = useState("0");
const [facilityType, setFacilityType] = useState(
facilityTypes?.length === 1 && facilityTypes[0]
? facilityTypes[0].val
: "0",
);
//시도, 시군구 조회
const fetchSido = async () => {
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_ctpv&outputFormat=application/json&propertyName=mctpv_korn_nm,mctpv_cd`;
const res = await fetch(url).then(res => res.json());
const list = res.features
.map((f: any) => ({
text: f.properties.mctpv_korn_nm,
val: f.properties.mctpv_cd,
})).sort((a, b) => {
// 커스텀 순서 정의
const customOrder = [
"서울특별시",
"부산광역시",
"대구광역시",
"인천광역시",
"광주광역시",
"대전광역시",
"울산광역시",
"세종특별자치시",
"경기도",
"강원특별자치도", // 경기도 다음에 배치
"충청북도",
"충청남도",
"전북특별자치도", // 충청남도 다음에 배치
"전라남도",
"경상북도",
"경상남도",
"제주특별자치도"
];
const indexA = customOrder.indexOf(a.text);
const indexB = customOrder.indexOf(b.text);
// 둘 다 커스텀 순서에 있으면 해당 순서로
if (indexA !== -1 && indexB !== -1) {
return indexA - indexB;
}
// 하나만 커스텀 순서에 있으면 그것을 앞으로
if (indexA !== -1) return -1;
if (indexB !== -1) return 1;
// 둘 다 커스텀 순서에 없으면 기본 알파벳 순서
return a.text.localeCompare(b.text, 'ko');
});
setSidoData([{ text: "선택", val: "0" }, ...list]);
};
const fetchSigungu = async () => {
const url = `/geoserver/hasudo/ows?service=WFS&version=1.0.0&request=GetFeature&typeName=hasudo:tb_ms_sgg&outputFormat=application/json&propertyName=sgg_cd,sgg_korn_nm`;
const res = await fetch(url).then(res => res.json());
const list = res.features
.map((f: any) => ({
text: f.properties.sgg_korn_nm,
val: f.properties.sgg_cd,
}))
.sort((a, b) => a.text.localeCompare(b.text, 'ko'));
setSigunData(list);
};
//시설명 콤보박스 조회
const fetchFacilitiesBySggNm = async (
sggCd: string,
sggNm: string,
sidoNm: string,
currentFacilityType?: string,
) => {
const type = currentFacilityType ?? facilityType;
if (!type || type === "0") return;
const baseUrlTemplate = FACILITY_TYPE_URL_MAP[type];
if (!baseUrlTemplate) return;
const year = new Date().getFullYear().toString();
let url = baseUrlTemplate
.replace("{year}", year)
.replace("{sggCd}", sggCd)
.replace("{sidoNm}", encodeURIComponent(sidoNm))
.replace("{sggNm}", encodeURIComponent(sggNm));
let list: SelectDataType[] = [];
if (
type === "tb_il_swage_prcs_fclt" //하수처리시설
) {
const cqlFilter = `ctpv_nm='${encodeURIComponent(sidoNm)}' AND sgg_nm='${encodeURIComponent(sggNm)}'`;
url = `${url}&typeName=hasudo:${type}&cql_filter=${cqlFilter}`;
const res = await fetch(url);
const json = await res.json();
list = json.features.map((f: any) => ({
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
val: f.id,
}));
} else if (
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
) {
//재용에서 참조하는 하수처리시설 목록
const paramsRes = await fetch("/reuse-service/reuseFc/pubFc");
const paramsJson = await paramsRes.json();
const params = paramsJson.data;
const res = await fetch(url, {
method: "POST",
headers: {
accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.ruseUtztnFcltNm ?? "(이름없음)",
val: item.pbnstRuseFcltId,
}));
} else if (
type === "tb_wr_ruse_fclt_dmd_co" //수요처
) {
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.dmdInfoId,
}));
} else if (
type === "tb_wr_tmpr_dmd_co" //잠재수요처
) {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.dmdCoNm ?? "(이름없음)",
val: item.fctryMngSn,
}));
} else {
// GET 방식
const res = await fetch(url);
const json = await res.json();
list = (json.data || []).map((item: any) => ({
text: item.bdstNm ?? "(이름없음)",
val: item.bdstInfoSn,
}));
}
setFacilityData([{ text: "선택", val: "0" }, ...list]);
setFacilityValue("0");
};
//시도, 시군구 초기 데이터 조회
useEffect(() => {
fetchSido();
fetchSigungu();
}, []);
//시군구 데이터 초기화
const initializeSigunData = useCallback(
(sidoCd: string) => {
if (sidoCd === "0") {
setFilteredSigunData([{ text: "선택", val: "0" }]);
setSigunValue("0");
return;
}
const prefix = sidoCd.substring(0, 2);
const filtered = sigunData.filter((item) => item.val.startsWith(prefix));
setFilteredSigunData([{ text: "선택", val: "0" }, ...filtered]);
setSigunValue("0");
},
[sigunData],
);
//콤보박스 선택시 연관 콤보박스 처리
const onSelectValue = useCallback(
(val: string, type: string) => {
if (type === "ctpvCd") {
setSidoValue(val);
initializeSigunData(val);
setFacilityData([{ text: "선택", val: "0" }]);
setFacilityValue("0");
} else if (type === "sggCd") {
setSigunValue(val);
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm = sigunData.find((item) => item.val === val)?.text || "";
const sggCd = sigunData.find((item) => item.val === val)?.val || "";
if (sggNm && sidoNm)
fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, facilityType);
} else if (type === "facilityType") {
setFacilityType(val);
setFacilityValue("0");
setFacilityData([{ text: "선택", val: "0" }]);
const sidoNm =
sidoData.find((item) => item.val === sidoValue)?.text || "";
const sggNm =
sigunData.find((item) => item.val === sigunValue)?.text || "";
const sggCd =
sigunData.find((item) => item.val === sigunValue)?.val || "";
if (sggNm && sidoNm) fetchFacilitiesBySggNm(sggCd, sggNm, sidoNm, val);
} else if (type === "fcltCd") {
setFacilityValue(val);
}
},
[initializeSigunData, sigunData, sigunValue, sidoData, sidoValue],
);
//조회 버튼 클릭
const handleSearch = () => {
if (!gisMaps.current || facilityValue === "0") return;
const layers = gisMaps.current.getLayers().getArray();
for (const layer of layers) {
const source = layer.getSource?.();
const feature = source?.getFeatureById?.(facilityValue);
if (feature) {
onFeatureSelect(feature);
return;
}
}
alert("해당 처리시설을 지도에서 찾을 수 없습니다.");
};
return (
<div className="grid grid-cols-4 gap-4">
<div className="col-span-4">
<div className="card card-border flex">
<div className="col-span-10 flex flex-row">
{facilityTypes.length > 1 && (
<SelectBox
labelClass="w-[120px]"
className="mr-10 w-[190px]"
selectClass="rounded-lg bg-white"
label="시설물유형"
selectArray={[{ text: "선택", val: "0" }, ...facilityTypes]}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "facilityType")
}
value={facilityType}
/>
)}
<SelectBox
labelClass="w-[40px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={sidoData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "ctpvCd")
}
value={sidoValue}
/>
<SelectBox
labelClass="w-[65px]"
className="mr-5 w-[150px]"
selectClass="rounded-lg bg-white"
label=""
selectArray={filteredSigunData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "sggCd")
}
value={sigunValue}
/>
<SelectBox
labelClass="w-[60px]"
className="mr-3 w-[180px]"
selectClass="rounded-lg bg-white"
label="시설명"
selectArray={facilityData}
onSelectValue={(val: any) =>
onSelectValue(val.toString(), "fcltCd")
}
value={facilityValue}
/>
</div>
<div className="flex flex-row space-x-2">
<Button
color="primary"
className="mx-2 ml-5 mt-1"
size={"sm"}
onClick={handleSearch}
>
조회
</Button>
</div>
</div>
</div>
</div>
);
};
@@ -1,3 +1,2 @@
export * from "./comboSearchBar";
export * from "./procSearchBar";
export * from './searchBar'
@@ -18,19 +18,10 @@ interface SearchBarProps {
export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
const [sidoValue, setSidoValue] = useState("0");
const [sigunValue, setSigunValue] = useState("0");
const [sidoData, setSidoData] = useState<SelectDataType[]>([
{ text: "시도", val: "0" },
]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([
{ text: "시군구", val: "0" },
]);
const [facilityData, setFacilityData] = useState<SelectDataType[]>([
{ text: "하수처리시설", val: "0" },
]);
const [sidoData, setSidoData] = useState<SelectDataType[]>([{ text: "시도", val: "0" }]);
const [sigunData, setSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
const [filteredSigunData, setFilteredSigunData] = useState<SelectDataType[]>([{ text: "시군구", val: "0" }]);
const [facilityData, setFacilityData] = useState<SelectDataType[]>([{ text: "하수처리시설", val: "0" }]);
const [facilityValue, setFacilityValue] = useState("0");
const [facilityType, setFacilityType] = useState(
@@ -48,7 +39,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
text: f.properties.mctpv_korn_nm,
val: f.properties.mctpv_cd,
}))
.sort((a, b) => {
.sort((a: any, b: any) => {
// 커스텀 순서 정의
const customOrder = [
"서울특별시",
@@ -97,7 +88,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
text: f.properties.sgg_korn_nm,
val: f.properties.sgg_cd,
}))
.sort((a, b) => a.text.localeCompare(b.text, 'ko'));
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSigunData(list);
};
@@ -135,7 +126,7 @@ export const ProcSearchBar = ({ gisMaps, facilityTypes }: SearchBarProps) => {
text: f.properties.swtrmt_fclt_nm ?? "(이름없음)",
val: f.id,
}))
.sort((a, b) => a.text.localeCompare(b.text, 'ko'));
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
} else if (
type === "tb_wr_bdst_ruse_fclt" // 재이용시설
) {
@@ -61,7 +61,7 @@ export const SearchBar = ({
const sidoList = json
.filter((item: any) => item.lwpoCd?.length === 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
.sort((a, b) => {
.sort((a: any, b: any) => {
// 커스텀 순서 정의
const customOrder = [
"서울특별시",
@@ -101,7 +101,7 @@ export const SearchBar = ({
const sigunList = json
.filter((item: any) => item.lwpoCd?.length > 2)
.map((item: any) => ({ val: item.lwpoCd, text: item.lwpoCdNm }))
.sort((a, b) => a.text.localeCompare(b.text, 'ko'));
.sort((a: any, b: any) => a.text.localeCompare(b.text, 'ko'));
setSidoData([{ text: "시도", val: "0" }, ...sidoList]);
setSigunData(sigunList);
};