실시간 지도 마커 업데이트 구현 및 마커 종류에 트럭 추가

This commit is contained in:
dohyeons 2025-11-17 15:05:59 +09:00
parent 227ab1904c
commit 542f2ccc96
5 changed files with 160 additions and 73 deletions

View File

@ -455,7 +455,10 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
<Label htmlFor="refresh-interval" className="mb-2 block text-xs font-semibold">
</Label>
<Select value={refreshInterval.toString()} onValueChange={(value) => setRefreshInterval(parseInt(value))}>
<Select
value={refreshInterval.toString()}
onValueChange={(value) => setRefreshInterval(parseInt(value))}
>
<SelectTrigger id="refresh-interval" className="h-9 text-sm">
<SelectValue placeholder="간격 선택" />
</SelectTrigger>
@ -605,20 +608,20 @@ export function WidgetConfigSidebar({ element, isOpen, onClose, onApply }: Widge
<Button variant="outline" onClick={onClose} className="h-9 flex-1 text-sm">
</Button>
<Button
onClick={handleApply}
<Button
onClick={handleApply}
className="h-9 flex-1 text-sm"
disabled={
// 다중 데이터 소스 위젯: 데이터 소스가 있는데 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)
(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)
}
>

View File

@ -528,6 +528,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
<SelectContent>
<SelectItem value="circle" className="text-xs"></SelectItem>
<SelectItem value="arrow" className="text-xs"></SelectItem>
<SelectItem value="truck" className="text-xs"></SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">

View File

@ -163,7 +163,10 @@ export interface ChartDataSource {
markerColor?: string; // 마커 색상 (예: "#ff0000")
polygonColor?: string; // 폴리곤 색상 (예: "#0000ff")
polygonOpacity?: number; // 폴리곤 투명도 (0.0 ~ 1.0, 기본값: 0.5)
markerType?: string; // 마커 종류 (circle, arrow)
markerType?: string; // 마커 종류 (circle, arrow, truck)
minZoom?: number; // 최소 줌 레벨 (최대로 멀리 보기, 기본값: 2)
maxZoom?: number; // 최대 줌 레벨 (최대로 가까이 보기, 기본값: 18)
initialZoom?: number; // 초기 줌 레벨 (기본값: 13)
// 컬럼 매핑 (다중 데이터 소스 통합용)
columnMapping?: Record<string, string>; // { 원본컬럼: 표시이름 } (예: { "name": "product" })

View File

@ -20,32 +20,30 @@ interface MapConfigSectionProps {
* -
* -
*/
export function MapConfigSection({
queryResult,
export function MapConfigSection({
queryResult,
refreshInterval = 5,
markerType = "circle",
onRefreshIntervalChange,
onMarkerTypeChange
onMarkerTypeChange,
}: MapConfigSectionProps) {
// 쿼리 결과가 없으면 안내 메시지 표시
if (!queryResult || !queryResult.columns || queryResult.columns.length === 0) {
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label className="mb-2 block text-xs font-semibold"> </Label>
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">
.
</AlertDescription>
<AlertDescription className="text-xs"> .</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="rounded-lg bg-background p-3 shadow-sm">
<div className="bg-background rounded-lg p-3 shadow-sm">
<Label className="mb-3 block text-xs font-semibold"> </Label>
<div className="space-y-3">
{/* 자동 새로고침 간격 */}
<div className="space-y-1.5">
@ -60,16 +58,24 @@ export function MapConfigSection({
<SelectValue placeholder="새로고침 간격 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0" className="text-xs"></SelectItem>
<SelectItem value="5" className="text-xs">5</SelectItem>
<SelectItem value="10" className="text-xs">10</SelectItem>
<SelectItem value="30" className="text-xs">30</SelectItem>
<SelectItem value="60" className="text-xs">1</SelectItem>
<SelectItem value="0" className="text-xs">
</SelectItem>
<SelectItem value="5" className="text-xs">
5
</SelectItem>
<SelectItem value="10" className="text-xs">
10
</SelectItem>
<SelectItem value="30" className="text-xs">
30
</SelectItem>
<SelectItem value="60" className="text-xs">
1
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
{/* 마커 종류 선택 */}
@ -77,24 +83,25 @@ export function MapConfigSection({
<Label htmlFor="marker-type" className="text-xs">
</Label>
<Select
value={markerType}
onValueChange={(value) => onMarkerTypeChange?.(value)}
>
<Select value={markerType} onValueChange={(value) => onMarkerTypeChange?.(value)}>
<SelectTrigger id="marker-type" className="h-9 text-xs">
<SelectValue placeholder="마커 종류 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="circle" className="text-xs"></SelectItem>
<SelectItem value="arrow" className="text-xs"></SelectItem>
<SelectItem value="circle" className="text-xs">
</SelectItem>
<SelectItem value="arrow" className="text-xs">
</SelectItem>
<SelectItem value="truck" className="text-xs">
</SelectItem>
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-require-imports */
"use client";
import React, { useEffect, useState, useCallback, useMemo } from "react";
import React, { useEffect, useState, useCallback, useMemo, useRef } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types";
import { Button } from "@/components/ui/button";
@ -80,7 +80,7 @@ interface PolygonData {
export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const [markers, setMarkers] = useState<MarkerData[]>([]);
const [prevMarkers, setPrevMarkers] = useState<MarkerData[]>([]); // 이전 마커 위치 저장
const prevMarkersRef = useRef<MarkerData[]>([]); // 이전 마커 위치 저장 (useRef 사용)
const [polygons, setPolygons] = useState<PolygonData[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -158,11 +158,24 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 이전 마커 위치와 비교하여 진행 방향 계산
const markersWithHeading = allMarkers.map((marker) => {
const prevMarker = prevMarkers.find((pm) => pm.id === marker.id);
const prevMarker = prevMarkersRef.current.find((pm) => pm.id === marker.id);
console.log("🔍 마커 비교:", {
id: marker.id,
: `[${marker.lat}, ${marker.lng}]`,
이전위치: prevMarker ? `[${prevMarker.lat}, ${prevMarker.lng}]` : "없음",
같은지: prevMarker ? prevMarker.lat === marker.lat && prevMarker.lng === marker.lng : "N/A",
});
if (prevMarker && (prevMarker.lat !== marker.lat || prevMarker.lng !== marker.lng)) {
// 이동했으면 방향 계산
const heading = calculateHeading(prevMarker.lat, prevMarker.lng, marker.lat, marker.lng);
console.log("🧭 방향 계산:", {
id: marker.id,
from: `[${prevMarker.lat.toFixed(4)}, ${prevMarker.lng.toFixed(4)}]`,
to: `[${marker.lat.toFixed(4)}, ${marker.lng.toFixed(4)}]`,
heading: `${heading.toFixed(1)}°`,
});
return {
...marker,
heading,
@ -172,13 +185,14 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
}
// 이동하지 않았거나 이전 데이터가 없으면 기존 heading 유지 (또는 0)
console.log("⏸️ 이동 없음:", { id: marker.id, heading: marker.heading || prevMarker?.heading || 0 });
return {
...marker,
heading: marker.heading || prevMarker?.heading || 0,
};
});
setPrevMarkers(markersWithHeading); // 다음 비교를 위해 현재 위치 저장
prevMarkersRef.current = markersWithHeading; // 다음 비교를 위해 현재 위치 저장 (useRef 사용)
setMarkers(markersWithHeading);
setPolygons(allPolygons);
setLastRefreshTime(new Date());
@ -188,7 +202,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
} finally {
setLoading(false);
}
}, [dataSources, prevMarkers, calculateHeading]);
}, [dataSources, calculateHeading]); // prevMarkersRef는 의존성에 포함하지 않음 (useRef이므로)
// 수동 새로고침 핸들러
const handleManualRefresh = useCallback(() => {
@ -448,7 +462,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const regionName = row.name || row.area || row.region || row.location || row.subRegion;
if (regionName && MARITIME_ZONES[regionName] && mapDisplayType !== "marker") {
polygons.push({
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정)
name: regionName,
coordinates: MARITIME_ZONES[regionName] as [number, number][],
status: row.status || row.level,
@ -494,7 +508,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (regionName) {
// console.log(` 🔷 강제 폴리곤 모드: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
polygons.push({
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정)
name: regionName,
coordinates: [], // GeoJSON에서 좌표를 가져올 것
status: row.status || row.level,
@ -511,7 +525,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// 위도/경도가 있고 polygon 모드가 아니면 마커로 처리
if (lat !== undefined && lng !== undefined && (mapDisplayType as string) !== "polygon") {
markers.push({
id: `${sourceName}-marker-${index}-${row.code || row.id || Date.now()}`, // 고유 ID 생성
id: `${sourceName}-marker-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정)
lat: Number(lat),
lng: Number(lng),
latitude: Number(lat),
@ -528,7 +542,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
if (regionName) {
// console.log(` 📍 위도/경도 없지만 지역명 있음: ${regionName} → 폴리곤으로 추가 (GeoJSON 매칭)`);
polygons.push({
id: `${sourceName}-polygon-${index}-${row.code || row.id || Date.now()}`,
id: `${sourceName}-polygon-${index}`, // 고유 ID 생성 (위치 추적을 위해 고정)
name: regionName,
coordinates: [], // GeoJSON에서 좌표를 가져올 것
status: row.status || row.level,
@ -995,9 +1009,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSources, element?.chartConfig?.refreshInterval]);
// 타일맵 URL (chartConfig에서 가져오기)
const tileMapUrl =
element?.chartConfig?.tileMapUrl || `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
// 타일맵 URL (VWorld 한국 지도)
const tileMapUrl = `https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`;
// 지도 중심점 계산
const center: [number, number] =
@ -1006,7 +1019,7 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
markers.reduce((sum, m) => sum + m.lat, 0) / markers.length,
markers.reduce((sum, m) => sum + m.lng, 0) / markers.length,
]
: [37.5665, 126.978]; // 기본: 서울
: [20, 0]; // 🌍 세계 지도 중심 (ISS 테스트용)
return (
<div className="bg-background flex h-full w-full flex-col">
@ -1046,11 +1059,17 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
<MapContainer
key={`map-widget-${element.id}`}
center={center}
zoom={13}
zoom={element.chartConfig?.initialZoom || 8}
minZoom={8}
maxZoom={18}
scrollWheelZoom={true}
doubleClickZoom={true}
touchZoom={true}
zoomControl={true}
style={{ width: "100%", height: "100%" }}
className="z-0"
>
<TileLayer url={tileMapUrl} attribution="&copy; VWorld" maxZoom={19} />
<TileLayer url={tileMapUrl} attribution="&copy; VWorld" minZoom={8} maxZoom={18} />
{/* 폴리곤 렌더링 */}
{/* GeoJSON 렌더링 (육지 지역 경계선) */}
@ -1296,6 +1315,8 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
const L = require("leaflet");
const heading = marker.heading || 0;
console.log("🎨 마커 렌더링:", { id: marker.id, heading: `${heading.toFixed(1)}°`, type: markerType });
if (markerType === "arrow") {
// 화살표 마커
markerIcon = L.divIcon({
@ -1311,28 +1332,12 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="40" height="40" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<!-- -->
<!-- ( ) -->
<polygon
points="20,5 25,15 23,15 23,25 17,25 17,15 15,15"
points="20,5 28,30 12,30"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
/>
<!-- -->
<polygon
points="20,2 28,12 12,12"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
/>
<!-- -->
<circle
cx="20"
cy="30"
r="3"
fill="white"
stroke="${marker.color || "#3b82f6"}"
stroke-width="1.5"
stroke-width="2"
/>
</svg>
</div>
@ -1340,6 +1345,74 @@ export default function MapTestWidgetV2({ element }: MapTestWidgetV2Props) {
iconSize: [40, 40],
iconAnchor: [20, 20],
});
} else if (markerType === "truck") {
// 트럭 마커
markerIcon = L.divIcon({
className: "custom-truck-marker",
html: `
<div style="
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
transform: translate(-50%, -50%) rotate(${heading}deg);
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
">
<svg width="48" height="48" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
<g transform="rotate(-90 20 20)">
<!-- -->
<rect
x="10"
y="12"
width="12"
height="10"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
rx="1"
/>
<!-- -->
<path
d="M 22 14 L 22 22 L 28 22 L 28 18 L 26 14 Z"
fill="${marker.color || "#3b82f6"}"
stroke="white"
stroke-width="1.5"
/>
<!-- -->
<rect
x="23"
y="15"
width="3"
height="4"
fill="white"
opacity="0.8"
/>
<!-- -->
<circle
cx="25"
cy="23"
r="2.5"
fill="#333"
stroke="white"
stroke-width="1"
/>
<!-- -->
<circle
cx="14"
cy="23"
r="2.5"
fill="#333"
stroke="white"
stroke-width="1"
/>
</g>
</svg>
</div>
`,
iconSize: [48, 48],
iconAnchor: [24, 24],
});
} else {
// 동그라미 마커 (기본)
markerIcon = L.divIcon({