From e6c11a0e04f458e48aa06176117a76e2f8785a36 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 28 Oct 2025 13:38:22 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=80=EB=8F=84=EC=97=90=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/VehicleMapConfigPanel.tsx | 408 ++++++++++++++---- frontend/components/admin/dashboard/types.ts | 14 + .../dashboard/widgets/MapSummaryWidget.tsx | 148 +++++-- 3 files changed, 443 insertions(+), 127 deletions(-) diff --git a/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx b/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx index 55df37d0..9b9e9567 100644 --- a/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx +++ b/frontend/components/admin/dashboard/VehicleMapConfigPanel.tsx @@ -1,7 +1,9 @@ -'use client'; +"use client"; -import React, { useState, useCallback } from 'react'; -import { ChartConfig, QueryResult } from './types'; +import React, { useState, useCallback } from "react"; +import { ChartConfig, QueryResult, MarkerColorRule } from "./types"; +import { Plus, Trash2 } from "lucide-react"; +import { v4 as uuidv4 } from "uuid"; interface VehicleMapConfigPanelProps { config?: ChartConfig; @@ -18,24 +20,80 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V const [currentConfig, setCurrentConfig] = useState(config || {}); // 설정 업데이트 - const updateConfig = useCallback((updates: Partial) => { - const newConfig = { ...currentConfig, ...updates }; - setCurrentConfig(newConfig); - onConfigChange(newConfig); - }, [currentConfig, onConfigChange]); + const updateConfig = useCallback( + (updates: Partial) => { + const newConfig = { ...currentConfig, ...updates }; + setCurrentConfig(newConfig); + onConfigChange(newConfig); + }, + [currentConfig, onConfigChange], + ); // 사용 가능한 컬럼 목록 const availableColumns = queryResult?.columns || []; const sampleData = queryResult?.rows?.[0] || {}; + // 마커 색상 모드 변경 + const handleMarkerColorModeChange = useCallback( + (mode: "single" | "conditional") => { + if (mode === "single") { + updateConfig({ + markerColorMode: "single", + markerColorColumn: undefined, + markerColorRules: undefined, + markerDefaultColor: "#3b82f6", // 파란색 + }); + } else { + updateConfig({ + markerColorMode: "conditional", + markerColorRules: [], + markerDefaultColor: "#6b7280", // 회색 + }); + } + }, + [updateConfig], + ); + + // 마커 색상 규칙 추가 + const addColorRule = useCallback(() => { + const newRule: MarkerColorRule = { + id: uuidv4(), + value: "", + color: "#3b82f6", + label: "", + }; + const currentRules = currentConfig.markerColorRules || []; + updateConfig({ markerColorRules: [...currentRules, newRule] }); + }, [currentConfig.markerColorRules, updateConfig]); + + // 마커 색상 규칙 삭제 + const deleteColorRule = useCallback( + (id: string) => { + const currentRules = currentConfig.markerColorRules || []; + updateConfig({ markerColorRules: currentRules.filter((rule) => rule.id !== id) }); + }, + [currentConfig.markerColorRules, updateConfig], + ); + + // 마커 색상 규칙 업데이트 + const updateColorRule = useCallback( + (id: string, updates: Partial) => { + const currentRules = currentConfig.markerColorRules || []; + updateConfig({ + markerColorRules: currentRules.map((rule) => (rule.id === id ? { ...rule, ...updates } : rule)), + }); + }, + [currentConfig.markerColorRules, updateConfig], + ); + return (

🗺️ 지도 설정

{/* 쿼리 결과가 없을 때 */} {!queryResult && ( -
-
+
+
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
@@ -49,10 +107,10 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V updateConfig({ title: e.target.value })} placeholder="차량 위치 지도" - className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs" + className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs" />
@@ -60,12 +118,12 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
updateConfig({ longitudeColumn: e.target.value })} - className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs" + className="w-full rounded-lg border border-gray-300 px-2 py-1.5 text-xs" > {availableColumns.map((col) => ( @@ -98,13 +156,11 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V {/* 라벨 컬럼 (선택사항) */}
- +
- {/* 상태 컬럼 (선택사항) */} -
- - + {/* 마커 색상 설정 */} +
+
🎨 마커 색상 설정
+ + {/* 색상 모드 선택 */} +
+ +
+ + +
+
+ + {/* 단일 색상 모드 */} + {(currentConfig.markerColorMode || "single") === "single" && ( +
+ +
+ updateConfig({ markerDefaultColor: e.target.value })} + className="h-8 w-12 cursor-pointer rounded border border-gray-300" + /> + updateConfig({ markerDefaultColor: e.target.value })} + placeholder="#3b82f6" + className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs" + /> +
+

모든 마커가 동일한 색상으로 표시됩니다

+
+ )} + + {/* 조건부 색상 모드 */} + {currentConfig.markerColorMode === "conditional" && ( +
+ {/* 색상 조건 컬럼 선택 */} +
+ + +

이 컬럼의 값에 따라 마커 색상이 결정됩니다

+
+ + {/* 기본 색상 */} +
+ +
+ updateConfig({ markerDefaultColor: e.target.value })} + className="h-8 w-12 cursor-pointer rounded border border-gray-300" + /> + updateConfig({ markerDefaultColor: e.target.value })} + placeholder="#6b7280" + className="flex-1 rounded-lg border border-gray-300 px-2 py-1.5 text-xs" + /> +
+

