이희진 진행사항 중간세이브

This commit is contained in:
leeheejin
2025-10-28 13:40:17 +09:00
parent d5e72ce901
commit 1291f9287c
14 changed files with 1842 additions and 125 deletions

View File

@@ -3,7 +3,8 @@
import React, { useEffect, useState, useCallback, useMemo } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Loader2, RefreshCw } from "lucide-react";
import "leaflet/dist/leaflet.css";
// Leaflet 아이콘 경로 설정 (엑박 방지)
@@ -60,6 +61,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [geoJsonData, setGeoJsonData] = useState<any>(null);
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
console.log("🧪 MapTestWidgetV2 렌더링!", element);
console.log("📍 마커:", markers.length, "🔷 폴리곤:", polygons.length);
@@ -136,6 +138,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
setMarkers(allMarkers);
setPolygons(allPolygons);
setLastRefreshTime(new Date());
} catch (err: any) {
console.error("❌ 데이터 로딩 중 오류:", err);
setError(err.message);
@@ -144,6 +147,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}, [dataSources]);
// 수동 새로고침 핸들러
const handleManualRefresh = useCallback(() => {
console.log("🔄 수동 새로고침 버튼 클릭");
loadMultipleDataSources();
}, [loadMultipleDataSources]);
// REST API 데이터 로딩
const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => {
console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType);
@@ -263,11 +272,47 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
return convertToMapData(rows, source.name || source.id || "Database", source.mapDisplayType);
};
// XML 데이터 파싱 (UTIC API 등)
const parseXmlData = (xmlText: string): any[] => {
try {
console.log(" 📄 XML 파싱 시작");
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const records = xmlDoc.getElementsByTagName("record");
const results: any[] = [];
for (let i = 0; i < records.length; i++) {
const record = records[i];
const obj: any = {};
for (let j = 0; j < record.children.length; j++) {
const child = record.children[j];
obj[child.tagName] = child.textContent || "";
}
results.push(obj);
}
console.log(` ✅ XML 파싱 완료: ${results.length}개 레코드`);
return results;
} catch (error) {
console.error(" ❌ XML 파싱 실패:", error);
return [];
}
};
// 텍스트 데이터 파싱 (CSV, 기상청 형식 등)
const parseTextData = (text: string): any[] => {
try {
console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500));
// XML 형식 감지
if (text.trim().startsWith("<?xml") || text.trim().startsWith("<result>")) {
console.log(" 📄 XML 형식 데이터 감지");
return parseXmlData(text);
}
const lines = text.split('\n').filter(line => {
const trimmed = line.trim();
return trimmed &&
@@ -382,8 +427,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
// 마커 데이터 처리 (위도/경도가 있는 경우)
let lat = row.lat || row.latitude || row.y;
let lng = row.lng || row.longitude || row.x;
let lat = row.lat || row.latitude || row.y || row.locationDataY;
let lng = row.lng || row.longitude || row.x || row.locationDataX;
// 위도/경도가 없으면 지역 코드/지역명으로 변환 시도
if ((lat === undefined || lng === undefined) && (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId)) {
@@ -715,6 +760,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}, [dataSources, loadMultipleDataSources]);
// 자동 새로고침
useEffect(() => {
if (!dataSources || dataSources.length === 0) return;
// 모든 데이터 소스 중 가장 짧은 refreshInterval 찾기
const intervals = dataSources
.map((ds) => ds.refreshInterval)
.filter((interval): interval is number => typeof interval === "number" && interval > 0);
if (intervals.length === 0) return;
const minInterval = Math.min(...intervals);
console.log(`⏱️ 자동 새로고침 설정: ${minInterval}초마다`);
const intervalId = setInterval(() => {
console.log("🔄 자동 새로고침 실행");
loadMultipleDataSources();
}, minInterval * 1000);
return () => {
console.log("⏹️ 자동 새로고침 정리");
clearInterval(intervalId);
};
}, [dataSources, loadMultipleDataSources]);
// 타일맵 URL (chartConfig에서 가져오기)
const tileMapUrl = element?.chartConfig?.tileMapUrl ||
`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
@@ -737,9 +807,26 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
</h3>
<p className="text-xs text-muted-foreground">
{element?.dataSources?.length || 0}
{lastRefreshTime && (
<span className="ml-2">
: {lastRefreshTime.toLocaleTimeString("ko-KR")}
</span>
)}
</p>
</div>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleManualRefresh}
disabled={loading}
className="h-8 gap-2 text-xs"
>
<RefreshCw className={`h-3 w-3 ${loading ? "animate-spin" : ""}`} />
</Button>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</div>
</div>
{/* 지도 */}
@@ -769,19 +856,22 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{/* 폴리곤 렌더링 */}
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
{geoJsonData && polygons.length > 0 && (
{(() => {
console.log(`🗺️ GeoJSON 렌더링 조건 체크:`, {
geoJsonData: !!geoJsonData,
polygonsLength: polygons.length,
polygonNames: polygons.map(p => p.name),
});
return null;
})()}
{geoJsonData && polygons.length > 0 ? (
<GeoJSON
key={JSON.stringify(polygons.map(p => p.id))} // 폴리곤 변경 시 재렌더링
data={geoJsonData}
style={(feature: any) => {
const ctpName = feature?.properties?.CTP_KOR_NM; // 시/도명 (예: 경상북도)
const sigName = feature?.properties?.SIG_KOR_NM; // 시/군/구명 (예: 군위군)
// 🔍 디버그: GeoJSON 속성 확인
if (ctpName === "경상북도" || sigName?.includes("군위") || sigName?.includes("영천")) {
console.log(`🔍 GeoJSON 속성:`, { ctpName, sigName, properties: feature?.properties });
console.log(`🔍 매칭 시도할 폴리곤:`, polygons.map(p => p.name));
}
// 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명)
const matchingPolygon = polygons.find(p => {
if (!p.name) return false;
@@ -859,6 +949,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
}}
/>
) : (
<>{console.log(`⚠️ GeoJSON 렌더링 안 됨: geoJsonData=${!!geoJsonData}, polygons=${polygons.length}`)}</>
)}
{/* 폴리곤 렌더링 (해상 구역만) */}
@@ -902,21 +994,79 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
key={marker.id}
position={[marker.lat, marker.lng]}
>
<Popup>
<div className="min-w-[200px]">
<div className="mb-2 font-semibold">{marker.name}</div>
{marker.source && (
<div className="mb-1 text-xs text-muted-foreground">
: {marker.source}
<Popup maxWidth={350}>
<div className="min-w-[250px] max-w-[350px]">
{/* 제목 */}
<div className="mb-2 border-b pb-2">
<div className="text-base font-bold">{marker.name}</div>
{marker.source && (
<div className="mt-1 text-xs text-muted-foreground">
📡 {marker.source}
</div>
)}
</div>
{/* 상세 정보 */}
<div className="space-y-2">
{marker.description && (
<div className="rounded bg-muted p-2">
<div className="mb-1 text-xs font-semibold text-foreground"> </div>
<div className="text-xs text-muted-foreground whitespace-pre-wrap">
{(() => {
try {
const parsed = JSON.parse(marker.description);
return (
<div className="space-y-1">
{parsed.incidenteTypeCd === "1" && (
<div className="font-semibold text-destructive">🚨 </div>
)}
{parsed.incidenteTypeCd === "2" && (
<div className="font-semibold text-warning">🚧 </div>
)}
{parsed.addressJibun && (
<div>📍 {parsed.addressJibun}</div>
)}
{parsed.addressNew && parsed.addressNew !== parsed.addressJibun && (
<div>📍 {parsed.addressNew}</div>
)}
{parsed.roadName && (
<div>🛣 {parsed.roadName}</div>
)}
{parsed.linkName && (
<div>🔗 {parsed.linkName}</div>
)}
{parsed.incidentMsg && (
<div className="mt-2 border-t pt-2">💬 {parsed.incidentMsg}</div>
)}
{parsed.eventContent && (
<div className="mt-2 border-t pt-2">📝 {parsed.eventContent}</div>
)}
{parsed.startDate && (
<div className="text-[10px]">🕐 {parsed.startDate}</div>
)}
{parsed.endDate && (
<div className="text-[10px]">🕐 : {parsed.endDate}</div>
)}
</div>
);
} catch {
return marker.description;
}
})()}
</div>
</div>
)}
{marker.status && (
<div className="text-xs">
<span className="font-semibold">:</span> {marker.status}
</div>
)}
{/* 좌표 */}
<div className="border-t pt-2 text-[10px] text-muted-foreground">
📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div>
)}
{marker.status && (
<div className="mb-1 text-xs">
: {marker.status}
</div>
)}
<div className="text-xs text-muted-foreground">
{marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
</div>
</div>
</Popup>