diff --git a/apps/cm/src/pages/dashboard/api/sn-service.ts b/apps/cm/src/pages/dashboard/api/sn-service.ts index 0beaaf59e..bb8a3fd9b 100644 --- a/apps/cm/src/pages/dashboard/api/sn-service.ts +++ b/apps/cm/src/pages/dashboard/api/sn-service.ts @@ -2,7 +2,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 { BasinStofcbrCountContent, PplLengthContent, SewerageService } from "../model/sn-interface"; import type { OldPplChartData, mapPplOldType, mapPplOldItem } from "../model/sn-interface"; export type SnState = { @@ -35,26 +35,7 @@ const initialUsePplChartData: OldPplChartData = { }; const initialOldPplChartData: OldPplChartData = { labels: ["금강", "한강", "낙동강", "영산강"], - datasets: [ - { - label: "20-25년", - data: [1140, 3181, 3100, 1662], - backgroundColor: "#3B82F6", - borderRadius: 4 - }, - { - label: "25-30년", - data: [1391, 3615, 1814, 1411], - backgroundColor: "#EF4444", - borderRadius: 4 - }, - { - label: "30년이상", - data: [2014, 10303, 9831, 2000], - backgroundColor: "#F87171", - borderRadius: 4 - } - ] + datasets: [] }; type OldPplStoreState = { chartData: OldPplChartData; @@ -70,23 +51,122 @@ export const useUsePplStore = create((set) => ({ })); export const initailMapOldPplData: mapPplOldType = { - colTitle: { totalLen: "총길이", pplLen2025: "20~25년", pplLen2530: "25~30년", pplLen30: "30년 이상" }, //헤더 영역 + colTitle: { stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" }, 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 }, + { name: "금강", totalLen: 0, stofcbrCount: 0 }, + { name: "한강", totalLen: 0, stofcbrCount: 0 }, + { name: "낙동강", totalLen: 0, stofcbrCount: 0 }, + { name: "영산강", totalLen: 0, stofcbrCount: 0 }, ] }; type mapPplOldState = { mapData: mapPplOldItem[]; setMapData: (mapData: mapPplOldItem[]) => void; + setStofcbrCounts: (counts: BasinStofcbrCountContent[]) => void; }; export const useMapOldPplStore = create((set) => ({ mapData: initailMapOldPplData.data, - setMapData: (newMapData) => set({ mapData: newMapData }), + setMapData: (newMapData) => set((state) => ({ + mapData: newMapData.map((item) => ({ + ...item, + stofcbrCount: state.mapData.find((current) => current.name === item.name)?.stofcbrCount ?? item.stofcbrCount, + })), + })), + setStofcbrCounts: (counts) => set((state) => ({ + mapData: state.mapData.map((item) => ({ + ...item, + stofcbrCount: counts.find((count) => count.zoneNm === item.name)?.stofcbrCount ?? item.stofcbrCount, + })), + })), })); +const DEFAULT_BASIN_ORDER = ["금강", "한강", "낙동강", "영산강"]; +const YEAR_GROUP_START_COLOR = "#FACC15"; +const YEAR_GROUP_END_COLOR = "#F87171"; + +/** + * 시작색과 종료색 사이를 RGB 기준으로 보간해 구간 수만큼 색상을 생성합니다. + * 구간이 하나면 시작색을, 둘 이상이면 첫 구간은 파란색·마지막 구간은 빨간색으로 고정합니다. + */ +function buildYearGroupColors(count: number): string[] { + if (count <= 0) return []; + if (count === 1) return [YEAR_GROUP_START_COLOR]; + + const parseHex = (color: string) => [ + Number.parseInt(color.slice(1, 3), 16), + Number.parseInt(color.slice(3, 5), 16), + Number.parseInt(color.slice(5, 7), 16), + ]; + const toHex = (value: number) => Math.round(value).toString(16).padStart(2, "0"); + const start = parseHex(YEAR_GROUP_START_COLOR); + const end = parseHex(YEAR_GROUP_END_COLOR); + + return Array.from({ length: count }, (_, index) => { + const ratio = index / (count - 1); + return `#${start.map((channel, channelIndex) => { + const endChannel = end[channelIndex] ?? channel; + return toHex(channel + (endChannel - channel) * ratio); + }).join("")}`; + }); +} + +/** + * 별도 정렬순서가 없는 MV 계약에서 구간명의 첫 숫자를 기준으로 표시순서를 결정합니다. + * 숫자가 없는 비정형 구간은 숫자 구간 뒤에 한글 오름차순으로 배치합니다. + */ +function compareYearGroups(left: string, right: string): number { + const leftNumber = left.match(/\d+/)?.[0]; + const rightNumber = right.match(/\d+/)?.[0]; + + if (leftNumber !== undefined && rightNumber !== undefined) { + const numberDifference = Number(leftNumber) - Number(rightNumber); + if (numberDifference !== 0) return numberDifference; + } else if (leftNumber !== undefined) { + return -1; + } else if (rightNumber !== undefined) { + return 1; + } + + return left.localeCompare(right, "ko"); +} + +/** + * MV 응답의 yearGroup 목록으로 노후관로 차트 데이터를 동적으로 생성합니다. + * 구간명이나 구간 개수가 바뀌어도 고정 매핑 없이 동일한 로직을 사용합니다. + */ +export function buildOldPplChartData(items: PplLengthContent[]): OldPplChartData { + const responseZones = [...new Set(items.map((item) => item.zoneNm).filter(Boolean))]; + const labels = [ + ...DEFAULT_BASIN_ORDER, + ...responseZones.filter((zone) => !DEFAULT_BASIN_ORDER.includes(zone)).sort((left, right) => left.localeCompare(right, "ko")), + ]; + const yearGroups = [ + ...new Set( + items + .map((item) => item.yearGroup?.trim()) + .filter((yearGroup): yearGroup is string => Boolean(yearGroup)), + ), + ].sort(compareYearGroups); + const colors = buildYearGroupColors(yearGroups.length); + + const lengthByZoneAndGroup = new Map(); + items.forEach((item) => { + const yearGroup = item.yearGroup?.trim(); + if (!item.zoneNm || !yearGroup) return; + lengthByZoneAndGroup.set(`${item.zoneNm}\u0000${yearGroup}`, Number(item.sumPrlgVl) || 0); + }); + + return { + labels, + datasets: yearGroups.map((yearGroup, index) => ({ + label: yearGroup, + data: labels.map((zone) => lengthByZoneAndGroup.get(`${zone}\u0000${yearGroup}`) ?? 0), + backgroundColor: colors[index] ?? YEAR_GROUP_START_COLOR, + borderRadius: 4, + })), + }; +} + /** * OldPplChartData를 지도 팝업 데이터 형태인 mapPplOldItem[]으로 변환합니다. * @param chartData 차트 데이터 @@ -96,22 +176,16 @@ export function transformChartDataToMapData(chartData: OldPplChartData): mapPplO 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; + const totalLen = datasets.reduce((sum, dataset) => { + const value = dataset.data[index]; + return sum + (typeof value === "number" ? value : 0); + }, 0); mapData.push({ name: label, totalLen, - pplLen2025, - pplLen2530, - pplLen30, + stofcbrCount: 0, }); }); @@ -171,7 +245,7 @@ export class SewerageApiService implements SewerageService { })); return list; } - // 유역별 노후도별 관로 길이(km) MV조회 : yearGroup - 20년~25년미만,25년~30년미만,30년이상 + // 유역별 노후도별 관로 길이(km) MV조회: yearGroup은 MV가 제공하는 동적 구간명 public async getMvOldPplLength(): Promise { const response = await client.get<{ PplLengthList: PplLengthContent[]; @@ -184,6 +258,18 @@ export class SewerageApiService implements SewerageService { return list; } + // CM 대시보드 유역별 우수토실 개소수 조회 + public async getBasinStofcbrCount(): Promise { + const response = await client.get<{ + basinStofcbrCountList: BasinStofcbrCountContent[]; + }>(ENDPOINT.SEWERAGE_SERVICE.BASIN_STOFCBR_COUNT); + + return (response.data.basinStofcbrCountList ?? []).map((item) => ({ + ...item, + stofcbrCount: Number(item.stofcbrCount), + })); + } + } export const SewerageApi = new SewerageApiService(); diff --git a/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx index c4eedbd10..c6ec33559 100644 --- a/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx +++ b/apps/cm/src/pages/dashboard/lib/korea-basin-map.tsx @@ -12,7 +12,7 @@ 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 { SewerageApi, useMapOldPplStore } from "../api/sn-service"; import {Button} from "@common/components"; const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요 @@ -31,12 +31,12 @@ const mapOperFcltData: mapOperFcltType = { ] }; const mapPplOldData: mapPplOldType = { - colTitle: {totalLen: "총길이",pplLen2025: "20~25년",pplLen2530: "25~30년",pplLen30 : "30년 이상" }, //헤더 영역 + colTitle: {stofcbrCount: "우수토실개소수", totalLen: "하수관로총연장" }, 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}, + {name: "금강", totalLen: 0, stofcbrCount: 0}, + {name: "한강", totalLen: 0, stofcbrCount: 0}, + {name: "낙동강", totalLen: 0, stofcbrCount: 0}, + {name: "영산강", totalLen: 0, stofcbrCount: 0}, ] }; const mapReuseMapData: mapReuseMapType = { @@ -62,17 +62,18 @@ export function KoreaBasinMap() { const { selectedBasin, setSelectedBasin } = useStore(basinStore); const selectedBasinKey = basinKeyMap[selectedBasin]; const {mapOperFclt} = useMapOperFcltStore(); - const {mapData: mapPplOld} = useMapOldPplStore(); + const {mapData: mapPplOld, setStofcbrCounts} = useMapOldPplStore(); const {setMapData: setMapReuseMapData} = useMapReuseMapStore(); mapOperFcltData.data = mapOperFclt; mapPplOldData.data = mapPplOld; // mapReuseMapData.data = mapReuseMap; - let standardYear = '2024'; + const [standardYear, setStandardYear] = useState('2024'); useEffect(() => { const fetchData = async () => { try { - standardYear = await OperationApi.getStandardYear(); - const mapReuseMapRslt = await OperationApi.getReuseMap(standardYear); + const fetchedStandardYear = await OperationApi.getStandardYear(); + setStandardYear(fetchedStandardYear); + const mapReuseMapRslt = await OperationApi.getReuseMap(fetchedStandardYear); setMapReuseMapData(mapReuseMapRslt); mapReuseMapData.data = mapReuseMapRslt; } catch (e) { @@ -80,7 +81,13 @@ export function KoreaBasinMap() { } }; fetchData(); - }, []); + }, [setMapReuseMapData]); + + useEffect(() => { + SewerageApi.getBasinStofcbrCount() + .then(setStofcbrCounts) + .catch((e) => console.error("유역별 우수토실 개소수 조회 실패", e)); + }, [setStofcbrCounts]); // 유역정보 const showFor = useCallback((g: SVGGElement) => { const activeService = services.find(s => s.active); @@ -104,24 +111,16 @@ export function KoreaBasinMap() { }); } else if (activeService.svcId === 'sn') { // 하수관로 const item = mapPplOldData.data.find(d => d.name === basinName); - newPopupContent.title = `${standardYear}년도 ${basinName}유역 노후하수관로 현황`; + newPopupContent.title = `${standardYear}년도 ${basinName}유역 하수관로 현황`; const colTitles = mapPplOldData.colTitle; + newPopupContent.items.push({ + header: colTitles.stofcbrCount, + data: item ? `${fmt(item.stofcbrCount)}개소` : '-', + }); 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}유역 물재이용 현황`; @@ -160,7 +159,7 @@ export function KoreaBasinMap() { ); popupRef.current.style.left = x + "px"; } - }, [services, mapOperFclt]); + }, [services, mapOperFclt, mapPplOld, standardYear]); // selectedBasinKey prop이 변경될 때 지도를 업데이트하는 useEffect useEffect(() => { @@ -410,8 +409,14 @@ export function KoreaBasinMap() { }; // 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요. return ( -
-
+
+

유역별 정보

diff --git a/apps/cm/src/pages/dashboard/model/sn-interface.ts b/apps/cm/src/pages/dashboard/model/sn-interface.ts index 4b86cd897..bf9a912ce 100644 --- a/apps/cm/src/pages/dashboard/model/sn-interface.ts +++ b/apps/cm/src/pages/dashboard/model/sn-interface.ts @@ -6,11 +6,17 @@ export interface PplLengthContent { yearGroup?: string; } +export interface BasinStofcbrCountContent { + zoneNm: string; + stofcbrCount: number; +} + export interface SewerageService { getPplLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; getPplUseLength(ymdFrom: string, ymdTo: string, useCd: string): Promise; getMvUsgPplLength(): Promise; getMvOldPplLength(): Promise; + getBasinStofcbrCount(): Promise; } export interface ChartDataSet { @@ -37,17 +43,13 @@ type SnOldPplLength = { export type mapPplOldItem = { name: string; totalLen: number; - pplLen2025: number; - pplLen2530: number; - pplLen30: number; + stofcbrCount: number; }; export type mapPplOldType = { colTitle: { - totalLen: string; // 총길이 - pplLen2025: string; // 20~25년 - pplLen2530: string; // 25~30년 - pplLen30: string; // 30년 이상 + stofcbrCount: string; // 우수토실개소수 + totalLen: string; // 총연장 }; data: mapPplOldItem[]; }; @@ -82,4 +84,4 @@ export function buildSnOldPplLength( }); return result; -} \ No newline at end of file +} diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index fe8a7f6c8..ed4095ea5 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -135,7 +135,7 @@ function DashboardPage() {
{/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */} -
+
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
{isMounted ? ( @@ -173,9 +173,9 @@ function DashboardPage() {
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */} -
+
{/* 지도영역 - 고정 (드래그 불가) */} -
+
diff --git a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx index fa502e7c5..07d333abb 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx @@ -15,8 +15,8 @@ import { } from "chart.js"; import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; -import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface"; -import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service"; +import type { PplLengthContent } from "../model/sn-interface"; +import { buildOldPplChartData, SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service"; import { useDragHandle } from "./sortable-panel"; @@ -41,45 +41,7 @@ export default function SnOldPpl() { useEffect(() => { if (oldPplLenData.length === 0) return; - // 유역 목록 추출 (중복 제거) - const allZones = [...new Set(oldPplLenData.map(d => d.zoneNm))]; - - // yearGroup 매핑: API 응답값 -> 차트 label - const yearGroupMap: { [key: string]: { label: string; color: string } } = { - "20년~25년미만": { label: "20-25년", color: "#3B82F6" }, - "25년~30년미만": { label: "25-30년", color: "#EF4444" }, - "30년이상": { label: "30년이상", color: "#F87171" }, - }; - - // 유역별, 노후년도별 데이터 구조화 - const dataByZone: { [zone: string]: { [yearGroup: string]: number } } = {}; - allZones.forEach(zone => { - dataByZone[zone] = {}; - }); - - oldPplLenData.forEach(item => { - let zoneData = dataByZone[item.zoneNm]; - if (!zoneData) { - zoneData = {}; - dataByZone[item.zoneNm] = zoneData; - } - if (item.yearGroup) { - zoneData[item.yearGroup] = item.sumPrlgVl; - } - }); - - // 데이터셋 생성 - const newDatasets: ChartDataSet[] = Object.entries(yearGroupMap).map(([yearGroup, { label, color }]) => ({ - label, - data: allZones.map(zone => dataByZone[zone]?.[yearGroup] ?? 0), - backgroundColor: color, - borderRadius: 4, - })); - - const newChartData: OldPplChartData = { - labels: allZones, - datasets: newDatasets, - }; + const newChartData = buildOldPplChartData(oldPplLenData); setChartData(newChartData); @@ -110,9 +72,7 @@ export default function SnOldPpl() { y: { beginAtZero: true, min: 0, - max: 10000, ticks: { - stepSize: 2000, callback: function (value) { return Number(value).toLocaleString() + 'km'; } @@ -192,4 +152,4 @@ export default function SnOldPpl() {
); -} \ No newline at end of file +} diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 26d5b8e95..f5ebb324f 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -80,6 +80,7 @@ export const ENDPOINT = { OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", MV_USG_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvUsgPplLength", MV_OLD_PPL_LENGTH: "/sewerage-service/api/v1/ms/mvOldPplLength", + BASIN_STOFCBR_COUNT: "/sewerage-service/api/v1/ms/basinStofcbrCount", }, OPERATION_SERVICE: { MAIN_YEAR: "/operation-service/api/v1/dashBoard/main/year",