Files
frontend/apps/dm/app/(base-layout)/pages/wrStatus/page.tsx
T
2025-07-28 09:25:54 +09:00

549 lines
21 KiB
TypeScript

'use client';
import { CHART_FORM, MainChart, type ChartConfig } from '@common/business_components';
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
interface RegionData {
name: string;
x: number;
y: number;
values: number[];
}
interface TableData {
region: string;
sewage: string;
rainwater: string;
reclaimed: string;
thermal: string;
}
const WrStatusPage: React.FC = () => {
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const svgContainerRef = useRef<HTMLDivElement>(null);
const [chartData] = useState<any[]>([
{
label : '서울',
하수 : 4,
빗물 : 816,
중수도 : 371,
온배수 : 0
},
{
label : '부산',
하수 : 13,
빗물 : 68,
중수도 : 20,
온배수 : 1
},
{
label : '대구',
하수 : 9,
빗물 : 294,
중수도 : 14,
온배수 : 2
},
{
label : '인천',
하수 : 17,
빗물 : 165,
중수도 : 35,
온배수 : 15
},
{
label : '광주',
하수 : 3,
빗물 : 74,
중수도 : 15,
온배수 : 15
},
{
label : '대전',
하수 : 2,
빗물 : 52,
중수도 : 11,
온배수 : 20
},
{
label : '울산',
하수 : 8,
빗물 : 111,
중수도 : 19,
온배수 : 30
},
{
label : '세종',
하수 : 9,
빗물 : 65,
중수도 : 0,
온배수 : 0
},
{
label : '경기',
하수 : 176,
빗물 : 1009,
중수도 : 252,
온배수 : 40
},
{
label : '강원',
하수 : 69,
빗물 : 69,
중수도 : 22,
온배수 : 0
},
{
label : '충북',
하수 : 38,
빗물 : 17,
중수도 : 91,
온배수 : 0
},
{
label : '충남',
하수 : 67,
빗물 : 54,
중수도 : 188,
온배수 : 0
},
{
label : '전북',
하수 : 55,
빗물 : 33,
중수도 : 220,
온배수 : 0
},
{
label : '전남',
하수 : 83,
빗물 : 16,
중수도 : 75,
온배수 : 0
},
{
label : '경북',
하수 : 81,
빗물 : 37,
중수도 : 70,
온배수 : 0
},
{
label : '경남',
하수 : 75,
빗물 : 27,
중수도 : 140,
온배수 : 0
},
{
label : '제주',
하수 : 8,
빗물 : 47,
중수도 : 64,
온배수 : 0
}
]);
const chartConfig = chartData.reduce((config, element) => {
config[element.label] = {
label: element.label,
color : "hsl(var(--chart-1))"
};
return config;
}, {} as Record<string, { label: string; color: string; }>) satisfies ChartConfig;
const chartOption = {
chartForm : CHART_FORM.BAR,
chartConfig : chartConfig,
chartData : chartData,
chartTitle : '',
chartClassName : "h-[300px] w-full",
graphSize : 40,
yFieldText : '시설(개소)',
showLegend : true
}
const colorLegend = [
{ label: '하수', color: '#FF6B6B', key: 'sewage' },
{ label: '빗물', color: '#4ECDC4', key: 'rainwater' },
{ label: '중수도', color: '#45B7D1', key: 'reclaimed' },
{ label: '온배수', color: '#FED16A', key: 'thermal' },
];
const regionIdMap: Record<string, string> = {
서울: '서울특별시',
부산: '부산광역시',
대구: '대구광역시',
인천: '인천광역시',
광주: '광주광역시',
대전: '대전광역시',
울산: '울산광역시',
세종: '세종특별자치시',
경기: '경기도',
강원: '강원특별자치도',
충북: '충청북도',
충남: '충청남도',
전북: '전라북도',
전남: '전라남도',
경북: '경상북도',
경남: '경상남도',
제주: '제주특별자치도'
};
const tableData: TableData[] = [
{ region: '전국', sewage: '717(1,137,538)', rainwater: '3544(13,945)', reclaimed: '1018(222,476)', thermal: '123(492)' },
{ region: '서울특별시', sewage: '4(114,908)', rainwater: '816(430)', reclaimed: '371(341)', thermal: '0' },
{ region: '부산광역시', sewage: '13(146,197)', rainwater: '68(335)', reclaimed: '20(512)', thermal: '1(18)' },
{ region: '대구광역시', sewage: '9(102,380)', rainwater: '294(133)', reclaimed: '14(44)', thermal: '2(12)' },
{ region: '인천광역시', sewage: '17(57,691)', rainwater: '165(642)', reclaimed: '35(7,859)', thermal: '15(32)' },
{ region: '광주광역시', sewage: '3(28,738)', rainwater: '74(23)', reclaimed: '15(450)', thermal: '15(6)' },
{ region: '대전광역시', sewage: '2(11,966)', rainwater: '52(2)', reclaimed: '11(492)', thermal: '20(100)' },
{ region: '울산광역시', sewage: '8(36,076)', rainwater: '111(39)', reclaimed: '19(28,904)', thermal: '30(130)' },
{ region: '세종특별자치시', sewage: '9(2,555)', rainwater: '65(16)', reclaimed: '0(0)', thermal: '0' },
{ region: '경기도', sewage: '176(313,298)', rainwater: '1009(4,735)', reclaimed: '252(77,096)', thermal: '40(143)' },
{ region: '강원특별자치도', sewage: '69(19,677)', rainwater: '69(4)', reclaimed: '22(447)', thermal: '0' },
{ region: '충청북도', sewage: '38(15,607)', rainwater: '17(91)', reclaimed: '91(2,211)', thermal: '0' },
{ region: '충청남도', sewage: '67(41,368)', rainwater: '54(77)', reclaimed: '188(50,434)', thermal: '0' },
{ region: '전라북도', sewage: '55(42,671)', rainwater: '33(199)', reclaimed: '220(9,453)', thermal: '0' },
{ region: '전라남도', sewage: '83(23,560)', rainwater: '16(885)', reclaimed: '75(15,412)', thermal: '0' },
{ region: '경상북도', sewage: '81(129,081)', rainwater: '37(341)', reclaimed: '70(27,818)', thermal: '0' },
{ region: '경상남도', sewage: '75(50,101)', rainwater: '27(244)', reclaimed: '140(343)', thermal: '0' },
{ region: '제주특별자치도', sewage: '8(1,666)', rainwater: '47(5,746)', reclaimed: '64(660)', thermal: '0' },
];
const regionData: RegionData[] = useMemo(() => [
{ name: '서울', x: 170, y: 140, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '인천', x: 120, y: 150, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경기', x: 210, y: 210, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '강원', x: 330, y: 120, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충북', x: 260, y: 280, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충남', x: 100, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '세종', x: 180, y: 305, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대전', x: 195, y: 360, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전북', x: 170, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전남', x: 160, y: 600, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '광주', x: 120, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경북', x: 400, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경남', x: 310, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대구', x: 350, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '울산', x: 450, y: 500, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '부산', x: 420, y: 550, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '제주', x: 60, y: 750, values: [0.3, 0.3, 0.2, 0.2] },
], []);
const handleMarkerClick = (regionId: string) => {
if (selectedRegion === regionId) {
setSelectedRegion(null);
} else {
setSelectedRegion(regionId);
}
};
const createDonutMarker = useCallback((svg: SVGSVGElement, x: number, y: number, name: string, values: number[]) => {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A'];
const radius = 26;
const innerRadius = 18;
// const regionId = regionIdMap[name];
const regionId = regionIdMap[name];
if (!regionId) {
console.warn(`Invalid region name: ${name}`);
return;
}
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', `translate(${x}, ${y})`);
g.setAttribute('id', `marker-${name}`);
g.style.cursor = 'pointer';
g.setAttribute('role', 'button');
g.setAttribute('tabindex', '0');
g.setAttribute('aria-label', `${name} 지역 선택`);
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('r', radius.toString());
bg.setAttribute('fill', 'white');
bg.setAttribute('pointer-events', 'all');
g.appendChild(bg);
let startAngle = 0;
values.forEach((value, idx) => {
const endAngle = startAngle + value * 2 * Math.PI;
const x1 = radius * Math.sin(startAngle);
const y1 = -radius * Math.cos(startAngle);
const x2 = radius * Math.sin(endAngle);
const y2 = -radius * Math.cos(endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const d = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L 0 0 Z`;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('fill', colors[idx % colors.length] as string);
g.appendChild(path);
startAngle = endAngle;
});
const center = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
center.setAttribute('r', innerRadius.toString());
center.setAttribute('fill', 'white');
g.appendChild(center);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', '0');
text.setAttribute('y', '2');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-size', '13');
text.setAttribute('font-weight', 'bold');
text.setAttribute('dominant-baseline', 'middle');
text.setAttribute('fill', 'black');
text.textContent = name || '';
g.appendChild(text);
g.addEventListener('mouseenter', (e) => {
setHoveredRegion(regionId);
setTooltipPosition({ x: e.clientX, y: e.clientY });
});
g.addEventListener('mouseleave', () => {
setHoveredRegion(null);
setTooltipPosition(null);
});
g.addEventListener('click', () => {
handleMarkerClick(regionId);
});
g.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const matrix = g.getScreenCTM();
if (!matrix) return; // 또는 console.warn('matrix is null'); return;
const point = svg.createSVGPoint();
point.x = 0;
point.y = 0;
const transformed = point.matrixTransform(matrix);
setTooltipPosition({ x: transformed.x, y: transformed.y });
setHoveredRegion(regionId);
handleMarkerClick(regionId);
}
});
svg.appendChild(g);
}, [handleMarkerClick, regionIdMap]);
useEffect(() => {
const loadSVG = async () => {
try {
const response = await fetch('/img/wrStatus/southKoreaMap.svg');
const svgText = await response.text();
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = svgText;
const svg = svgContainerRef.current.querySelector('svg') as SVGSVGElement;
if (svg) {
svg.style.width = '58%';
svg.style.height = 'auto';
svg.style.objectFit = 'contain';
// Set default fills
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
if (!path.getAttribute('fill') || path.getAttribute('fill') === 'none') {
path.setAttribute('fill', '#ffffff');
}
});
// Create markers
regionData.forEach(region => {
createDonutMarker(svg, region.x, region.y, region.name, region.values);
});
}
}
} catch (error) {
console.error('SVG 로딩 실패:', error);
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = "<p className='text-red-500'>SVG 로딩 실패</p>";
}
}
};
loadSVG();
}, [createDonutMarker, regionData]);
useEffect(() => {
const svg = svgContainerRef.current?.querySelector('svg');
if (svg) {
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const pathId = path.getAttribute('id');
if (pathId) {
if (selectedRegion === pathId) {
path.setAttribute('fill', '#2563eb');
} else {
const original = '#ffffff';
path.setAttribute('fill', original);
}
}
});
}
}, [selectedRegion]);
return (
<div className="flex h-screen bg-[#F2F2F2] text-gray-800">
{/* 우측 콘텐츠 영역 */}
<main className="flex-1 p-6 overflow-auto">
{/* 상단 제목 */}
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold">물재이용 현황</h2>
<div className="text-sm text-gray-500">관리자 ▼</div>
</div>
{/* 콘텐츠 본문 */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* 지도 카드 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden max-h-[900px]">
{/* 타이틀 영역 */}
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">재이용시설현황(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황(개소)</p>
</div>
{/* 본문 콘텐츠 */}
<div className="p-4">
<div className="bg-[#E2EDF3] p-4 rounded shadow relative">
<div className="flex justify-center">
<div
ref={svgContainerRef}
className="w-full max-w-5xl max-h-[680px] overflow-y-auto block mx-auto"
aria-label="대한민국 지역별 재이용시설 분포도"
role="region"
tabIndex={0}
/>
</div>
{typeof window !== 'undefined' && hoveredRegion && tooltipPosition && (
<div
className="fixed bg-white text-[12px] text-gray-700 border border-gray-300 shadow-lg rounded px-3 py-2 z-50"
style={{
top: tooltipPosition.y + 10,
left: tooltipPosition.x + 10,
pointerEvents: 'none',
}}
>
<div className="font-semibold mb-1">{hoveredRegion}</div>
<div className="space-y-1">
{colorLegend.map(({ label, color, key }) => (
<div key={key} className="flex items-center space-x-2">
<span className="text-[12px] inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></span>
<span className="text-[12px]">
{label}:{' '}
{tableData.find(d => d.region === hoveredRegion)?.[key as keyof TableData] ?? '-'}
</span>
</div>
))}
</div>
</div>
)}
{/* 범례 */}
<div className="absolute bottom-2 right-2 bg-white rounded-lg shadow-md p-4 text-sm space-y-2">
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#FF6B6B'}}></span>
<span className="text-[12px] text-gray-900">하수재이용시설 (총 717개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#4ECDC4'}}></span>
<span className="text-[12px] text-gray-900">빗물이용시설 (총 3,730개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#45B7D1'}}></span>
<span className="text-[12px] text-gray-900">중수도시설 (총 1,074개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#FED16A'}}></span>
<span className="text-[12px] text-gray-900">온배수시설 (총 201개소)</span>
</div>
</div>
</div>
</div>
</div>
{/* 우측 콘텐츠 (표 + 차트) */}
<div className="col-span-1 flex flex-col space-y-4">
{/* 재이용시설현황 표 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
{/* 타이틀 + 버튼 영역 */}
<div className="flex justify-between items-center border-b border-[#DBE2E6] px-4 py-3">
<div>
<h3 className="text-[12px] font-semibold">재이용시설현황(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황(개소)</p>
</div>
</div>
<div className="p-4">
<div className="rounded border border-[#DBE2E6] shadow overflow-auto max-h-[292px] p-4">
<table className="min-w-full text-sm text-center">
<caption className="sr-only text-[12px]">행정구역별 하수, 빗물, 중수도, 온배수 재이용시설 수</caption>
<thead className="bg-gray-50 text-xs text-gray-600 uppercase border-b">
<tr>
<th className="text-[12px] font-semibold px-4 py-2">번호</th>
<th className="text-[12px] font-semibold px-4 py-2">시도</th>
<th className="text-[12px] font-semibold px-4 py-2">하수(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">빗물(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">중수도(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">온배수(이용량,천𝓂³/년)</th>
</tr>
</thead>
<tbody className="divide-y">
{tableData.map((row, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="text-[12px] px-4 py-2 text-center">
{index+1}
</td>
<td className="text-[12px]">{row.region}</td>
<td className="text-[12px]">{row.sewage}</td>
<td className="text-[12px]">{row.rainwater}</td>
<td className="text-[12px]">{row.reclaimed}</td>
<td className="text-[12px]">{row.thermal}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* 재이용통계 차트 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">재이용통계(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설 현황 개소수 그래프</p>
</div>
<div className="p-4 bg-[#f9fbfd]">
<div className="h-[305px] text-gray-500">
<MainChart
chartForm={chartOption.chartForm}
chartConfig={chartOption.chartConfig}
chartData={chartOption.chartData}
chartTitle={chartOption.chartTitle}
chartClassName={chartOption.chartClassName}
graphSize={chartOption.graphSize}
yFieldText={chartOption.yFieldText}
showLegend={chartOption.showLegend}
/>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default WrStatusPage;