규칙에 매칭되지 않는 경우 사용할 색상

+
+ + {/* 색상 규칙 목록 */} +
+
+ + +
+ + {/* 규칙 리스트 */} + {(currentConfig.markerColorRules || []).length === 0 ? ( +
+

추가 버튼을 눌러 색상 규칙을 만드세요

+
+ ) : ( +
+ {(currentConfig.markerColorRules || []).map((rule) => ( +
+ {/* 규칙 헤더 */} +
+ 규칙 + +
+ + {/* 조건 값 */} +
+ + updateColorRule(rule.id, { value: e.target.value })} + placeholder="예: active, inactive" + className="w-full rounded border border-gray-300 px-2 py-1 text-xs" + /> +
+ + {/* 색상 */} +
+ +
+ updateColorRule(rule.id, { color: e.target.value })} + className="h-8 w-12 cursor-pointer rounded border border-gray-300" + /> + updateColorRule(rule.id, { color: e.target.value })} + placeholder="#3b82f6" + className="flex-1 rounded border border-gray-300 px-2 py-1 text-xs" + /> +
+
+ + {/* 라벨 (선택사항) */} +
+ + updateColorRule(rule.id, { label: e.target.value })} + placeholder="예: 활성, 비활성" + className="w-full rounded border border-gray-300 px-2 py-1 text-xs" + /> +
+
+ ))} +
+ )} +
+
+ )}
{/* 날씨 정보 표시 옵션 */} -
- -

- 마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다 -

-
+
+ +

마커 팝업에 해당 위치의 날씨 정보를 함께 표시합니다

+
-
- -

- 현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다 -

-
+
+ +

+ 현재 발효 중인 기상특보(주의보/경보)를 지도에 색상 영역으로 표시합니다 +

