From cbdd9fef0fff07f2c221b6897d0a320d551e0f07 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 15:46:24 +0900 Subject: [PATCH 1/9] =?UTF-8?q?http=20polling=20=EC=A3=BC=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=205=EC=B4=88=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 862 ++++++++++-------- 1 file changed, 492 insertions(+), 370 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 5eeeca12..1ea8685c 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ "use client"; import React, { useEffect, useState, useCallback, useMemo } from "react"; @@ -11,12 +12,13 @@ import "leaflet/dist/leaflet.css"; // Leaflet 아이콘 경로 설정 (엑박 방지) if (typeof window !== "undefined") { - const L = require("leaflet"); - delete (L.Icon.Default.prototype as any)._getIconUrl; - L.Icon.Default.mergeOptions({ - iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", - iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", - shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + import("leaflet").then((L) => { + delete (L.Icon.Default.prototype as any)._getIconUrl; + L.Icon.Default.mergeOptions({ + iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png", + iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png", + shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png", + }); }); } @@ -46,6 +48,9 @@ interface MarkerData { description?: string; source?: string; // 어느 데이터 소스에서 왔는지 color?: string; // 마커 색상 + heading?: number; // 진행 방향 (0-360도, 0=북쪽) + prevLat?: number; // 이전 위도 (방향 계산용) + prevLng?: number; // 이전 경도 (방향 계산용) } interface PolygonData { @@ -61,6 +66,7 @@ interface PolygonData { export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const [markers, setMarkers] = useState([]); + const [prevMarkers, setPrevMarkers] = useState([]); // 이전 마커 위치 저장 const [polygons, setPolygons] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -75,10 +81,23 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return element?.dataSources || element?.chartConfig?.dataSources; }, [element?.dataSources, element?.chartConfig?.dataSources]); + // 두 좌표 사이의 방향 계산 (0-360도, 0=북쪽) + const calculateHeading = useCallback((lat1: number, lng1: number, lat2: number, lng2: number): number => { + const dLng = (lng2 - lng1) * (Math.PI / 180); + const lat1Rad = lat1 * (Math.PI / 180); + const lat2Rad = lat2 * (Math.PI / 180); + + const y = Math.sin(dLng) * Math.cos(lat2Rad); + const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) - Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng); + + let heading = Math.atan2(y, x) * (180 / Math.PI); + heading = (heading + 360) % 360; // 0-360 범위로 정규화 + + return heading; + }, []); + // 다중 데이터 소스 로딩 const loadMultipleDataSources = useCallback(async () => { - const dataSourcesList = dataSources; - if (!dataSources || dataSources.length === 0) { // // console.log("⚠️ 데이터 소스가 없습니다."); return; @@ -94,38 +113,38 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { dataSources.map(async (source) => { try { // // console.log(`📡 데이터 소스 "${source.name || source.id}" 로딩 중...`); - + if (source.type === "api") { return await loadRestApiData(source); } else if (source.type === "database") { return await loadDatabaseData(source); } - + return { markers: [], polygons: [] }; } catch (err: any) { console.error(`❌ 데이터 소스 "${source.name || source.id}" 로딩 실패:`, err); return { markers: [], polygons: [] }; } - }) + }), ); // 성공한 데이터만 병합 const allMarkers: MarkerData[] = []; const allPolygons: PolygonData[] = []; - + results.forEach((result, index) => { // // console.log(`🔍 결과 ${index}:`, result); - + if (result.status === "fulfilled" && result.value) { const value = result.value as { markers: MarkerData[]; polygons: PolygonData[] }; // // console.log(`✅ 데이터 소스 ${index} 성공:`, value); - + // 마커 병합 if (value.markers && Array.isArray(value.markers)) { // // console.log(` → 마커 ${value.markers.length}개 추가`); allMarkers.push(...value.markers); } - + // 폴리곤 병합 if (value.polygons && Array.isArray(value.polygons)) { // // console.log(` → 폴리곤 ${value.polygons.length}개 추가`); @@ -139,8 +158,31 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // // console.log(`✅ 총 ${allMarkers.length}개의 마커, ${allPolygons.length}개의 폴리곤 로딩 완료`); // // console.log("📍 최종 마커 데이터:", allMarkers); // // console.log("🔷 최종 폴리곤 데이터:", allPolygons); - - setMarkers(allMarkers); + + // 이전 마커 위치와 비교하여 진행 방향 계산 + const markersWithHeading = allMarkers.map((marker) => { + const prevMarker = prevMarkers.find((pm) => pm.id === marker.id); + + if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) { + // 이동했으면 방향 계산 + const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng); + return { + ...marker, + heading, + prevLat: prevMarker.lat, + prevLng: prevMarker.lng, + }; + } + + // 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0) + return { + ...marker, + heading: marker.heading || prevMarker?.heading || 0, + }; + }); + + setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장 + setMarkers(markersWithHeading); setPolygons(allPolygons); setLastRefreshTime(new Date()); } catch (err: any) { @@ -149,7 +191,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } finally { setLoading(false); } - }, [dataSources]); + }, [dataSources, prevMarkers, calculateHeading]); // 수동 새로고침 핸들러 const handleManualRefresh = useCallback(() => { @@ -158,9 +200,11 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { }, [loadMultipleDataSources]); // REST API 데이터 로딩 - const loadRestApiData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { + const loadRestApiData = async ( + source: ChartDataSource, + ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { // // console.log(`🌐 REST API 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - + if (!source.endpoint) { throw new Error("API endpoint가 없습니다."); } @@ -205,16 +249,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } const result = await response.json(); - + if (!result.success) { throw new Error(result.message || "API 호출 실패"); } // 데이터 추출 및 파싱 let data = result.data; - + // 텍스트 형식 데이터 체크 (기상청 API 등) - if (data && typeof data === 'object' && data.text && typeof data.text === 'string') { + if (data && typeof data === "object" && data.text && typeof data.text === "string") { // // console.log("📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(data.text); if (parsedData.length > 0) { @@ -224,7 +268,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return convertToMapData(mappedData, source.name || source.id || "API", source.mapDisplayType, source); } } - + // JSON Path로 데이터 추출 if (source.jsonPath) { const pathParts = source.jsonPath.split("."); @@ -234,18 +278,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { } const rows = Array.isArray(data) ? data : [data]; - + // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); - + // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) return convertToMapData(mappedRows, source.name || source.id || "API", source.mapDisplayType, source); }; // Database 데이터 로딩 - const loadDatabaseData = async (source: ChartDataSource): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { + const loadDatabaseData = async ( + source: ChartDataSource, + ): Promise<{ markers: MarkerData[]; polygons: PolygonData[] }> => { // // console.log(`💾 Database 데이터 로딩 시작:`, source.name, `mapDisplayType:`, source.mapDisplayType); - + if (!source.query) { throw new Error("SQL 쿼리가 없습니다."); } @@ -257,9 +303,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const externalResult = await ExternalDbConnectionAPI.executeQuery( parseInt(source.externalConnectionId), - source.query + source.query, ); - + if (!externalResult.success || !externalResult.data) { throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패"); } @@ -267,19 +313,19 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const resultData = externalResult.data as unknown as { rows: Record[]; }; - + rows = resultData.rows; } else { // 현재 DB const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(source.query); - + rows = result.rows; } - + // 컬럼 매핑 적용 const mappedRows = applyColumnMapping(rows, source.columnMapping); - + // 마커와 폴리곤으로 변환 (mapDisplayType + dataSource 전달) return convertToMapData(mappedRows, source.name || source.id || "Database", source.mapDisplayType, source); }; @@ -290,7 +336,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // // console.log(" 📄 XML 파싱 시작"); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(xmlText, "text/xml"); - + const records = xmlDoc.getElementsByTagName("record"); const results: any[] = []; @@ -318,56 +364,53 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const parseTextData = (text: string): any[] => { try { // // console.log(" 🔍 원본 텍스트 (처음 500자):", text.substring(0, 500)); - + // XML 형식 감지 if (text.trim().startsWith("")) { // // console.log(" 📄 XML 형식 데이터 감지"); return parseXmlData(text); } - - const lines = text.split('\n').filter(line => { + + const lines = text.split("\n").filter((line) => { const trimmed = line.trim(); - return trimmed && - !trimmed.startsWith('#') && - !trimmed.startsWith('=') && - !trimmed.startsWith('---'); + return trimmed && !trimmed.startsWith("#") && !trimmed.startsWith("=") && !trimmed.startsWith("---"); }); - + // // console.log(` 📝 유효한 라인: ${lines.length}개`); - + if (lines.length === 0) return []; - + // CSV 형식으로 파싱 const result: any[] = []; - + for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const values = line.split(',').map(v => v.trim().replace(/,=$/g, '')); - + const values = line.split(",").map((v) => v.trim().replace(/,=$/g, "")); + // // console.log(` 라인 ${i}:`, values); - + // 기상특보 형식: 지역코드, 지역명, 하위코드, 하위지역명, 발표시각, 특보종류, 등급, 발표상태, 설명 if (values.length >= 4) { const obj: any = { - code: values[0] || '', // 지역 코드 (예: L1070000) - region: values[1] || '', // 지역명 (예: 경상북도) - subCode: values[2] || '', // 하위 코드 (예: L1071600) - subRegion: values[3] || '', // 하위 지역명 (예: 영주시) - tmFc: values[4] || '', // 발표시각 - type: values[5] || '', // 특보종류 (강풍, 호우 등) - level: values[6] || '', // 등급 (주의, 경보) - status: values[7] || '', // 발표상태 - description: values.slice(8).join(', ').trim() || '', + code: values[0] || "", // 지역 코드 (예: L1070000) + region: values[1] || "", // 지역명 (예: 경상북도) + subCode: values[2] || "", // 하위 코드 (예: L1071600) + subRegion: values[3] || "", // 하위 지역명 (예: 영주시) + tmFc: values[4] || "", // 발표시각 + type: values[5] || "", // 특보종류 (강풍, 호우 등) + level: values[6] || "", // 등급 (주의, 경보) + status: values[7] || "", // 발표상태 + description: values.slice(8).join(", ").trim() || "", }; - + // 지역 이름 설정 (하위 지역명 우선, 없으면 상위 지역명) obj.name = obj.subRegion || obj.region || obj.code; - + result.push(obj); // console.log(` ✅ 파싱 성공:`, obj); } } - + // // console.log(" 📊 최종 파싱 결과:", result.length, "개"); return result; } catch (error) { @@ -378,15 +421,15 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 데이터를 마커와 폴리곤으로 변환 const convertToMapData = ( - rows: any[], - sourceName: string, + rows: any[], + sourceName: string, mapDisplayType?: "auto" | "marker" | "polygon", - dataSource?: ChartDataSource + dataSource?: ChartDataSource, ): { markers: MarkerData[]; polygons: PolygonData[] } => { // // console.log(`🔄 ${sourceName} 데이터 변환 시작:`, rows.length, "개 행"); // // console.log(` 📌 mapDisplayType:`, mapDisplayType, `(타입: ${typeof mapDisplayType})`); // // console.log(` 🎨 마커 색상:`, dataSource?.markerColor, `폴리곤 색상:`, dataSource?.polygonColor); - + if (rows.length === 0) return { markers: [], polygons: [] }; const markers: MarkerData[] = []; @@ -394,20 +437,20 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { rows.forEach((row, index) => { // // console.log(` 행 ${index}:`, row); - + // 텍스트 데이터 체크 (기상청 API 등) - if (row && typeof row === 'object' && row.text && typeof row.text === 'string') { + if (row && typeof row === "object" && row.text && typeof row.text === "string") { // // console.log(" 📄 텍스트 형식 데이터 감지, CSV 파싱 시도"); const parsedData = parseTextData(row.text); // // console.log(` ✅ CSV 파싱 결과: ${parsedData.length}개 행`); - + // 파싱된 데이터를 재귀적으로 변환 (색상 정보 전달) const result = convertToMapData(parsedData, sourceName, mapDisplayType, dataSource); markers.push(...result.markers); polygons.push(...result.polygons); return; // 이 행은 처리 완료 } - + // 폴리곤 데이터 체크 (coordinates 필드가 배열인 경우 또는 강제 polygon 모드) if (row.coordinates && Array.isArray(row.coordinates) && row.coordinates.length > 0) { // // console.log(` → coordinates 발견:`, row.coordinates.length, "개"); @@ -437,7 +480,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { name: regionName, coordinates: MARITIME_ZONES[regionName] as [number, number][], status: row.status || row.level, - description: row.description || `${row.type || ''} ${row.level || ''}`.trim() || JSON.stringify(row, null, 2), + description: row.description || `${row.type || ""} ${row.level || ""}`.trim() || JSON.stringify(row, null, 2), source: sourceName, color: dataSource?.polygonColor || getColorByStatus(row.status || row.level), }); @@ -449,7 +492,10 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { 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)) { + if ( + (lat === undefined || lng === undefined) && + (row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId) + ) { const regionCode = row.code || row.areaCode || row.regionCode || row.tmFc || row.stnId; // // console.log(` → 지역 코드 발견: ${regionCode}, 위도/경도 변환 시도`); const coords = getCoordinatesByRegionCode(regionCode); @@ -492,8 +538,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return; // 폴리곤으로 처리했으므로 마커로는 추가하지 않음 } - // 위도/경도가 있고 marker 모드가 아니면 마커로 처리 - if (lat !== undefined && lng !== undefined && mapDisplayType !== "polygon") { + // 위도/경도가 있고 polygon 모드가 아니면 마커로 처리 + if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") { // // console.log(` → 마커로 처리: (${lat}, ${lng})`); markers.push({ id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성 @@ -535,12 +581,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 상태에 따른 색상 반환 const getColorByStatus = (status?: string): string => { if (!status) return "#3b82f6"; // 기본 파란색 - + const statusLower = status.toLowerCase(); if (statusLower.includes("경보") || statusLower.includes("위험")) return "#ef4444"; // 빨강 if (statusLower.includes("주의")) return "#f59e0b"; // 주황 if (statusLower.includes("정상")) return "#10b981"; // 초록 - + return "#3b82f6"; // 기본 파란색 }; @@ -549,34 +595,34 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 기상청 지역 코드 매핑 (예시) const regionCodeMap: Record = { // 서울/경기 - "11": { lat: 37.5665, lng: 126.9780 }, // 서울 + "11": { lat: 37.5665, lng: 126.978 }, // 서울 "41": { lat: 37.4138, lng: 127.5183 }, // 경기 - + // 강원 "42": { lat: 37.8228, lng: 128.1555 }, // 강원 - + // 충청 "43": { lat: 36.6357, lng: 127.4913 }, // 충북 - "44": { lat: 36.5184, lng: 126.8000 }, // 충남 - + "44": { lat: 36.5184, lng: 126.8 }, // 충남 + // 전라 - "45": { lat: 35.7175, lng: 127.1530 }, // 전북 - "46": { lat: 34.8679, lng: 126.9910 }, // 전남 - + "45": { lat: 35.7175, lng: 127.153 }, // 전북 + "46": { lat: 34.8679, lng: 126.991 }, // 전남 + // 경상 "47": { lat: 36.4919, lng: 128.8889 }, // 경북 "48": { lat: 35.4606, lng: 128.2132 }, // 경남 - + // 제주 "50": { lat: 33.4996, lng: 126.5312 }, // 제주 - + // 광역시 "26": { lat: 35.1796, lng: 129.0756 }, // 부산 "27": { lat: 35.8714, lng: 128.6014 }, // 대구 "28": { lat: 35.1595, lng: 126.8526 }, // 광주 "29": { lat: 36.3504, lng: 127.3845 }, // 대전 "30": { lat: 35.5384, lng: 129.3114 }, // 울산 - "31": { lat: 36.8000, lng: 127.7000 }, // 세종 + "31": { lat: 36.8, lng: 127.7 }, // 세종 }; return regionCodeMap[code] || null; @@ -585,30 +631,130 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 해상 구역 폴리곤 좌표 (기상청 특보 구역 기준) const MARITIME_ZONES: Record> = { // 제주도 해역 - 제주도남부앞바다: [[33.25, 126.0], [33.25, 126.85], [33.0, 126.85], [33.0, 126.0]], - 제주도남쪽바깥먼바다: [[33.15, 125.7], [33.15, 127.3], [32.5, 127.3], [32.5, 125.7]], - 제주도동부앞바다: [[33.4, 126.7], [33.4, 127.25], [33.05, 127.25], [33.05, 126.7]], - 제주도남동쪽안쪽먼바다: [[33.3, 126.85], [33.3, 127.95], [32.65, 127.95], [32.65, 126.85]], - 제주도남서쪽안쪽먼바다: [[33.3, 125.35], [33.3, 126.45], [32.7, 126.45], [32.7, 125.35]], + 제주도남부앞바다: [ + [33.25, 126.0], + [33.25, 126.85], + [33.0, 126.85], + [33.0, 126.0], + ], + 제주도남쪽바깥먼바다: [ + [33.15, 125.7], + [33.15, 127.3], + [32.5, 127.3], + [32.5, 125.7], + ], + 제주도동부앞바다: [ + [33.4, 126.7], + [33.4, 127.25], + [33.05, 127.25], + [33.05, 126.7], + ], + 제주도남동쪽안쪽먼바다: [ + [33.3, 126.85], + [33.3, 127.95], + [32.65, 127.95], + [32.65, 126.85], + ], + 제주도남서쪽안쪽먼바다: [ + [33.3, 125.35], + [33.3, 126.45], + [32.7, 126.45], + [32.7, 125.35], + ], // 남해 해역 - 남해동부앞바다: [[34.65, 128.3], [34.65, 129.65], [33.95, 129.65], [33.95, 128.3]], - 남해동부안쪽먼바다: [[34.25, 127.95], [34.25, 129.75], [33.45, 129.75], [33.45, 127.95]], - 남해동부바깥먼바다: [[33.65, 127.95], [33.65, 130.35], [32.45, 130.35], [32.45, 127.95]], + 남해동부앞바다: [ + [34.65, 128.3], + [34.65, 129.65], + [33.95, 129.65], + [33.95, 128.3], + ], + 남해동부안쪽먼바다: [ + [34.25, 127.95], + [34.25, 129.75], + [33.45, 129.75], + [33.45, 127.95], + ], + 남해동부바깥먼바다: [ + [33.65, 127.95], + [33.65, 130.35], + [32.45, 130.35], + [32.45, 127.95], + ], // 동해 해역 - 경북북부앞바다: [[36.65, 129.2], [36.65, 130.1], [35.95, 130.1], [35.95, 129.2]], - 경북남부앞바다: [[36.15, 129.1], [36.15, 129.95], [35.45, 129.95], [35.45, 129.1]], - 동해남부남쪽안쪽먼바다: [[35.65, 129.35], [35.65, 130.65], [34.95, 130.65], [34.95, 129.35]], - 동해남부남쪽바깥먼바다: [[35.25, 129.45], [35.25, 131.15], [34.15, 131.15], [34.15, 129.45]], - 동해남부북쪽안쪽먼바다: [[36.6, 129.65], [36.6, 130.95], [35.85, 130.95], [35.85, 129.65]], - 동해남부북쪽바깥먼바다: [[36.65, 130.35], [36.65, 132.15], [35.85, 132.15], [35.85, 130.35]], + 경북북부앞바다: [ + [36.65, 129.2], + [36.65, 130.1], + [35.95, 130.1], + [35.95, 129.2], + ], + 경북남부앞바다: [ + [36.15, 129.1], + [36.15, 129.95], + [35.45, 129.95], + [35.45, 129.1], + ], + 동해남부남쪽안쪽먼바다: [ + [35.65, 129.35], + [35.65, 130.65], + [34.95, 130.65], + [34.95, 129.35], + ], + 동해남부남쪽바깥먼바다: [ + [35.25, 129.45], + [35.25, 131.15], + [34.15, 131.15], + [34.15, 129.45], + ], + 동해남부북쪽안쪽먼바다: [ + [36.6, 129.65], + [36.6, 130.95], + [35.85, 130.95], + [35.85, 129.65], + ], + 동해남부북쪽바깥먼바다: [ + [36.65, 130.35], + [36.65, 132.15], + [35.85, 132.15], + [35.85, 130.35], + ], // 강원 해역 - 강원북부앞바다: [[38.15, 128.4], [38.15, 129.55], [37.45, 129.55], [37.45, 128.4]], - 강원중부앞바다: [[37.65, 128.7], [37.65, 129.6], [36.95, 129.6], [36.95, 128.7]], - 강원남부앞바다: [[37.15, 128.9], [37.15, 129.85], [36.45, 129.85], [36.45, 128.9]], - 동해중부안쪽먼바다: [[38.55, 129.35], [38.55, 131.15], [37.25, 131.15], [37.25, 129.35]], - 동해중부바깥먼바다: [[38.6, 130.35], [38.6, 132.55], [37.65, 132.55], [37.65, 130.35]], + 강원북부앞바다: [ + [38.15, 128.4], + [38.15, 129.55], + [37.45, 129.55], + [37.45, 128.4], + ], + 강원중부앞바다: [ + [37.65, 128.7], + [37.65, 129.6], + [36.95, 129.6], + [36.95, 128.7], + ], + 강원남부앞바다: [ + [37.15, 128.9], + [37.15, 129.85], + [36.45, 129.85], + [36.45, 128.9], + ], + 동해중부안쪽먼바다: [ + [38.55, 129.35], + [38.55, 131.15], + [37.25, 131.15], + [37.25, 129.35], + ], + 동해중부바깥먼바다: [ + [38.6, 130.35], + [38.6, 132.55], + [37.65, 132.55], + [37.65, 130.35], + ], // 울릉도·독도 - "울릉도.독도": [[37.7, 130.7], [37.7, 132.0], [37.4, 132.0], [37.4, 130.7]], + "울릉도.독도": [ + [37.7, 130.7], + [37.7, 132.0], + [37.4, 132.0], + [37.4, 130.7], + ], }; // 지역명을 위도/경도로 변환 @@ -624,70 +770,70 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { const regionNameMap: Record = { // 서울/경기 - "서울": { lat: 37.5665, lng: 126.9780 }, - "서울특별시": { lat: 37.5665, lng: 126.9780 }, - "경기": { lat: 37.4138, lng: 127.5183 }, - "경기도": { lat: 37.4138, lng: 127.5183 }, - "인천": { lat: 37.4563, lng: 126.7052 }, - "인천광역시": { lat: 37.4563, lng: 126.7052 }, - + 서울: { lat: 37.5665, lng: 126.978 }, + 서울특별시: { lat: 37.5665, lng: 126.978 }, + 경기: { lat: 37.4138, lng: 127.5183 }, + 경기도: { lat: 37.4138, lng: 127.5183 }, + 인천: { lat: 37.4563, lng: 126.7052 }, + 인천광역시: { lat: 37.4563, lng: 126.7052 }, + // 강원 - "강원": { lat: 37.8228, lng: 128.1555 }, - "강원도": { lat: 37.8228, lng: 128.1555 }, - "강원특별자치도": { lat: 37.8228, lng: 128.1555 }, - + 강원: { lat: 37.8228, lng: 128.1555 }, + 강원도: { lat: 37.8228, lng: 128.1555 }, + 강원특별자치도: { lat: 37.8228, lng: 128.1555 }, + // 충청 - "충북": { lat: 36.6357, lng: 127.4913 }, - "충청북도": { lat: 36.6357, lng: 127.4913 }, - "충남": { lat: 36.5184, lng: 126.8000 }, - "충청남도": { lat: 36.5184, lng: 126.8000 }, - "대전": { lat: 36.3504, lng: 127.3845 }, - "대전광역시": { lat: 36.3504, lng: 127.3845 }, - "세종": { lat: 36.8000, lng: 127.7000 }, - "세종특별자치시": { lat: 36.8000, lng: 127.7000 }, - + 충북: { lat: 36.6357, lng: 127.4913 }, + 충청북도: { lat: 36.6357, lng: 127.4913 }, + 충남: { lat: 36.5184, lng: 126.8 }, + 충청남도: { lat: 36.5184, lng: 126.8 }, + 대전: { lat: 36.3504, lng: 127.3845 }, + 대전광역시: { lat: 36.3504, lng: 127.3845 }, + 세종: { lat: 36.8, lng: 127.7 }, + 세종특별자치시: { lat: 36.8, lng: 127.7 }, + // 전라 - "전북": { lat: 35.7175, lng: 127.1530 }, - "전북특별자치도": { lat: 35.7175, lng: 127.1530 }, - "전라북도": { lat: 35.7175, lng: 127.1530 }, - "전남": { lat: 34.8679, lng: 126.9910 }, - "전라남도": { lat: 34.8679, lng: 126.9910 }, - "광주": { lat: 35.1595, lng: 126.8526 }, - "광주광역시": { lat: 35.1595, lng: 126.8526 }, - + 전북: { lat: 35.7175, lng: 127.153 }, + 전북특별자치도: { lat: 35.7175, lng: 127.153 }, + 전라북도: { lat: 35.7175, lng: 127.153 }, + 전남: { lat: 34.8679, lng: 126.991 }, + 전라남도: { lat: 34.8679, lng: 126.991 }, + 광주: { lat: 35.1595, lng: 126.8526 }, + 광주광역시: { lat: 35.1595, lng: 126.8526 }, + // 경상 - "경북": { lat: 36.4919, lng: 128.8889 }, - "경상북도": { lat: 36.4919, lng: 128.8889 }, - "포항": { lat: 36.0190, lng: 129.3435 }, - "포항시": { lat: 36.0190, lng: 129.3435 }, - "경주": { lat: 35.8562, lng: 129.2247 }, - "경주시": { lat: 35.8562, lng: 129.2247 }, - "안동": { lat: 36.5684, lng: 128.7294 }, - "안동시": { lat: 36.5684, lng: 128.7294 }, - "영주": { lat: 36.8056, lng: 128.6239 }, - "영주시": { lat: 36.8056, lng: 128.6239 }, - "경남": { lat: 35.4606, lng: 128.2132 }, - "경상남도": { lat: 35.4606, lng: 128.2132 }, - "창원": { lat: 35.2280, lng: 128.6811 }, - "창원시": { lat: 35.2280, lng: 128.6811 }, - "진주": { lat: 35.1800, lng: 128.1076 }, - "진주시": { lat: 35.1800, lng: 128.1076 }, - "부산": { lat: 35.1796, lng: 129.0756 }, - "부산광역시": { lat: 35.1796, lng: 129.0756 }, - "대구": { lat: 35.8714, lng: 128.6014 }, - "대구광역시": { lat: 35.8714, lng: 128.6014 }, - "울산": { lat: 35.5384, lng: 129.3114 }, - "울산광역시": { lat: 35.5384, lng: 129.3114 }, - + 경북: { lat: 36.4919, lng: 128.8889 }, + 경상북도: { lat: 36.4919, lng: 128.8889 }, + 포항: { lat: 36.019, lng: 129.3435 }, + 포항시: { lat: 36.019, lng: 129.3435 }, + 경주: { lat: 35.8562, lng: 129.2247 }, + 경주시: { lat: 35.8562, lng: 129.2247 }, + 안동: { lat: 36.5684, lng: 128.7294 }, + 안동시: { lat: 36.5684, lng: 128.7294 }, + 영주: { lat: 36.8056, lng: 128.6239 }, + 영주시: { lat: 36.8056, lng: 128.6239 }, + 경남: { lat: 35.4606, lng: 128.2132 }, + 경상남도: { lat: 35.4606, lng: 128.2132 }, + 창원: { lat: 35.228, lng: 128.6811 }, + 창원시: { lat: 35.228, lng: 128.6811 }, + 진주: { lat: 35.18, lng: 128.1076 }, + 진주시: { lat: 35.18, lng: 128.1076 }, + 부산: { lat: 35.1796, lng: 129.0756 }, + 부산광역시: { lat: 35.1796, lng: 129.0756 }, + 대구: { lat: 35.8714, lng: 128.6014 }, + 대구광역시: { lat: 35.8714, lng: 128.6014 }, + 울산: { lat: 35.5384, lng: 129.3114 }, + 울산광역시: { lat: 35.5384, lng: 129.3114 }, + // 제주 - "제주": { lat: 33.4996, lng: 126.5312 }, - "제주도": { lat: 33.4996, lng: 126.5312 }, - "제주특별자치도": { lat: 33.4996, lng: 126.5312 }, - + 제주: { lat: 33.4996, lng: 126.5312 }, + 제주도: { lat: 33.4996, lng: 126.5312 }, + 제주특별자치도: { lat: 33.4996, lng: 126.5312 }, + // 울릉도/독도 - "울릉도": { lat: 37.4845, lng: 130.9057 }, + 울릉도: { lat: 37.4845, lng: 130.9057 }, "울릉도.독도": { lat: 37.4845, lng: 130.9057 }, - "독도": { lat: 37.2433, lng: 131.8642 }, + 독도: { lat: 37.2433, lng: 131.8642 }, }; // 정확한 매칭 @@ -705,23 +851,18 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return null; }; - // 데이터를 마커로 변환 (하위 호환성) + // 데이터를 마커로 변환 (하위 호환성 - 현재 미사용) + // eslint-disable-next-line @typescript-eslint/no-unused-vars const convertToMarkers = (rows: any[]): MarkerData[] => { if (rows.length === 0) return []; // 위도/경도 컬럼 찾기 const firstRow = rows[0]; const columns = Object.keys(firstRow); - - const latColumn = columns.find((col) => - /^(lat|latitude|위도|y)$/i.test(col) - ); - const lngColumn = columns.find((col) => - /^(lng|lon|longitude|경도|x)$/i.test(col) - ); - const nameColumn = columns.find((col) => - /^(name|title|이름|명칭|location)$/i.test(col) - ); + + const latColumn = columns.find((col) => /^(lat|latitude|위도|y)$/i.test(col)); + const lngColumn = columns.find((col) => /^(lng|lon|longitude|경도|x)$/i.test(col)); + const nameColumn = columns.find((col) => /^(name|title|이름|명칭|location)$/i.test(col)); if (!latColumn || !lngColumn) { console.warn("⚠️ 위도/경도 컬럼을 찾을 수 없습니다."); @@ -737,7 +878,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return null; } - return { + const marker: MarkerData = { id: row.id || `marker-${index}`, lat, lng, @@ -747,6 +888,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { status: row.status, description: JSON.stringify(row, null, 2), }; + return marker; }) .filter((marker): marker is MarkerData => marker !== null); }; @@ -766,70 +908,60 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { loadGeoJsonData(); }, []); - // 초기 로드 + // 초기 로드 및 자동 새로고침 useEffect(() => { - const dataSources = element?.dataSources || element?.chartConfig?.dataSources; - // // console.log("🔄 useEffect 트리거! dataSources:", dataSources); - if (dataSources && dataSources.length > 0) { - loadMultipleDataSources(); - } else { - // // console.log("⚠️ dataSources가 없거나 비어있음"); + console.log("🔄 지도 위젯 초기화"); + + if (!dataSources || dataSources.length === 0) { + console.log("⚠️ dataSources가 없거나 비어있음"); setMarkers([]); setPolygons([]); + return; } - }, [dataSources, loadMultipleDataSources]); - // 자동 새로고침 - useEffect(() => { - if (!dataSources || dataSources.length === 0) return; + // 즉시 첫 로드 + console.log("📡 초기 데이터 로드"); + loadMultipleDataSources(); - // 모든 데이터 소스 중 가장 짧은 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}초마다`); + // 5초마다 자동 새로고침 + const refreshInterval = 5; + console.log(`⏱️ 자동 새로고침 설정: ${refreshInterval}초마다`); const intervalId = setInterval(() => { - // // console.log("🔄 자동 새로고침 실행"); + console.log("🔄 자동 새로고침 실행"); loadMultipleDataSources(); - }, minInterval * 1000); + }, refreshInterval * 1000); return () => { - // // console.log("⏹️ 자동 새로고침 정리"); + console.log("⏹️ 자동 새로고침 정리"); clearInterval(intervalId); }; - }, [dataSources, loadMultipleDataSources]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 타일맵 URL (chartConfig에서 가져오기) - const tileMapUrl = element?.chartConfig?.tileMapUrl || - `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; + const tileMapUrl = + element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`; // 지도 중심점 계산 - const center: [number, number] = markers.length > 0 - ? [ - markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, - markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, - ] - : [37.5665, 126.978]; // 기본: 서울 + const center: [number, number] = + markers.length > 0 + ? [ + markers.reduce((sum, m) => sum + m.lat, 0) / markers.length, + markers.reduce((sum, m) => sum + m.lng, 0) / markers.length, + ] + : [37.5665, 126.978]; // 기본: 서울 return ( -
+
{/* 헤더 */}
-

