실시간 지도 마커 업데이트 구현 및 마커 종류에 트럭 추가
This commit is contained in:
parent
227ab1904c
commit
542f2ccc96
|
|
@ -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)
|
||||
}
|
||||
>
|
||||
적용
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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" })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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="© VWorld" maxZoom={19} />
|
||||
<TileLayer url={tileMapUrl} attribution="© 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({
|
||||
|
|
|
|||
Loading…
Reference in New Issue