This commit is contained in:
2025-12-17 14:39:58 +09:00
10 changed files with 476 additions and 217 deletions
+106 -29
View File
@@ -6,6 +6,7 @@ import { ENDPOINT } from "@/shared/config";
import type { OperWqltContent, OperFcltContent, ReuseStatusContent, OperationService
} from "../model/fo-interface";
import type { mapOperFcltItem, mapOperFcltType } from "../model/fo-interface";
import type { ReuseMapContent, mapReuseMapType } from "../model/fo-interface";
// 기준년도 조회
export type YearState = {
@@ -52,34 +53,35 @@ export type OperFcltStateActions = {
setOperFcltStatus: (operFcltStatus: OperFcltContent[] | null) => void;
};
export type OperFcltStore = OperFcltState & OperFcltStateActions;
const initialOperFclt: OperFcltContent[]= [
{
"cnt": 710,
"fctCpc": 2911609.0,
"rvbsn": "금강",
"gubun": "R"
},
{
"cnt": 1032,
"fctCpc": 7506047.0,
"rvbsn": "낙동강",
"gubun": "R"
},
{
"cnt": 1261,
"fctCpc": 2061610.0,
"rvbsn": "영산강",
"gubun": "R"
},
{
"cnt": 983,
"fctCpc": 1.3229809E7,
"rvbsn": "한강",
"gubun": "R"
}
];
// const initialOperFclt: OperFcltContent[]= [
// {
// "cnt": 710,
// "fctCpc": 2911609.0,
// "rvbsn": "금강",
// "gubun": "R"
// },
// {
// "cnt": 1032,
// "fctCpc": 7506047.0,
// "rvbsn": "낙동강",
// "gubun": "R"
// },
// {
// "cnt": 1261,
// "fctCpc": 2061610.0,
// "rvbsn": "영산강",
// "gubun": "R"
// },
// {
// "cnt": 983,
// "fctCpc": 1.3229809E7,
// "rvbsn": "한강",
// "gubun": "R"
// }
// ];
export const defaultInitOperFcltState: OperFcltState = {
operFcltStatus: initialOperFclt,
// operFcltStatus: initialOperFclt,
operFcltStatus: null,
};
export const createOperFcltStore = (initState: OperFcltState = defaultInitOperFcltState) =>
createStore<OperFcltStore>()((set) => ({
@@ -106,6 +108,24 @@ export const useMapOperFcltStore = create<mapOperFcltState>((set) => ({
setMapOperFclt: (newMapOperFclt) => set({ mapOperFclt: newMapOperFclt }),
}));
export const initailMapReuseMapData: mapReuseMapType = {
colTitle: {reuseAmnt: "하수재이용량",cnt : "시설수" }, //헤더 영역
data: [
{name: "금강", reuseAmnt: 17099, cnt: 3181},
{name: "한강", reuseAmnt: 17099, cnt: 3181},
{name: "낙동강", reuseAmnt: 14745, cnt: 3100},
{name: "영산강", reuseAmnt: 5073, cnt: 1662},
]
};
type mapReuseMapState = {
mapData: ReuseMapContent[];
setMapData: (mapData: ReuseMapContent[]) => void;
};
export const useMapReuseMapStore = create<mapReuseMapState>((set) => ({
mapData: initailMapReuseMapData.data,
setMapData: (newMapOperFclt) => set({ mapData: newMapOperFclt }),
}));
/**
* OperFcltContent[]를 지도 팝업 데이터 형태인 mapOperFcltItem[]으로 변환합니다.
* @param data API 응답 데이터
@@ -196,6 +216,45 @@ export const createReuseStore = (initState: ReuseState = defaultInitReuseState)
}));
export const reuseStore = createReuseStore();
/**
* 두 객체 또는 배열의 깊은 비교를 수행합니다.
* @param obj1 비교할 첫 번째 객체
* @param obj2 비교할 두 번째 객체
* @returns 두 객체의 내용이 같으면 true, 그렇지 않으면 false
*/
// function deepEqual(obj1: any, obj2: any): boolean {
// if (obj1 === obj2) {
// return true;
// }
// if (obj1 == null || typeof obj1 !== 'object' || obj2 == null || typeof obj2 !== 'object') {
// return false;
// }
// const keys1 = Object.keys(obj1);
// const keys2 = Object.keys(obj2);
// if (keys1.length !== keys2.length) {
// return false;
// }
// for (const key of keys1) {
// if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) {
// return false;
// }
// }
// // 배열인 경우 순서와 내용이 모두 동일한지 확인
// if (Array.isArray(obj1) && Array.isArray(obj2)) {
// if (obj1.length !== obj2.length) return false;
// for (let i = 0; i < obj1.length; i++) {
// if (!deepEqual(obj1[i], obj2[i])) return false;
// }
// }
// return true;
// }
export class OperationApiService implements OperationService {
@@ -248,7 +307,7 @@ export class OperationApiService implements OperationService {
return list;
}
// 수계별 시설운영 개소수 및 가동률
// 수계별 유입ㆍ방류수질 분석
public async getDashOperWqlt(year: string): Promise<OperWqltContent[]> {
const response = await client.get<{
@@ -293,7 +352,25 @@ export class OperationApiService implements OperationService {
return list;
}
// 수계별 하수재이용량, 시설수
public async getReuseMap(year: string): Promise<ReuseMapContent[]> {
const response = await client.get<{
status: number;
data: ReuseMapContent[];
count: number;
}>(ENDPOINT.OPERATION_SERVICE.REUSE_MAP, {
params: {
year,
},
});
const list = (response.data.data ?? []).map((item) => ({
...item,
}));
return list;
}
}
export const OperationApi = new OperationApiService();
+80 -25
View File
@@ -3,7 +3,7 @@ import { client } from "@/shared/api";
import { create } from "zustand";
import { ENDPOINT } from "@/shared/config";
import type { PplLengthContent, SewerageService } from "../model/sn-interface";
import type { OldPplChartData } from "../model/sn-interface";
import type { OldPplChartData,mapPplOldType,mapPplOldItem } from "../model/sn-interface";
export type SnState = {
snStatus: PplLengthContent[] | null;
@@ -13,44 +13,44 @@ export type SnStateActions = {
};
export type SnStore = SnState & SnStateActions;
const initialUsePplChartData: OldPplChartData={
"labels": ["금강","한강","낙동강", "영산강"],
"datasets": [
{
"label": "기타",
"data": [1966,15480,6957,1936],
"backgroundColor": "rgba(255, 178, 150, 0.9)"
},
{
"label": "오수관로",
"data": [8074,15879,16337,5671],
"backgroundColor": "rgba(180, 240, 190, 0.95)"
},
{
"label": "우수관로",
"data": [4838,12893,10492,4281],
"backgroundColor": "rgba(170, 210, 245, 0.95)"
}
]
};
const initialOldPplChartData: OldPplChartData={
labels: [
"금강",
"한강",
"낙동강",
"영산강"
],
labels: ["금강","한강","낙동강","영산강"],
datasets: [
{
label: "20-25년",
data: [
1140,
3181,
3100,
1662
],
data: [ 1140,3181,3100,1662],
backgroundColor: "#3B82F6",
borderRadius: 4
},
{
label: "25-30년",
data: [
1391,
3615,
1814,
1411
],
data: [1391, 3615, 1814, 1411],
backgroundColor: "#EF4444",
borderRadius: 4
},
{
label: "30년이상",
data: [
2014,
10303,
9831,
2000
],
data: [ 2014, 10303,9831,2000],
backgroundColor: "#F87171",
borderRadius: 4
}
@@ -64,6 +64,61 @@ export const useOldPplStore = create<OldPplStoreState>((set) => ({
chartData: initialOldPplChartData,
setChartData: (chartData) => set(() => ({ chartData })),
}));
export const useUsePplStore = create<OldPplStoreState>((set) => ({
chartData: initialUsePplChartData,
setChartData: (chartData) => set(() => ({ chartData })),
}));
export const initailMapOldPplData: mapPplOldType = {
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
data: [
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
]
};
type mapPplOldState = {
mapData: mapPplOldItem[];
setMapData: (mapData: mapPplOldItem[]) => void;
};
export const useMapOldPplStore = create<mapPplOldState>((set) => ({
mapData: initailMapOldPplData.data,
setMapData: (newMapData) => set({ mapData: newMapData }),
}));
/**
* OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다.
* @param chartData 차트 데이터
* @returns 유역별 노후관로 현황 데이터
*/
export function transformChartDataToMapData(chartData: OldPplChartData): mapPplOldItem[] {
const mapData: mapPplOldItem[] = [];
const { labels, datasets } = chartData;
const pplLen2025Data = datasets.find(d => d.label === '20-25년')?.data || [];
const pplLen2530Data = datasets.find(d => d.label === '25-30년')?.data || [];
const pplLen30Data = datasets.find(d => d.label === '30년이상')?.data || [];
labels.forEach((label, index) => {
const pplLen2025 = pplLen2025Data[index] || 0;
const pplLen2530 = pplLen2530Data[index] || 0;
const pplLen30 = pplLen30Data[index] || 0;
const totalLen = pplLen2025 + pplLen2530 + pplLen30;
mapData.push({
name: label,
totalLen,
pplLen2025,
pplLen2530,
pplLen30,
});
});
return mapData;
}
export class SewerageApiService implements SewerageService {
// 기간별 관로 총길이
@@ -2,15 +2,18 @@
/**
* 유역 지도
*/
import React, { useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useStore } from "zustand";
import styles from "./korea-basin-map.module.css"; // 추가
import { basinStore } from "../api/fo-service";
import { basinKeyMap,MANUAL_LABEL_POS } from "../model/map-data";
import { BASIN_DATA, BASIN_IDS } from "../model/map-data";
import type { mapOperFcltType } from "../model/fo-interface";
import { useMapOperFcltStore } from "../api/fo-service";
// import {Button} from "@common/components";
import { svcInfo } from "../model/map-data";
import type { mapOperFcltType, mapReuseMapType} from "../model/fo-interface";
import type { mapPplOldType } from "../model/sn-interface";
import { useMapOperFcltStore, OperationApi, useMapReuseMapStore } from "../api/fo-service";
import { useMapOldPplStore } from "../api/sn-service";
import {Button} from "@common/components";
const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요
@@ -21,28 +24,143 @@ function fmt(n: number) {
const mapOperFcltData: mapOperFcltType = {
colTitle: {cnt:"개소수",rt:"가동률"}, //헤더 영역
data: [
// {name: "금강", cnt: 710, rt: 100,},
// {name: "한강", cnt: 983, rt: 100,},
// {name: "낙동강", cnt: 1032, rt: 100,},
// {name: "영산강", cnt: 1261, rt: 100,},
{name: "금강", cnt: 710, rt: 100,},
{name: "한강", cnt: 983, rt: 100,},
{name: "낙동강", cnt: 1032, rt: 100,},
{name: "영산강", cnt: 1261, rt: 100,},
]
};
const mapPplOldData: mapPplOldType = {
colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역
data: [
{name: "금강", totalLen: 4545, pplLen2025: 1140, pplLen2530: 1391, pplLen30 : 2014},
{name: "한강", totalLen: 17099, pplLen2025: 3181, pplLen2530: 3615, pplLen30 : 10303},
{name: "낙동강", totalLen: 14745, pplLen2025: 3100, pplLen2530: 1814, pplLen30 : 9831},
{name: "영산강", totalLen: 5073, pplLen2025: 1662, pplLen2530: 1411, pplLen30 : 2000},
]
};
const mapReuseMapData: mapReuseMapType = {
colTitle: {reuseAmnt: "하수재이용량",cnt : "시설수" }, //헤더 영역
data: [
{name: "금강", reuseAmnt: 17099, cnt: 3181},
{name: "한강", reuseAmnt: 17099, cnt: 3181},
{name: "낙동강", reuseAmnt: 14745, cnt: 3100},
{name: "영산강", reuseAmnt: 5073, cnt: 1662},
]
};
export function KoreaBasinMap() {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
const labelsLayerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const popupTitleRef = useRef<HTMLDivElement>(null);
const popupHeader1Ref = useRef<HTMLDivElement>(null);
const popupHeader2Ref = useRef<HTMLDivElement>(null);
const popupData1Ref = useRef<HTMLDivElement>(null);
const popupData2Ref = useRef<HTMLDivElement>(null);
// const [services, setServices] = useState(svcInfo);
const [services, setServices] = useState(svcInfo);
const [popupContent, setPopupContent] = useState<{ title: string; items: { header: string; data: string }[] }>({
title: '',
items: [],
});
const { selectedBasin, setSelectedBasin } = useStore(basinStore);
// const { operFcltStatus, setSelectedBasin } = useStore(operFcltStore);
const selectedBasinKey = basinKeyMap[selectedBasin];
const {mapOperFclt} = useMapOperFcltStore();
const {mapData: mapPplOld} = useMapOldPplStore();
const {setMapData: setMapReuseMapData} = useMapReuseMapStore();
mapOperFcltData.data = mapOperFclt;
mapPplOldData.data = mapPplOld;
// mapReuseMapData.data = mapReuseMap;
let standardYear = '2024';
useEffect(() => {
const fetchData = async () => {
try {
standardYear = await OperationApi.getStandardYear();
const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear);
setMapReuseMapData(mapReuseMapRslt);
mapReuseMapData.data = mapReuseMapRslt;
} catch (e) {
console.error("하수재이용량 map 데이터 조회 실패", e);
}
};
fetchData();
}, []);
// 유역정보
const showFor = useCallback((g: SVGGElement) => {
const activeService = services.find(s => s.active);
if (!activeService) return;
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const basinName = BASIN_DATA[key as keyof typeof BASIN_DATA]?.name || (g as SVGGElement & { dataset: { label: string } }).dataset.label;
const newPopupContent: { title: string; items: { header: string; data: string }[] } = { title: '', items: [] };
if (activeService.svcId === 'fo') { // 운영실태
const item = mapOperFcltData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 시설운영 현황`;
newPopupContent.items.push({
header: mapOperFcltData.colTitle.cnt,
data: item ? `${fmt(item.cnt)}개소` : '-',
});
newPopupContent.items.push({
header: mapOperFcltData.colTitle.rt,
data: item ? `${item.rt}%` : '-',
});
} else if (activeService.svcId === 'sn') { // 하수관로
const item = mapPplOldData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`;
const colTitles = mapPplOldData.colTitle;
newPopupContent.items.push({
header: colTitles.totalLen,
data: item ? `${fmt(item.totalLen)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2025,
data: item ? `${fmt(item.pplLen2025)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen2530,
data: item ? `${fmt(item.pplLen2530)}km` : '-',
});
newPopupContent.items.push({
header: colTitles.pplLen30,
data: item ? `${fmt(item.pplLen30)}km` : '-',
});
} else if (activeService.svcId === 'wr') { // 물재이용
const item = mapReuseMapData.data.find(d => d.name === basinName);
newPopupContent.title = `${standardYear}년도 ${basinName}유역 물재이용 현황`;
newPopupContent.items.push({
header: mapReuseMapData.colTitle.reuseAmnt,
data: item ? `${fmt(item.reuseAmnt)}m³` : '-',
});
newPopupContent.items.push({
header: mapReuseMapData.colTitle.cnt,
data: item ? `${fmt(item.cnt)}개소` : '-',
});
}
setPopupContent(newPopupContent);
if (!stageRef.current) return;
const sRect = stageRef.current.getBoundingClientRect(),
r = g.getBoundingClientRect();
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
if (popupRef.current) {
popupRef.current.style.left = cx + "px";
popupRef.current.style.top = ty + 6 + "px";
popupRef.current.style.opacity = "1";
popupRef.current.style.visibility = "visible";
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
}
if (popupRef.current) {
const pRect = popupRef.current.getBoundingClientRect();
const x = Math.max(
12 + pRect.width / 2,
Math.min(cx, sRect.width - 12 - pRect.width / 2),
);
popupRef.current.style.left = x + "px";
}
}, [services, mapOperFclt]);
// selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect
useEffect(() => {
@@ -63,6 +181,21 @@ export function KoreaBasinMap() {
let gs: SVGGElement[] = [];
let labels: HTMLDivElement[] = [];
const clear = () => {
gs.forEach((el) => el.classList.remove("active"));
if (popupRef.current) {
if (styles.show) {
popupRef.current.classList.remove(styles.show);
}
popupRef.current.style.opacity = "0";
}
labels.forEach((l) => l.classList.remove("active"));
};
// const positionLabels = () => {
// // This function's content is moved inside the fetch callback
// };
// console.log("Loading SVG from:", SVG_URL);
fetch(SVG_URL)
.then((r) => {
@@ -146,70 +279,6 @@ export function KoreaBasinMap() {
// // });
// });
// 2) 함수들 먼저 정의
const clear = () => {
gs.forEach((el) => el.classList.remove("active"));
if (popupRef.current) {
if (styles.show) {
popupRef.current.classList.remove(styles.show);
}
popupRef.current.style.opacity = "0";
}
labels.forEach((l) => l.classList.remove("active"));
};
// 유역정보
function showFor(g: SVGGElement) {
// console.log("showFor called for:", g);
const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key;
const basinName = BASIN_DATA[key as keyof typeof BASIN_DATA]?.name || (g as SVGGElement & { dataset: { label: string } }).dataset.label;
const fcltItem = mapOperFcltData.data.find(d => d.name === basinName);
if (popupTitleRef.current) {
popupTitleRef.current.textContent = `2024년도 ${basinName}유역 시설운영 현황`;
}
if (popupHeader1Ref.current) {
popupHeader1Ref.current.textContent = mapOperFcltData.colTitle.cnt;
}
if (popupHeader2Ref.current) {
popupHeader2Ref.current.textContent = mapOperFcltData.colTitle.rt;
}
if (popupData1Ref.current) {
popupData1Ref.current.textContent = fcltItem ? `${fmt(fcltItem.cnt)}개소` : "-";
}
if (popupData2Ref.current) {
popupData2Ref.current.textContent = fcltItem ? `${fcltItem.rt}%` : "-";
}
if (!stageRef.current) return;
const sRect = stageRef.current.getBoundingClientRect(),
r = g.getBoundingClientRect();
const cx = (r.left + r.right) / 2 - sRect.left;
const ty = r.top - sRect.top;
// console.log("Popup position:", { cx, ty });
if (popupRef.current) {
popupRef.current.style.left = cx + "px";
popupRef.current.style.top = ty + 6 + "px";
popupRef.current.style.opacity = "1";
popupRef.current.style.visibility = "visible";
if (styles.show) {
popupRef.current.classList.add(styles.show);
}
// console.log("Added show class to popup:", popupRef.current.classList.toString());
}
if (popupRef.current) {
const pRect = popupRef.current.getBoundingClientRect();
const x = Math.max(
12 + pRect.width / 2,
Math.min(cx, sRect.width - 12 - pRect.width / 2),
);
popupRef.current.style.left = x + "px";
}
}
function showForWithLabel(g: SVGGElement) {
// console.log("showForWithLabel called for:", g);
showFor(g);
@@ -232,7 +301,7 @@ export function KoreaBasinMap() {
}
// 3) 라벨 위치
function positionLabels() {
const positionLabels = () => {
if (!stageRef.current || !svg) return;
const sRect = stageRef.current.getBoundingClientRect();
const inset =
@@ -281,7 +350,7 @@ export function KoreaBasinMap() {
}
labels[i]!.style.left = `${px}px`;
labels[i]!.style.top = `${py + 18}px`;
});
});
}
// 4) 상호작용 (함수들은 이미 위에서 정의됨)
@@ -329,34 +398,36 @@ export function KoreaBasinMap() {
return () => {
window.removeEventListener("resize", () => {});
};
}, []);
}, [selectedBasinKey, showFor]); // 의존성 배열에 mapOperFclt 추가 불필요
// const handleSvcInfoClick = (clickedSvcId: string) => {
// setServices(prevServices =>
// prevServices.map(svc => ({
// ...svc,
// active: svc.svcId === clickedSvcId,
// }))
// );
// };
const handleSvcInfoClick = (clickedSvcId: string) => {
setServices(prevServices =>
prevServices.map(svc => ({
...svc,
active: svc.svcId === clickedSvcId,
}))
);
};
// 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요.
return (
<div className="col-span-3 !p-0 relative z-10" style={{ height: "765px", background: "rgba(255, 255, 255, 0.6)" }}>
<div className="card card-border" style={{ height: "100%" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">유역별 정보</h3>
{/*
{services.map((svc) => (
<Button
key={svc.name}
className="h-[3.2rem]"
color={svc.active ? "primary" : "white"}
onClick={() => handleSvcInfoClick(svc.svcId)}
>
{svc.name}
</Button>
))}
*/}
<div className="flex gap-2">
{services.map((svc) => (
<Button
key={svc.name}
className="h-[3.2rem] float-right"
color={svc.active ? "primary" : "white"}
onClick={() => handleSvcInfoClick(svc.svcId)}
>
{svc.name}
</Button>
))}
</div>
</div>
<div id="mapArea" style={{height: "calc(100% - 40px)"}}>
<div
@@ -399,31 +470,29 @@ export function KoreaBasinMap() {
<div
ref={popupRef}
id="popup"
className={`${styles.popup} absolute z-20 grid w-full font-sans border border-blue-200 rounded-lg shadow-md overflow-hidden opacity-0 invisible bg-blue-50`}
className={`${styles.popup} absolute z-20 font-sans border border-blue-200 rounded-lg shadow-md overflow-hidden opacity-0 invisible bg-blue-50`}
style={{
minWidth: 160,
display: 'grid',
gridTemplateColumns: `repeat(${Math.max(1, popupContent.items.length)}, 1fr)`,
}}
>
{/* 제목 영역 */}
<div ref={popupTitleRef} className="col-span-2 bg-blue-100 text-center font-bold border-b-2 border-blue-200 flex items-center justify-center">
2024년도 000유역 시설운영 현황
<div className="bg-blue-100 text-center font-bold border-b-2 border-blue-200 flex items-center justify-center" style={{ gridColumn: `span ${Math.max(1, popupContent.items.length)}` }}>
{popupContent.title}
</div>
{/* 헤더 영역 */}
<div ref={popupHeader1Ref} className="text-center border-b-2 border-r-2 border-blue-200 flex items-center justify-center">
개소수
</div>
<div ref={popupHeader2Ref} className="text-center border-b-2 border-blue-200 flex items-center justify-center">
가동률
</div>
{/* 데이터 영역 */}
<div ref={popupData1Ref} className="text-center border-r-2 border-blue-200 flex items-center justify-center">
2,134개소
</div>
<div ref={popupData2Ref} className="text-center flex items-center justify-center">
75%
</div>
{/* 헤더 및 데이터 동적 렌더링 */}
{popupContent.items.map((item, index) => (
<div key={`header-${index}`} className={`text-center border-b-2 border-blue-200 flex items-center justify-center ${index < popupContent.items.length - 1 ? 'border-r-2' : ''}`}>
{item.header}
</div>
))}
{popupContent.items.map((item, index) => (
<div key={`data-${index}`} className={`text-center flex items-center justify-center ${index < popupContent.items.length - 1 ? 'border-r-2' : ''}`}>
{item.data}
</div>
))}
</div>
</div>
</div>
@@ -34,6 +34,11 @@ export interface OperWqltContent {
avgEfflTn: number; // 기간 평균 유출수 TN
}
export interface ReuseMapContent {
name: string; // 유역명
reuseAmnt: number; // 하수재이용량
cnt: number; // 시설수
}
export interface ReuseStatusContent {
yr: string;
@@ -63,10 +68,10 @@ export type mapOperFcltType = {
data: mapOperFcltItem[];
};
// function buildCntRt( operFcltStatus: OperFcltContent[]) : mapBasinData => {
// const colTitle = {cnt:"개소수",rt:"가동률"};
// const result: mapBasinData = {};
// }
export type mapReuseMapType = {
colTitle: {
reuseAmnt: string; // 하수재이용량
cnt: string; // 시설수
};
data: ReuseMapContent[];
};
@@ -30,6 +30,25 @@ type SnOldPplLength = {
[key: string]: SnOldPplItem;
};
export type mapPplOldItem = {
name: string;
totalLen: number;
pplLen2025: number;
pplLen2530: number;
pplLen30: number;
};
export type mapPplOldType = {
colTitle: {
totalLen: string; // 총길이
pplLen2025: string; // 20~25년
pplLen2530: string; // 25~30년
pplLen30 : string; // 30년 이상
};
data: mapPplOldItem[];
};
export function buildSnOldPplLength(
chartData: {
labels: string[];
@@ -199,8 +199,8 @@ const FoWqltInflEffl: React.FC = () => {
<h3 className="text-2xl font-medium">수계별 유입ㆍ방류수질 분석</h3>
<BasinSelect selectedBasin={selectedBasin} onValueChange={onBasinChange} />
</div>
<div style={{ width: "100%", maxWidth: 400, height: 180 }}>
<div style={{ padding: '4px', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div className="h-70 p-0 flex items-center justify-center">
<div style={{ width: "100%", maxWidth: 400, height: 180 }}>
{/* 탭 메뉴 */}
<div style={{ display: 'flex', gap: '20px', borderBottom: '1px solid #000000', flexShrink: 0 }}>
{tabs.map((tab) => (
+48 -23
View File
@@ -155,7 +155,7 @@ const ReuseChart: React.FC = () => {
const startSlider = () => {
intervalRef.current = setInterval(() => {
setActiveIndex(prevIndex => (prevIndex + 1) % chartItems.length);
}, 20000);
}, 7000);
};
const stopSlider = () => {
@@ -179,35 +179,60 @@ const ReuseChart: React.FC = () => {
window.open(process.env.NEXT_PUBLIC_WR, "_blank", "noopener,noreferrer");
};
const viewportRef = useRef<HTMLDivElement>(null);
return (
<div className="card card-border" style={{ height: "250px" }}>
<div className="card-header text-foreground">
<h3 className="text-2xl font-medium">{chartItems[activeIndex]?.title}</h3>
<Button className="h-[3.2rem]" onClick={handleMoreClick}>더보기</Button>
</div>
<div className="p-4 relative overflow-hidden" style={{ width: "100%", maxWidth: 400, height: 180 }}>
<div
className="flex transition-transform duration-500 ease-in-out h-full"
style={{ transform: `translateX(-${activeIndex * 100}%)` }}
>
{chartItems.map((chart, index) => (
<div key={index} className="w-full flex-shrink-0 h-full">
<Line data={chart.data} options={chart.options as ChartOptions<'line'>} />
<div className="relative" style={{ maxWidth: 400, height: 180, marginLeft: 20 }}>
<div className="h-full">
{/* ✅ 좌표 기준 (절대 이동 X) */}
<div
ref={viewportRef}
className="w-full overflow-hidden"
style={{ height: 170}}
>
{/* ✅ 이동 대상 */}
<div
className="flex h-full transition-transform duration-500 ease-in-out"
style={{
width: `${chartItems.length * 100}%`,
transform: `translateX(-${
activeIndex * (viewportRef.current?.offsetWidth ?? 0)
}px)`
}}
>
{chartItems.map((chart, index) => (
<div
key={index}
className="h-full flex-shrink-0"
style={{ width: `${100 / chartItems.length}%` }}
>
<Line
data={chart.data}
options={chart.options as ChartOptions<'line'>}
/>
</div>
))}
</div>
))}
</div>
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 flex space-x-2">
{chartItems.map((_, index) => (
<button
key={index}
onClick={() => handleIndicatorClick(index)}
className={`w-3 h-3 rounded-full cursor-pointer transition-colors ${
activeIndex === index ? 'bg-blue-500' : 'bg-gray-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
<div className="absolute left-1/2 -translate-x-1/2 flex space-x-2" style={{ bottom: '-1px'}}>
{chartItems.map((_, index) => (
<button
key={index}
onClick={() => handleIndicatorClick(index)}
className={`w-3 h-3 rounded-full cursor-pointer transition-colors ${
activeIndex === index ? 'bg-blue-500' : 'bg-gray-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
</div>
</div>
+11 -5
View File
@@ -15,8 +15,8 @@ import {
} from "chart.js";
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent, ChartDataSet } from "../model/sn-interface";
import { SewerageApi, useOldPplStore } from "../api/sn-service";
import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface";
import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service";
// ChartJS 컴포넌트 등록
@@ -27,6 +27,7 @@ export default function SnOldPpl() {
const [oldPpl2530Data, setOldPpl2530Data] = useState<PplLengthContent[]>([]);
const [oldPpl30Data, setOldPpl30Data] = useState<PplLengthContent[]>([]);
const {chartData, setChartData} = useOldPplStore();
const {setMapData} = useMapOldPplStore();
useEffect(() => {
const currentYear = new Date().getFullYear();
@@ -72,11 +73,15 @@ export default function SnOldPpl() {
newDatasets.push(createDataset('30년이상', oldPpl30Data, '#F87171'));
}
setChartData({
const newChartData: OldPplChartData = {
labels: allZones,
datasets: newDatasets,
});
};
setChartData(newChartData);
// 차트 데이터를 기반으로 지도 팝업 데이터 생성 및 상태 업데이트
setMapData(transformChartDataToMapData(newChartData));
}, [oldPpl2025Data, oldPpl2530Data, oldPpl30Data]);
// 각 지역별 총 길이 계산
@@ -86,7 +91,8 @@ export default function SnOldPpl() {
return sum + (typeof value === 'number' ? value : 0);
}, 0);
});
// 차트 옵션 설정
const options: ChartOptions<"bar"> = {
responsive: true,
@@ -16,7 +16,7 @@ import {
import { Bar } from "react-chartjs-2";
import { Button } from "@common/components";
import type { PplLengthContent } from "../model/sn-interface";
import { SewerageApi } from "../api/sn-service";
import { SewerageApi,useUsePplStore } from "../api/sn-service";
interface ChartDataSet {
label: string;
@@ -31,10 +31,11 @@ export default function SnPplStatus() {
const [sewageData, setSewageData] = useState<PplLengthContent[]>([]);
const [stormwaterData, setStormwaterData] = useState<PplLengthContent[]>([]);
const [totalData, setTotalData] = useState<PplLengthContent[]>([]);
const [chartData, setChartData] = useState<{
labels: string[];
datasets: ChartDataSet[];
}>({ labels: [], datasets: [] });
// const [chartData, setChartData] = useState<{
// labels: string[];
// datasets: ChartDataSet[];
// }>({ labels: [], datasets: [] });
const {chartData, setChartData} = useUsePplStore();
useEffect(() => {
const ymdFrom = "19010101";
@@ -65,7 +66,7 @@ export default function SnPplStatus() {
// 데이터가 변경될 때마다 차트를 다시 계산
useEffect(() => {
// 오수관 또는 우수관 데이터가 없으면 차트를 그리지 않음
// 오수관 우수관 두 데이터 모두 없으면 차트를 그리지 않음
if (sewageData.length === 0 && stormwaterData.length === 0) return;
const allZones = new Set([
@@ -133,6 +134,7 @@ export default function SnPplStatus() {
const totals = chartData.labels.map((_, i) =>
chartData.datasets.reduce((s, ds) => s + (ds.data[i] as number), 0)
);
console.log(chartData);
const options: ChartOptions<"bar"> = {
responsive: true,
+1
View File
@@ -84,6 +84,7 @@ export const ENDPOINT = {
OPER_CNTRT: "/operation-service/api/v1/dashBoard/main/oper/cntrt",
OPER_WQLT: "/operation-service/api/v1/dashBoard/main/oper/wqlt",
REUSE_STATUS: "/operation-service/api/v1/dashBoard/main/reuse/status",
REUSE_MAP: "/operation-service/api/v1/dashBoard/main/reuse/map",
},
IC_SERVICE: {
GROUPING_STATISTICS: "/ic-service/api/v1/support-status-summary",