- {element?.customTitle || "지도"} -

-

+

{element?.customTitle || "지도"}

+

{element?.dataSources?.length || 0}개 데이터 소스 연결됨 {lastRefreshTime && ( - - • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")} - + • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")} )}

@@ -852,27 +984,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
{error ? (
-

{error}

+

{error}

) : !element?.dataSources || element.dataSources.length === 0 ? (
-

- 데이터 소스를 연결해주세요 -

+

데이터 소스를 연결해주세요

) : ( - - - + + + {/* 폴리곤 렌더링 */} {/* GeoJSON 렌더링 (육지 지역 경계선) */} {(() => { @@ -885,16 +1006,16 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { })()} {geoJsonData && polygons.length > 0 ? ( p.id))} // 폴리곤 변경 시 재렌더링 + 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; // 시/군/구명 (예: 군위군) - + // 폴리곤 매칭 (시/군/구명 우선, 없으면 시/도명) - const matchingPolygon = polygons.find(p => { + const matchingPolygon = polygons.find((p) => { if (!p.name) return false; - + // 정확한 매칭 if (p.name === sigName) { // console.log(`✅ 정확 매칭: ${p.name} === ${sigName}`); @@ -904,7 +1025,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // console.log(`✅ 정확 매칭: ${p.name} === ${ctpName}`); return true; } - + // 부분 매칭 (GeoJSON 지역명에 폴리곤 이름이 포함되는지) if (sigName && sigName.includes(p.name)) { // console.log(`✅ 부분 매칭: ${sigName} includes ${p.name}`); @@ -914,7 +1035,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // console.log(`✅ 부분 매칭: ${ctpName} includes ${p.name}`); return true; } - + // 역방향 매칭 (폴리곤 이름에 GeoJSON 지역명이 포함되는지) if (sigName && p.name.includes(sigName)) { // console.log(`✅ 역방향 매칭: ${p.name} includes ${sigName}`); @@ -924,7 +1045,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // console.log(`✅ 역방향 매칭: ${p.name} includes ${ctpName}`); return true; } - + return false; }); @@ -945,8 +1066,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { onEachFeature={(feature: any, layer: any) => { const ctpName = feature?.properties?.CTP_KOR_NM; const sigName = feature?.properties?.SIG_KOR_NM; - - const matchingPolygon = polygons.find(p => { + + const matchingPolygon = polygons.find((p) => { if (!p.name) return false; if (p.name === sigName || p.name === ctpName) return true; if (sigName && sigName.includes(p.name)) return true; @@ -960,9 +1081,9 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { layer.bindPopup(`
${matchingPolygon.name}
- ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ''} - ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ''} - ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ''} + ${matchingPolygon.source ? `
출처: ${matchingPolygon.source}
` : ""} + ${matchingPolygon.status ? `
상태: ${matchingPolygon.status}
` : ""} + ${matchingPolygon.description ? `
${matchingPolygon.description}
` : ""}
`); } @@ -975,150 +1096,152 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { )} {/* 폴리곤 렌더링 (해상 구역만) */} - {polygons.filter(p => MARITIME_ZONES[p.name]).map((polygon) => ( - - -
-
{polygon.name}
- {polygon.source && ( -
- 출처: {polygon.source} -
- )} - {polygon.status && ( -
- 상태: {polygon.status} -
- )} - {polygon.description && ( -
-
{polygon.description}
-
- )} -
-
-
- ))} - - {/* 마커 렌더링 */} + {polygons + .filter((p) => MARITIME_ZONES[p.name]) + .map((polygon) => ( + + +
+
{polygon.name}
+ {polygon.source && ( +
출처: {polygon.source}
+ )} + {polygon.status &&
상태: {polygon.status}
} + {polygon.description && ( +
+
{polygon.description}
+
+ )} +
+
+
+ ))} + + {/* 마커 렌더링 (화살표 모양) */} {markers.map((marker) => { - // 커스텀 색상 아이콘 생성 - let customIcon; + // 화살표 아이콘 생성 (진행 방향으로 회전) + let arrowIcon: any; if (typeof window !== "undefined") { const L = require("leaflet"); - customIcon = L.divIcon({ - className: "custom-marker", + const heading = marker.heading || 0; + arrowIcon = L.divIcon({ + className: "custom-arrow-marker", html: `
+ transform: translate(-50%, -50%) rotate(${heading}deg); + filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); + "> + + + + + + + + +
`, - iconSize: [30, 30], - iconAnchor: [15, 15], + iconSize: [40, 40], + iconAnchor: [20, 20], }); } return ( - - -
- {/* 제목 */} -
-
{marker.name}
- {marker.source && ( -
- 📡 {marker.source} -
- )} -
+ + +
+ {/* 제목 */} +
+
{marker.name}
+ {marker.source &&
📡 {marker.source}
} +
- {/* 상세 정보 */} -
- {marker.description && ( -
-
상세 정보
-
- {(() => { - try { - const parsed = JSON.parse(marker.description); - return ( -
- {parsed.incidenteTypeCd === "1" && ( -
🚨 교통사고
- )} - {parsed.incidenteTypeCd === "2" && ( -
🚧 도로공사
- )} - {parsed.addressJibun && ( -
📍 {parsed.addressJibun}
- )} - {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && ( -
📍 {parsed.addressNew}
- )} - {parsed.roadName && ( -
🛣️ {parsed.roadName}
- )} - {parsed.linkName && ( -
🔗 {parsed.linkName}
- )} - {parsed.incidentMsg && ( -
💬 {parsed.incidentMsg}
- )} - {parsed.eventContent && ( -
📝 {parsed.eventContent}
- )} - {parsed.startDate && ( -
🕐 {parsed.startDate}
- )} - {parsed.endDate && ( -
🕐 종료: {parsed.endDate}
- )} -
- ); - } catch { - return marker.description; - } - })()} + {/* 상세 정보 */} +
+ {marker.description && ( +
+
상세 정보
+
+ {(() => { + try { + const parsed = JSON.parse(marker.description); + return ( +
+ {parsed.incidenteTypeCd === "1" && ( +
🚨 교통사고
+ )} + {parsed.incidenteTypeCd === "2" && ( +
🚧 도로공사
+ )} + {parsed.addressJibun &&
📍 {parsed.addressJibun}
} + {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && ( +
📍 {parsed.addressNew}
+ )} + {parsed.roadName &&
🛣️ {parsed.roadName}
} + {parsed.linkName &&
🔗 {parsed.linkName}
} + {parsed.incidentMsg && ( +
💬 {parsed.incidentMsg}
+ )} + {parsed.eventContent && ( +
📝 {parsed.eventContent}
+ )} + {parsed.startDate &&
🕐 {parsed.startDate}
} + {parsed.endDate &&
🕐 종료: {parsed.endDate}
} +
+ ); + } catch { + return marker.description; + } + })()} +
-
- )} + )} - {marker.status && ( -
- 상태: {marker.status} -
- )} + {marker.status && ( +
+ 상태: {marker.status} +
+ )} - {/* 좌표 */} -
- 📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} + {/* 좌표 */} +
+ 📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} +
-
- - + + ); })} @@ -1127,7 +1250,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { {/* 하단 정보 */} {(markers.length > 0 || polygons.length > 0) && ( -
+
{markers.length > 0 && `마커 ${markers.length}개`} {markers.length > 0 && polygons.length > 0 && " · "} {polygons.length > 0 && `영역 ${polygons.length}개`} @@ -1136,4 +1259,3 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
); } - From 68184ac49f27710179bb91fbd13d9ffa8626d963 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 16:57:21 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/DashboardViewer.tsx | 134 +++++++++--------- .../dashboard/widgets/MapTestWidgetV2.tsx | 49 ++++--- 2 files changed, 100 insertions(+), 83 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 2b21b5f4..30a6f53c 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -16,8 +16,8 @@ import { import dynamic from "next/dynamic"; // 위젯 동적 import - 모든 위젯 -const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false }); -const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false }); +// const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { ssr: false }); +// const MapTestWidget = dynamic(() => import("./widgets/MapTestWidget"), { ssr: false }); const MapTestWidgetV2 = dynamic(() => import("./widgets/MapTestWidgetV2"), { ssr: false }); const ChartTestWidget = dynamic(() => import("./widgets/ChartTestWidget"), { ssr: false }); const ListTestWidget = dynamic( @@ -27,7 +27,7 @@ const ListTestWidget = dynamic( const CustomMetricTestWidget = dynamic(() => import("./widgets/CustomMetricTestWidget"), { ssr: false }); const RiskAlertTestWidget = dynamic(() => import("./widgets/RiskAlertTestWidget"), { ssr: false }); const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { ssr: false }); -const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false }); +// const RiskAlertWidget = dynamic(() => import("./widgets/RiskAlertWidget"), { ssr: false }); const WeatherWidget = dynamic(() => import("./widgets/WeatherWidget"), { ssr: false }); const WeatherMapWidget = dynamic(() => import("./widgets/WeatherMapWidget"), { ssr: false }); const ExchangeWidget = dynamic(() => import("./widgets/ExchangeWidget"), { ssr: false }); @@ -51,10 +51,10 @@ const ClockWidget = dynamic( () => import("@/components/admin/dashboard/widgets/ClockWidget").then((mod) => ({ default: mod.ClockWidget })), { ssr: false }, ); -const ListWidget = dynamic( - () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })), - { ssr: false }, -); +// const ListWidget = dynamic( +// () => import("@/components/admin/dashboard/widgets/ListWidget").then((mod) => ({ default: mod.ListWidget })), +// { ssr: false }, +// ); const YardManagement3DWidget = dynamic(() => import("@/components/admin/dashboard/widgets/YardManagement3DWidget"), { ssr: false, @@ -68,9 +68,9 @@ const CustomStatsWidget = dynamic(() => import("./widgets/CustomStatsWidget"), { ssr: false, }); -const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), { - ssr: false, -}); +// const CustomMetricWidget = dynamic(() => import("./widgets/CustomMetricWidget"), { +// ssr: false, +// }); /** * 위젯 렌더링 함수 - DashboardSidebar의 모든 subtype 처리 @@ -91,10 +91,10 @@ function renderWidget(element: DashboardElement) { return ; case "clock": return ; - case "map-summary": - return ; - case "map-test": - return ; + // case "map-summary": + // return ; + // case "map-test": + // return ; case "map-summary-v2": return ; case "chart": @@ -105,14 +105,14 @@ function renderWidget(element: DashboardElement) { return ; case "risk-alert-v2": return ; - case "risk-alert": - return ; + // case "risk-alert": + // return ; case "calendar": return ; case "status-summary": return ; - case "custom-metric": - return ; + // case "custom-metric": + // return ; // === 운영/작업 지원 === case "todo": @@ -122,8 +122,8 @@ function renderWidget(element: DashboardElement) { return ; case "document": return ; - case "list": - return ; + // case "list": + // return ; case "yard-management-3d": // console.log("🏗️ 야드관리 위젯 렌더링:", { @@ -171,7 +171,7 @@ function renderWidget(element: DashboardElement) { // === 기본 fallback === default: return ( -
+
알 수 없는 위젯 타입: {element.subtype}
@@ -212,7 +212,7 @@ export function DashboardViewer({ dataUrl: string, format: "png" | "pdf", canvasWidth: number, - canvasHeight: number + canvasHeight: number, ) => { if (format === "png") { console.log("💾 PNG 다운로드 시작..."); @@ -227,7 +227,7 @@ export function DashboardViewer({ } else { console.log("📄 PDF 생성 중..."); const jsPDF = (await import("jspdf")).default; - + // dataUrl에서 이미지 크기 계산 const img = new Image(); img.src = dataUrl; @@ -274,40 +274,41 @@ export function DashboardViewer({ console.log("📸 html-to-image 로딩 중..."); // html-to-image 동적 import + // @ts-expect-error - html-to-image 타입 선언 누락 const { toPng } = await import("html-to-image"); console.log("📸 캔버스 캡처 중..."); - + // 3D/WebGL 렌더링 완료 대기 console.log("⏳ 3D 렌더링 완료 대기 중..."); await new Promise((resolve) => setTimeout(resolve, 1000)); - + // WebGL 캔버스를 이미지로 변환 (Three.js 캔버스 보존) console.log("🎨 WebGL 캔버스 처리 중..."); const webglCanvases = canvas.querySelectorAll("canvas"); const webglImages: { canvas: HTMLCanvasElement; dataUrl: string; rect: DOMRect }[] = []; - + webglCanvases.forEach((webglCanvas) => { try { const rect = webglCanvas.getBoundingClientRect(); const dataUrl = webglCanvas.toDataURL("image/png"); webglImages.push({ canvas: webglCanvas, dataUrl, rect }); - console.log("✅ WebGL 캔버스 캡처:", { - width: rect.width, + console.log("✅ WebGL 캔버스 캡처:", { + width: rect.width, height: rect.height, left: rect.left, top: rect.top, - bottom: rect.bottom + bottom: rect.bottom, }); } catch (error) { console.warn("⚠️ WebGL 캔버스 캡처 실패:", error); } }); - + // 캔버스의 실제 크기와 위치 가져오기 const rect = canvas.getBoundingClientRect(); const canvasWidth = canvas.scrollWidth; - + // 실제 콘텐츠의 최하단 위치 계산 // 뷰어 모드에서는 모든 자식 요소를 확인 const children = canvas.querySelectorAll("*"); @@ -323,17 +324,17 @@ export function DashboardViewer({ maxBottom = relativeBottom; } }); - + // 실제 콘텐츠 높이 + 여유 공간 (50px) // maxBottom이 0이면 기본 캔버스 높이 사용 const canvasHeight = maxBottom > 50 ? maxBottom + 50 : Math.max(canvas.scrollHeight, rect.height); - + console.log("📐 캔버스 정보:", { rect: { x: rect.x, y: rect.y, left: rect.left, top: rect.top, width: rect.width, height: rect.height }, scroll: { width: canvasWidth, height: canvas.scrollHeight }, calculated: { width: canvasWidth, height: canvasHeight }, maxBottom: maxBottom, - webglCount: webglImages.length + webglCount: webglImages.length, }); // html-to-image로 캔버스 캡처 (WebGL 제외) @@ -344,8 +345,8 @@ export function DashboardViewer({ pixelRatio: 2, // 고해상도 cacheBust: true, skipFonts: false, - preferredFontFormat: 'woff2', - filter: (node) => { + preferredFontFormat: "woff2", + filter: (node: Node) => { // WebGL 캔버스는 제외 (나중에 수동으로 합성) if (node instanceof HTMLCanvasElement) { return false; @@ -353,7 +354,7 @@ export function DashboardViewer({ return true; }, }); - + // WebGL 캔버스를 이미지 위에 합성 if (webglImages.length > 0) { console.log("🖼️ WebGL 이미지 합성 중..."); @@ -362,17 +363,17 @@ export function DashboardViewer({ await new Promise((resolve) => { img.onload = resolve; }); - + // 새 캔버스에 합성 const compositeCanvas = document.createElement("canvas"); compositeCanvas.width = img.width; compositeCanvas.height = img.height; const ctx = compositeCanvas.getContext("2d"); - + if (ctx) { // 기본 이미지 그리기 ctx.drawImage(img, 0, 0); - + // WebGL 이미지들을 위치에 맞게 그리기 for (const { dataUrl: webglDataUrl, rect: webglRect } of webglImages) { const webglImg = new Image(); @@ -380,28 +381,28 @@ export function DashboardViewer({ await new Promise((resolve) => { webglImg.onload = resolve; }); - + // 상대 위치 계산 (pixelRatio 2 고려) const relativeX = (webglRect.left - rect.left) * 2; const relativeY = (webglRect.top - rect.top) * 2; const width = webglRect.width * 2; const height = webglRect.height * 2; - + ctx.drawImage(webglImg, relativeX, relativeY, width, height); console.log("✅ WebGL 이미지 합성 완료:", { x: relativeX, y: relativeY, width, height }); } - + // 합성된 이미지를 dataUrl로 변환 const compositeDataUrl = compositeCanvas.toDataURL("image/png"); console.log("✅ 최종 합성 완료"); - + // 합성된 이미지로 다운로드 return await handleDownloadWithDataUrl(compositeDataUrl, format, canvasWidth, canvasHeight); } } console.log("✅ 캡처 완료 (WebGL 없음)"); - + // WebGL이 없는 경우 기본 다운로드 await handleDownloadWithDataUrl(dataUrl, format, canvasWidth, canvasHeight); } catch (error) { @@ -409,7 +410,8 @@ export function DashboardViewer({ alert(`다운로드에 실패했습니다.\n\n에러: ${error instanceof Error ? error.message : String(error)}`); } }, - [backgroundColor, dashboardTitle], + // eslint-disable-next-line react-hooks/exhaustive-deps + [backgroundColor, dashboardTitle, handleDownloadWithDataUrl], ); // 캔버스 설정 계산 @@ -528,11 +530,11 @@ export function DashboardViewer({ // 요소가 없는 경우 if (elements.length === 0) { return ( -
+
📊
-
표시할 요소가 없습니다
-
대시보드 편집기에서 차트나 위젯을 추가해보세요
+
표시할 요소가 없습니다
+
대시보드 편집기에서 차트나 위젯을 추가해보세요
); @@ -541,8 +543,8 @@ export function DashboardViewer({ return ( {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */} -
-
+
+
{/* 다운로드 버튼 */}
@@ -584,7 +586,7 @@ export function DashboardViewer({
{/* 태블릿 이하: 반응형 세로 정렬 */} -
+
{/* 다운로드 버튼 */}
@@ -646,16 +648,16 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi // 태블릿 이하: 세로 스택 카드 스타일 return (
{element.showHeader !== false && (
-

{element.customTitle || element.title}

+

{element.customTitle || element.title}

-
From adb1056b3f2964d9fb8945b2cee2c30dc050b4ff Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 17:53:38 +0900 Subject: [PATCH 5/9] =?UTF-8?q?"=EC=A7=80=EB=8F=84=EA=B0=80=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=EB=90=98=EC=97=88=EC=8A=B5=EB=8B=88=EB=8B=A4"=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/MapTestWidgetV2.tsx | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index 834275b3..e1faded8 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -955,7 +955,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {

{element?.customTitle || "지도"}

- {element?.dataSources?.length || 0}개 데이터 소스 연결됨 + {dataSources?.length || 0}개 데이터 소스 연결됨 {lastRefreshTime && ( • 마지막 업데이트: {lastRefreshTime.toLocaleTimeString("ko-KR")} )} @@ -1281,22 +1281,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ); })} - {/* 데이터 소스 없을 때 안내 메시지 */} - {(!element?.dataSources || element.dataSources.length === 0) && ( -

-
-

- 📍 지도가 표시되었습니다 -

-

- 데이터 소스를 연결하면 마커가 표시됩니다 -

-
-
- )} )}
From c5d85695229479647556a4500b02a1b4c8d3f85b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 17:58:22 +0900 Subject: [PATCH 6/9] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/WidgetConfigSidebar.tsx | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 9fcfe296..5c272ec8 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -297,10 +297,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge ...(needsDataSource(element.subtype) ? { dataSource, - // 다중 데이터 소스 위젯은 dataSources도 포함 + // 다중 데이터 소스 위젯은 dataSources도 포함 (빈 배열도 허용 - 연결 해제) ...(isMultiDataSourceWidget ? { - dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], + dataSources: dataSources, } : {}), } @@ -316,14 +316,14 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge element.subtype === "chart" || ["bar", "horizontal-bar", "pie", "line", "area", "stacked-bar", "donut", "combo"].includes(element.subtype) ? { - // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 + // 다중 데이터 소스 위젯은 chartConfig에 dataSources 포함 (빈 배열도 허용 - 연결 해제) chartConfig: isMultiDataSourceWidget - ? { ...chartConfig, dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [] } + ? { ...chartConfig, dataSources: dataSources } : chartConfig, - // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 + // 프론트엔드 호환성을 위해 dataSources도 element에 직접 포함 (빈 배열도 허용 - 연결 해제) ...(isMultiDataSourceWidget ? { - dataSources: dataSources.length > 0 ? dataSources : element.dataSources || [], + dataSources: dataSources, } : {}), } @@ -570,15 +570,16 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge onClick={handleApply} className="h-9 flex-1 text-sm" disabled={ - // 다중 데이터 소스 위젯: dataSources가 비어있거나 endpoint가 없으면 비활성화 + // 다중 데이터 소스 위젯: 데이터 소스가 있는데 endpoint가 비어있으면 비활성화 + // (데이터 소스가 없는 건 OK - 연결 해제하는 경우) (element?.subtype === "map-summary-v2" || element?.subtype === "chart" || element?.subtype === "list-v2" || element?.subtype === "custom-metric-v2" || element?.subtype === "risk-alert-v2") && - (!dataSources || - dataSources.length === 0 || - dataSources.some(ds => ds.type === "api" && !ds.endpoint)) + dataSources && + dataSources.length > 0 && + dataSources.some(ds => ds.type === "api" && !ds.endpoint) } > 적용 From 5e8e714e8ae86ac28ab4a8fcc5f38328d35f90aa Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 18:22:02 +0900 Subject: [PATCH 7/9] =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=9C=84=EC=A0=AF=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EB=B2=84=ED=8A=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 4 +- .../admin/dashboard/WidgetConfigSidebar.tsx | 5 +- .../components/dashboard/DashboardViewer.tsx | 50 +++---------------- 3 files changed, 15 insertions(+), 44 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 09ddfe5c..ce08c522 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -853,7 +853,9 @@ export function CanvasElement({ )} {/* 제목 */} {!element.type || element.type !== "chart" ? ( - {element.customTitle || element.title} + element.subtype === "map-summary-v2" && !element.customTitle ? null : ( + {element.customTitle || element.title} + ) ) : null}
diff --git a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx index 5c272ec8..7ca9684b 100644 --- a/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx +++ b/frontend/components/admin/dashboard/WidgetConfigSidebar.tsx @@ -152,7 +152,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge setCustomTitle(element.customTitle || ""); setShowHeader(element.showHeader !== false); setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 }); - setDataSources(element.dataSources || []); + // dataSources는 element.dataSources 또는 chartConfig.dataSources에서 가져옴 + setDataSources(element.dataSources || element.chartConfig?.dataSources || []); setQueryResult(null); // 리스트 위젯 설정 초기화 @@ -301,6 +302,8 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge ...(isMultiDataSourceWidget ? { dataSources: dataSources, + // chartConfig에도 dataSources 포함 (일부 위젯은 chartConfig에서 읽음) + chartConfig: { ...chartConfig, dataSources: dataSources }, } : {}), } diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 30a6f53c..d26ac0b7 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -653,27 +653,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi > {element.showHeader !== false && (
-

{element.customTitle || element.title}

- + {/* map-summary-v2는 customTitle이 없으면 제목 숨김 */} + {element.subtype === "map-summary-v2" && !element.customTitle ? null : ( +

{element.customTitle || element.title}

+ )}
)}
@@ -716,27 +699,10 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi > {element.showHeader !== false && (
-

{element.customTitle || element.title}

- + {/* map-summary-v2는 customTitle이 없으면 제목 숨김 */} + {element.subtype === "map-summary-v2" && !element.customTitle ? null : ( +

{element.customTitle || element.title}

+ )}
)}
From 800bd858118596605b408429dad85b5b9fb76fc1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 12 Nov 2025 19:08:41 +0900 Subject: [PATCH 8/9] =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/data-sources/MultiApiConfig.tsx | 122 +++++++++++++ .../data-sources/MultiDatabaseConfig.tsx | 124 +++++++++++++- frontend/components/admin/dashboard/types.ts | 7 + .../dashboard/widgets/MapTestWidgetV2.tsx | 161 ++++++++++++------ 4 files changed, 359 insertions(+), 55 deletions(-) diff --git a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx index f16e8436..c72cb18e 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiApiConfig.tsx @@ -911,6 +911,128 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M

)} + + {/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */} + {availableColumns.length > 0 && ( +
+ + + {/* 기존 팝업 필드 목록 */} + {dataSource.popupFields && dataSource.popupFields.length > 0 && ( +
+ {dataSource.popupFields.map((field, index) => ( +
+
+ 필드 {index + 1} + +
+ + {/* 필드명 선택 */} +
+ + +
+ + {/* 라벨 입력 */} +
+ + { + const newFields = [...(dataSource.popupFields || [])]; + newFields[index].label = e.target.value; + onChange({ popupFields: newFields }); + }} + placeholder="예: 차량 번호" + className="h-8 w-full text-xs" + dir="ltr" + /> +
+ + {/* 포맷 선택 */} +
+ + +
+
+ ))} +
+ )} + + {/* 필드 추가 버튼 */} + + +

+ 마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요 +

+
+ )}
); } diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index 76986718..be4377b9 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -8,7 +8,7 @@ import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Loader2, CheckCircle, XCircle } from "lucide-react"; +import { Loader2, CheckCircle, XCircle, Plus, Trash2 } from "lucide-react"; interface MultiDatabaseConfigProps { dataSource: ChartDataSource; @@ -673,6 +673,128 @@ ORDER BY 하위부서수 DESC`,

)} + + {/* 지도 팝업 필드 설정 (MapTestWidgetV2 전용) */} + {availableColumns.length > 0 && ( +
+ + + {/* 기존 팝업 필드 목록 */} + {dataSource.popupFields && dataSource.popupFields.length > 0 && ( +
+ {dataSource.popupFields.map((field, index) => ( +
+
+ 필드 {index + 1} + +
+ + {/* 필드명 선택 */} +
+ + +
+ + {/* 라벨 입력 */} +
+ + { + const newFields = [...(dataSource.popupFields || [])]; + newFields[index].label = e.target.value; + onChange({ popupFields: newFields }); + }} + placeholder="예: 차량 번호" + className="h-8 w-full text-xs" + dir="ltr" + /> +
+ + {/* 포맷 선택 */} +
+ + +
+
+ ))} +
+ )} + + {/* 필드 추가 버튼 */} + + +

+ 마커 클릭 시 팝업에 표시할 필드를 선택하고 한글 라벨을 지정하세요 +

+
+ )}
); } diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 61fa60ae..f5490dbf 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -171,6 +171,13 @@ export interface ChartDataSource { // 메트릭 설정 (CustomMetricTestWidget용) selectedColumns?: string[]; // 표시할 컬럼 선택 (빈 배열이면 전체 표시) + + // 지도 팝업 설정 (MapTestWidgetV2용) + popupFields?: { + fieldName: string; // DB 컬럼명 (예: vehicle_number) + label: string; // 표시할 한글명 (예: 차량 번호) + format?: "text" | "date" | "datetime" | "number" | "url"; // 표시 포맷 + }[]; } export interface ChartConfig { diff --git a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx index e1faded8..dafc40fa 100644 --- a/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx +++ b/frontend/components/dashboard/widgets/MapTestWidgetV2.tsx @@ -919,7 +919,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 첫 번째 데이터 소스의 새로고침 간격 사용 (초) const firstDataSource = dataSources[0]; const refreshInterval = firstDataSource?.refreshInterval ?? 5; - + // 0이면 자동 새로고침 비활성화 if (refreshInterval === 0) { return; @@ -1123,12 +1123,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { // 첫 번째 데이터 소스의 마커 종류 가져오기 const firstDataSource = dataSources?.[0]; const markerType = firstDataSource?.markerType || "circle"; - + let markerIcon: any; if (typeof window !== "undefined") { const L = require("leaflet"); const heading = marker.heading || 0; - + if (markerType === "arrow") { // 화살표 마커 markerIcon = L.divIcon({ @@ -1216,63 +1216,117 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { return ( -
- {/* 제목 */} -
-
{marker.name}
- {marker.source &&
📡 {marker.source}
} -
+
+ {/* 데이터 소스명만 표시 */} + {marker.source && ( +
+
📡 {marker.source}
+
+ )} {/* 상세 정보 */}
- {marker.description && ( -
-
상세 정보
-
- {(() => { - try { - const parsed = JSON.parse(marker.description); - return ( -
- {parsed.incidenteTypeCd === "1" && ( -
🚨 교통사고
- )} - {parsed.incidenteTypeCd === "2" && ( -
🚧 도로공사
- )} - {parsed.addressJibun &&
📍 {parsed.addressJibun}
} - {parsed.addressNew && parsed.addressNew !== parsed.addressJibun && ( -
📍 {parsed.addressNew}
- )} - {parsed.roadName &&
🛣️ {parsed.roadName}
} - {parsed.linkName &&
🔗 {parsed.linkName}
} - {parsed.incidentMsg && ( -
💬 {parsed.incidentMsg}
- )} - {parsed.eventContent && ( -
📝 {parsed.eventContent}
- )} - {parsed.startDate &&
🕐 {parsed.startDate}
} - {parsed.endDate &&
🕐 종료: {parsed.endDate}
} -
- ); - } catch { - return marker.description; - } - })()} -
-
- )} + {marker.description && + (() => { + const firstDataSource = dataSources?.[0]; + const popupFields = firstDataSource?.popupFields; - {marker.status && ( -
- 상태: {marker.status} -
- )} + // popupFields가 설정되어 있으면 설정된 필드만 표시 + if (popupFields && popupFields.length > 0) { + try { + const parsed = JSON.parse(marker.description); + return ( +
+
상세 정보
+
+ {popupFields.map((field, idx) => { + const value = parsed[field.fieldName]; + if (value === undefined || value === null) return null; + + // 포맷팅 적용 + let formattedValue = value; + if (field.format === "date" && value) { + formattedValue = new Date(value).toLocaleDateString("ko-KR"); + } else if (field.format === "datetime" && value) { + formattedValue = new Date(value).toLocaleString("ko-KR"); + } else if (field.format === "number" && typeof value === "number") { + formattedValue = value.toLocaleString(); + } else if ( + field.format === "url" && + typeof value === "string" && + value.startsWith("http") + ) { + return ( +
+ {field.label}:{" "} + + 링크 열기 + +
+ ); + } + + return ( +
+ {field.label}:{" "} + {String(formattedValue)} +
+ ); + })} +
+
+ ); + } catch (error) { + return ( +
+
상세 정보
+
{marker.description}
+
+ ); + } + } + + // popupFields가 없으면 전체 데이터 표시 (기본 동작) + try { + const parsed = JSON.parse(marker.description); + return ( +
+
상세 정보
+
+ {Object.entries(parsed).map(([key, value], idx) => { + if (value === undefined || value === null) return null; + + // 좌표 필드는 제외 (하단에 별도 표시) + if (["lat", "lng", "latitude", "longitude", "x", "y"].includes(key)) return null; + + return ( +
+ {key}:{" "} + {String(value)} +
+ ); + })} +
+
+ ); + } catch (error) { + return ( +
+
상세 정보
+
{marker.description}
+
+ ); + } + })()} {/* 좌표 */}
- 📍 {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)} + {marker.lat.toFixed(6)}, {marker.lng.toFixed(6)}
@@ -1280,7 +1334,6 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) { ); })} - )}
From 3b9327f64c547ec01e4410902cd03a444a1f863a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 13 Nov 2025 12:10:04 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EA=B3=A0=EC=B9=A8=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data-sources/MultiDatabaseConfig.tsx | 331 +++++++++--------- 1 file changed, 168 insertions(+), 163 deletions(-) diff --git a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx index be4377b9..73b2ab4b 100644 --- a/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/MultiDatabaseConfig.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import { useState, useEffect } from "react"; import { ChartDataSource } from "@/components/admin/dashboard/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -45,13 +45,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult // ExternalDbConnectionAPI 사용 (인증 토큰 자동 포함) const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const connections = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" }); - + console.log("✅ 외부 DB 커넥션 로드 성공:", connections.length, "개"); - setExternalConnections(connections.map((conn: any) => ({ - id: String(conn.id), - name: conn.connection_name, - type: conn.db_type, - }))); + setExternalConnections( + connections.map((conn: any) => ({ + id: String(conn.id), + name: conn.connection_name, + type: conn.db_type, + })), + ); } catch (error) { console.error("❌ 외부 DB 커넥션 로드 실패:", error); setExternalConnections([]); @@ -73,27 +75,27 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult try { // dashboardApi 사용 (인증 토큰 자동 포함) const { dashboardApi } = await import("@/lib/api/dashboard"); - + if (dataSource.connectionType === "external" && dataSource.externalConnectionId) { // 외부 DB const { ExternalDbConnectionAPI } = await import("@/lib/api/externalDbConnection"); const result = await ExternalDbConnectionAPI.executeQuery( parseInt(dataSource.externalConnectionId), - dataSource.query + dataSource.query, ); - + if (result.success && result.data) { const rows = Array.isArray(result.data.rows) ? result.data.rows : []; const rowCount = rows.length; - + // 컬럼 목록 및 타입 추출 if (rows.length > 0) { const columns = Object.keys(rows[0]); setAvailableColumns(columns); - + // 컬럼 타입 분석 const types: Record = {}; - columns.forEach(col => { + columns.forEach((col) => { const value = rows[0][col]; if (value === null || value === undefined) { types[col] = "unknown"; @@ -113,17 +115,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }); setColumnTypes(types); setSampleData(rows.slice(0, 3)); - + console.log("📊 발견된 컬럼:", columns); console.log("📊 컬럼 타입:", types); } - + setTestResult({ success: true, message: "쿼리 실행 성공", rowCount, }); - + // 부모로 테스트 결과 전달 (차트 설정용) if (onTestResult && rows && rows.length > 0) { onTestResult(rows); @@ -134,15 +136,15 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult } else { // 현재 DB const result = await dashboardApi.executeQuery(dataSource.query); - + // 컬럼 목록 및 타입 추출 if (result.rows && result.rows.length > 0) { const columns = Object.keys(result.rows[0]); setAvailableColumns(columns); - + // 컬럼 타입 분석 const types: Record = {}; - columns.forEach(col => { + columns.forEach((col) => { const value = result.rows[0][col]; if (value === null || value === undefined) { types[col] = "unknown"; @@ -162,17 +164,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult }); setColumnTypes(types); setSampleData(result.rows.slice(0, 3)); - + console.log("📊 발견된 컬럼:", columns); console.log("📊 컬럼 타입:", types); } - + setTestResult({ success: true, message: "쿼리 실행 성공", rowCount: result.rowCount || 0, }); - + // 부모로 테스트 결과 전달 (차트 설정용) if (onTestResult && result.rows && result.rows.length > 0) { onTestResult(result.rows); @@ -194,25 +196,17 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult - onChange({ connectionType: value }) - } + onValueChange={(value: "current" | "external") => onChange({ connectionType: value })} >
- -
- -
@@ -222,12 +216,12 @@ export default function MultiDatabaseConfig({ dataSource, onChange, onTestResult {/* 외부 DB 선택 */} {dataSource.connectionType === "external" && (
-