+
{/* 설정 미리보기 */} -
-
📋 설정 미리보기
-
-
위도: {currentConfig.latitudeColumn || '미설정'}
-
경도: {currentConfig.longitudeColumn || '미설정'}
-
라벨: {currentConfig.labelColumn || '없음'}
-
상태: {currentConfig.statusColumn || '없음'}
-
날씨 표시: {currentConfig.showWeather ? '활성화' : '비활성화'}
-
기상특보 표시: {currentConfig.showWeatherAlerts ? '활성화' : '비활성화'}
-
데이터 개수: {queryResult.rows.length}개
+
+
📋 설정 미리보기
+
+
+ 위도: {currentConfig.latitudeColumn || "미설정"} +
+
+ 경도: {currentConfig.longitudeColumn || "미설정"} +
+
+ 라벨: {currentConfig.labelColumn || "없음"} +
+
+ 색상 모드: {currentConfig.markerColorMode === "conditional" ? "조건부" : "단일"} +
+ {currentConfig.markerColorMode === "conditional" && ( + <> +
+ 색상 조건 컬럼: {currentConfig.markerColorColumn || "미설정"} +
+
+ 색상 규칙 개수: {(currentConfig.markerColorRules || []).length}개 +
+ + )} +
+ 날씨 표시: {currentConfig.showWeather ? "활성화" : "비활성화"} +
+
+ 기상특보 표시: {currentConfig.showWeatherAlerts ? "활성화" : "비활성화"} +
+
+ 데이터 개수: {queryResult.rows.length}개 +
{/* 필수 필드 확인 */} {(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && ( -
-
+
+
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
@@ -192,4 +439,3 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
); } - diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index 096273c9..61be6e5b 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -205,6 +205,20 @@ export interface ChartConfig { statusColumn?: string; // 상태 컬럼 showWeather?: boolean; // 날씨 정보 표시 여부 showWeatherAlerts?: boolean; // 기상특보 영역 표시 여부 + + // 마커 색상 설정 + markerColorMode?: "single" | "conditional"; // 마커 색상 모드 (단일/조건부) + markerColorColumn?: string; // 색상 조건 컬럼 + markerColorRules?: MarkerColorRule[]; // 색상 규칙 배열 + markerDefaultColor?: string; // 기본 마커 색상 +} + +// 마커 색상 규칙 +export interface MarkerColorRule { + id: string; // 고유 ID + value: string; // 컬럼 값 (예: "active", "inactive") + color: string; // 마커 색상 (hex) + label?: string; // 라벨 (선택사항) } export interface QueryResult { diff --git a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx index 58c49814..ae911260 100644 --- a/frontend/components/dashboard/widgets/MapSummaryWidget.tsx +++ b/frontend/components/dashboard/widgets/MapSummaryWidget.tsx @@ -42,6 +42,7 @@ interface MarkerData { name: string; info: any; weather?: WeatherData | null; + markerColor?: string; // 마커 색상 } // 테이블명 한글 번역 @@ -472,6 +473,33 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { const latCol = element.chartConfig?.latitudeColumn || "latitude"; const lngCol = element.chartConfig?.longitudeColumn || "longitude"; + // 마커 색상 결정 함수 + const getMarkerColor = (row: any): string => { + const colorMode = element.chartConfig?.markerColorMode || "single"; + + if (colorMode === "single") { + // 단일 색상 모드 + return element.chartConfig?.markerDefaultColor || "#3b82f6"; + } else { + // 조건부 색상 모드 + const colorColumn = element.chartConfig?.markerColorColumn; + const colorRules = element.chartConfig?.markerColorRules || []; + const defaultColor = element.chartConfig?.markerDefaultColor || "#6b7280"; + + if (!colorColumn || colorRules.length === 0) { + return defaultColor; + } + + // 컬럼 값 가져오기 + const columnValue = String(row[colorColumn] || ""); + + // 색상 규칙 매칭 + const matchedRule = colorRules.find((rule) => String(rule.value) === columnValue); + + return matchedRule ? matchedRule.color : defaultColor; + } + }; + // 유효한 좌표 필터링 및 마커 데이터 생성 const markerData = rows .filter((row: any) => row[latCol] && row[lngCol]) @@ -481,6 +509,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음", info: row, weather: null, + markerColor: getMarkerColor(row), // 마커 색상 추가 })); setMarkers(markerData); @@ -693,54 +722,81 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) { })} {/* 마커 표시 */} - {markers.map((marker, idx) => ( - - -
- {/* 마커 정보 */} -
-
{marker.name}
- {Object.entries(marker.info) - .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) - .map(([key, value]) => ( -
- {key}: {String(value)} -
- ))} -
+ {markers.map((marker, idx) => { + // Leaflet 커스텀 아이콘 생성 (클라이언트 사이드에서만) + let customIcon; + if (typeof window !== "undefined") { + const L = require("leaflet"); + customIcon = L.divIcon({ + className: "custom-marker", + html: ` +
+ `, + iconSize: [30, 30], + iconAnchor: [15, 15], + }); + } - {/* 날씨 정보 */} - {marker.weather && ( -
-
- {getWeatherIcon(marker.weather.weatherMain)} - 현재 날씨 -
-
{marker.weather.weatherDescription}
-
-
- 온도 - {marker.weather.temperature}°C -
-
- 체감온도 - {marker.weather.feelsLike}°C -
-
- 습도 - {marker.weather.humidity}% -
-
- 풍속 - {marker.weather.windSpeed} m/s -
-
+ return ( + + +
+ {/* 마커 정보 */} +
+
{marker.name}
+ {Object.entries(marker.info) + .filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase())) + .map(([key, value]) => ( +
+ {key}: {String(value)} +
+ ))}
- )} -
-
-
- ))} + + {/* 날씨 정보 */} + {marker.weather && ( +
+
+ {getWeatherIcon(marker.weather.weatherMain)} + 현재 날씨 +
+
{marker.weather.weatherDescription}
+
+
+ 온도 + {marker.weather.temperature}°C +
+
+ 체감온도 + {marker.weather.feelsLike}°C +
+
+ 습도 + {marker.weather.humidity}% +
+
+ 풍속 + {marker.weather.windSpeed} m/s +
+
+
+ )} +
+ + + ); + })} {/* 범례 (특보가 있을 때만 표시) */} -- 2.43.0