날씨 진행 중 세이브
This commit is contained in:
parent
8ab36f32a0
commit
8ec54b4e7d
|
|
@ -968,9 +968,14 @@ function parseKMADataWeatherData(data: any, gridCoord: { name: string; nx: numbe
|
|||
clouds = 30;
|
||||
}
|
||||
|
||||
// 격자좌표 → 위도경도 변환
|
||||
const { lat, lng } = gridToLatLng(gridCoord.nx, gridCoord.ny);
|
||||
|
||||
return {
|
||||
city: gridCoord.name,
|
||||
country: 'KR',
|
||||
lat,
|
||||
lng,
|
||||
temperature: Math.round(temperature),
|
||||
feelsLike: Math.round(temperature - 2),
|
||||
humidity: Math.round(humidity),
|
||||
|
|
@ -1110,6 +1115,65 @@ function getGridCoordinates(city: string): { name: string; nx: number; ny: numbe
|
|||
return grids[city] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 격자좌표(nx, ny)를 위도경도로 변환
|
||||
* 기상청 격자 → 위경도 변환 공식 사용
|
||||
*/
|
||||
function gridToLatLng(nx: number, ny: number): { lat: number; lng: number } {
|
||||
const RE = 6371.00877; // 지구 반경(km)
|
||||
const GRID = 5.0; // 격자 간격(km)
|
||||
const SLAT1 = 30.0; // 표준위도1(degree)
|
||||
const SLAT2 = 60.0; // 표준위도2(degree)
|
||||
const OLON = 126.0; // 기준점 경도(degree)
|
||||
const OLAT = 38.0; // 기준점 위도(degree)
|
||||
const XO = 43; // 기준점 X좌표
|
||||
const YO = 136; // 기준점 Y좌표
|
||||
|
||||
const DEGRAD = Math.PI / 180.0;
|
||||
const re = RE / GRID;
|
||||
const slat1 = SLAT1 * DEGRAD;
|
||||
const slat2 = SLAT2 * DEGRAD;
|
||||
const olon = OLON * DEGRAD;
|
||||
const olat = OLAT * DEGRAD;
|
||||
|
||||
const sn = Math.tan(Math.PI * 0.25 + slat2 * 0.5) / Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
const sn_log = Math.log(Math.cos(slat1) / Math.cos(slat2)) / Math.log(sn);
|
||||
const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
||||
const sf_pow = Math.pow(sf, sn_log);
|
||||
const sf_result = (Math.cos(slat1) * sf_pow) / sn_log;
|
||||
const ro = Math.tan(Math.PI * 0.25 + olat * 0.5);
|
||||
const ro_pow = Math.pow(ro, sn_log);
|
||||
const ro_result = (re * sf_result) / ro_pow;
|
||||
|
||||
const xn = nx - XO;
|
||||
const yn = ro_result - (ny - YO);
|
||||
const ra = Math.sqrt(xn * xn + yn * yn);
|
||||
let alat: number;
|
||||
|
||||
if (sn_log > 0) {
|
||||
alat = 2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) - Math.PI * 0.5;
|
||||
} else {
|
||||
alat = -2.0 * Math.atan(Math.pow((re * sf_result) / ra, 1.0 / sn_log)) + Math.PI * 0.5;
|
||||
}
|
||||
|
||||
let theta: number;
|
||||
if (Math.abs(xn) <= 0.0) {
|
||||
theta = 0.0;
|
||||
} else {
|
||||
if (Math.abs(yn) <= 0.0) {
|
||||
theta = 0.0;
|
||||
} else {
|
||||
theta = Math.atan2(xn, yn);
|
||||
}
|
||||
}
|
||||
const alon = theta / sn_log + olon;
|
||||
|
||||
return {
|
||||
lat: parseFloat((alat / DEGRAD).toFixed(6)),
|
||||
lng: parseFloat((alon / DEGRAD).toFixed(6)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 공공데이터포털 초단기실황 응답 파싱
|
||||
* @param apiResponse - 공공데이터포털 API 응답 데이터
|
||||
|
|
@ -1171,8 +1235,13 @@ function parseDataPortalWeatherData(apiResponse: any, gridInfo: { name: string;
|
|||
weatherDescription = '추움';
|
||||
}
|
||||
|
||||
// 격자좌표 → 위도경도 변환
|
||||
const { lat, lng } = gridToLatLng(gridInfo.nx, gridInfo.ny);
|
||||
|
||||
return {
|
||||
city: gridInfo.name,
|
||||
lat,
|
||||
lng,
|
||||
temperature: Math.round(temperature * 10) / 10,
|
||||
humidity: Math.round(humidity),
|
||||
windSpeed: Math.round(windSpeed * 10) / 10,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,12 @@ export function DashboardSidebar() {
|
|||
subtype="weather"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="날씨 지도 위젯"
|
||||
type="widget"
|
||||
subtype="weather-map"
|
||||
onDragStart={handleDragStart}
|
||||
/>
|
||||
<DraggableItem
|
||||
title="계산기 위젯"
|
||||
type="widget"
|
||||
|
|
|
|||
|
|
@ -191,6 +191,7 @@ export function DashboardTopMenu({
|
|||
<SelectGroup>
|
||||
<SelectLabel>일반 위젯</SelectLabel>
|
||||
<SelectItem value="weather">날씨</SelectItem>
|
||||
<SelectItem value="weather-map">날씨 지도</SelectItem>
|
||||
<SelectItem value="exchange">환율</SelectItem>
|
||||
<SelectItem value="calculator">계산기</SelectItem>
|
||||
<SelectItem value="calendar">달력</SelectItem>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type ElementSubtype =
|
|||
| "combo" // 차트 타입
|
||||
| "exchange"
|
||||
| "weather"
|
||||
| "weather-map" // 날씨 지도 위젯
|
||||
| "clock"
|
||||
| "calendar"
|
||||
| "calculator"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const MapSummaryWidget = dynamic(() => import("./widgets/MapSummaryWidget"), { s
|
|||
const StatusSummaryWidget = dynamic(() => import("./widgets/StatusSummaryWidget"), { 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 });
|
||||
const VehicleStatusWidget = dynamic(() => import("./widgets/VehicleStatusWidget"), { ssr: false });
|
||||
const VehicleListWidget = dynamic(() => import("./widgets/VehicleListWidget"), { ssr: false });
|
||||
|
|
@ -64,6 +65,8 @@ function renderWidget(element: DashboardElement) {
|
|||
return <ExchangeWidget element={element} />;
|
||||
case "weather":
|
||||
return <WeatherWidget element={element} />;
|
||||
case "weather-map":
|
||||
return <WeatherMapWidget element={element} />;
|
||||
case "calculator":
|
||||
return <CalculatorWidget element={element} />;
|
||||
case "clock":
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getWeather, WeatherData } from "@/lib/api/openApi";
|
||||
import { Cloud, CloudRain, CloudSnow, Sun, Wind } from "lucide-react";
|
||||
import "leaflet/dist/leaflet.css";
|
||||
|
||||
// Leaflet 아이콘 경로 설정 (엑박 방지)
|
||||
|
|
@ -34,6 +36,7 @@ interface MarkerData {
|
|||
lng: number;
|
||||
name: string;
|
||||
info: any;
|
||||
weather?: WeatherData | null;
|
||||
}
|
||||
|
||||
// 테이블명 한글 번역
|
||||
|
|
@ -56,6 +59,66 @@ const translateTableName = (name: string): string => {
|
|||
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
|
||||
};
|
||||
|
||||
// 주요 도시 좌표 (날씨 API 지원 도시)
|
||||
const CITY_COORDINATES = [
|
||||
{ name: "서울", lat: 37.5665, lng: 126.978 },
|
||||
{ name: "부산", lat: 35.1796, lng: 129.0756 },
|
||||
{ name: "인천", lat: 37.4563, lng: 126.7052 },
|
||||
{ name: "대구", lat: 35.8714, lng: 128.6014 },
|
||||
{ name: "광주", lat: 35.1595, lng: 126.8526 },
|
||||
{ name: "대전", lat: 36.3504, lng: 127.3845 },
|
||||
{ name: "울산", lat: 35.5384, lng: 129.3114 },
|
||||
{ name: "세종", lat: 36.4800, lng: 127.2890 },
|
||||
{ name: "제주", lat: 33.4996, lng: 126.5312 },
|
||||
];
|
||||
|
||||
// 두 좌표 간 거리 계산 (Haversine formula)
|
||||
const getDistance = (lat1: number, lng1: number, lat2: number, lng2: number): number => {
|
||||
const R = 6371; // 지구 반경 (km)
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLng = ((lng2 - lng1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLng / 2) *
|
||||
Math.sin(dLng / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
};
|
||||
|
||||
// 가장 가까운 도시 찾기
|
||||
const findNearestCity = (lat: number, lng: number): string => {
|
||||
let nearestCity = "서울";
|
||||
let minDistance = Infinity;
|
||||
|
||||
for (const city of CITY_COORDINATES) {
|
||||
const distance = getDistance(lat, lng, city.lat, city.lng);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
nearestCity = city.name;
|
||||
}
|
||||
}
|
||||
|
||||
return nearestCity;
|
||||
};
|
||||
|
||||
// 날씨 아이콘 반환
|
||||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case "clear":
|
||||
return <Sun className="h-4 w-4 text-yellow-500" />;
|
||||
case "rain":
|
||||
return <CloudRain className="h-4 w-4 text-blue-500" />;
|
||||
case "snow":
|
||||
return <CloudSnow className="h-4 w-4 text-blue-300" />;
|
||||
case "clouds":
|
||||
return <Cloud className="h-4 w-4 text-gray-400" />;
|
||||
default:
|
||||
return <Wind className="h-4 w-4 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 범용 지도 위젯 (커스텀 지도 카드)
|
||||
* - 위도/경도가 있는 모든 데이터를 지도에 표시
|
||||
|
|
@ -67,6 +130,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tableName, setTableName] = useState<string | null>(null);
|
||||
const [weatherCache, setWeatherCache] = useState<Map<string, WeatherData>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (element?.dataSource?.query) {
|
||||
|
|
@ -83,6 +147,57 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
return () => clearInterval(interval);
|
||||
}, [element]);
|
||||
|
||||
// 마커들의 날씨 정보 로드
|
||||
const loadWeatherForMarkers = async (markerData: MarkerData[]) => {
|
||||
try {
|
||||
// 각 마커의 가장 가까운 도시 찾기
|
||||
const citySet = new Set<string>();
|
||||
markerData.forEach((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
citySet.add(nearestCity);
|
||||
});
|
||||
|
||||
// 캐시에 없는 도시만 날씨 조회
|
||||
const citiesToFetch = Array.from(citySet).filter((city) => !weatherCache.has(city));
|
||||
|
||||
if (citiesToFetch.length > 0) {
|
||||
// 날씨 정보 병렬 로드
|
||||
const weatherPromises = citiesToFetch.map((city) => getWeather(city));
|
||||
const weatherResults = await Promise.all(weatherPromises);
|
||||
|
||||
// 캐시 업데이트
|
||||
const newCache = new Map(weatherCache);
|
||||
citiesToFetch.forEach((city, index) => {
|
||||
newCache.set(city, weatherResults[index]);
|
||||
});
|
||||
setWeatherCache(newCache);
|
||||
|
||||
// 마커에 날씨 정보 추가
|
||||
const updatedMarkers = markerData.map((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
return {
|
||||
...marker,
|
||||
weather: newCache.get(nearestCity) || null,
|
||||
};
|
||||
});
|
||||
setMarkers(updatedMarkers);
|
||||
} else {
|
||||
// 캐시에서 날씨 정보 가져오기
|
||||
const updatedMarkers = markerData.map((marker) => {
|
||||
const nearestCity = findNearestCity(marker.lat, marker.lng);
|
||||
return {
|
||||
...marker,
|
||||
weather: weatherCache.get(nearestCity) || null,
|
||||
};
|
||||
});
|
||||
setMarkers(updatedMarkers);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("날씨 정보 로드 실패:", err);
|
||||
// 날씨 로드 실패해도 마커는 표시
|
||||
}
|
||||
};
|
||||
|
||||
const loadMapData = async () => {
|
||||
if (!element?.dataSource?.query) {
|
||||
return;
|
||||
|
|
@ -135,9 +250,13 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
lng: parseFloat(row[lngCol]),
|
||||
name: row.name || row.vehicle_number || row.warehouse_name || row.customer_name || "알 수 없음",
|
||||
info: row,
|
||||
weather: null,
|
||||
}));
|
||||
|
||||
setMarkers(markerData);
|
||||
|
||||
// 날씨 정보 로드 (비동기)
|
||||
loadWeatherForMarkers(markerData);
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
|
@ -205,15 +324,47 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
|||
{markers.map((marker, idx) => (
|
||||
<Marker key={idx} position={[marker.lat, marker.lng]}>
|
||||
<Popup>
|
||||
<div className="text-xs">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
<div className="min-w-[200px] text-xs">
|
||||
{/* 마커 정보 */}
|
||||
<div className="mb-2 border-b pb-2">
|
||||
<div className="mb-1 text-sm font-bold">{marker.name}</div>
|
||||
{Object.entries(marker.info)
|
||||
.filter(([key]) => !["latitude", "longitude", "lat", "lng"].includes(key.toLowerCase()))
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="text-xs">
|
||||
<strong>{key}:</strong> {String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
{marker.weather && (
|
||||
<div className="space-y-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
{getWeatherIcon(marker.weather.weatherMain)}
|
||||
<span className="text-xs font-semibold">현재 날씨</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="text-xs text-gray-600">{marker.weather.weatherDescription}</div>
|
||||
<div className="mt-2 space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">온도</span>
|
||||
<span className="font-medium">{marker.weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">체감온도</span>
|
||||
<span className="font-medium">{marker.weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">습도</span>
|
||||
<span className="font-medium">{marker.weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">풍속</span>
|
||||
<span className="font-medium">{marker.weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||
import { getMultipleWeather, WeatherData } from "@/lib/api/openApi";
|
||||
import { Cloud, CloudRain, CloudSnow, Sun, Wind } from "lucide-react";
|
||||
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",
|
||||
});
|
||||
}
|
||||
|
||||
// Leaflet 동적 import (SSR 방지)
|
||||
const MapContainer = dynamic(() => import("react-leaflet").then((mod) => mod.MapContainer), { ssr: false });
|
||||
const TileLayer = dynamic(() => import("react-leaflet").then((mod) => mod.TileLayer), { ssr: false });
|
||||
const Marker = dynamic(() => import("react-leaflet").then((mod) => mod.Marker), { ssr: false });
|
||||
const Popup = dynamic(() => import("react-leaflet").then((mod) => mod.Popup), { ssr: false });
|
||||
|
||||
// 브이월드 API 키
|
||||
const VWORLD_API_KEY = "97AD30D5-FDC4-3481-99C3-158E36422033";
|
||||
|
||||
interface WeatherMapWidgetProps {
|
||||
element: DashboardElement;
|
||||
cities?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 날씨 아이콘 반환
|
||||
*/
|
||||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case "clear":
|
||||
return <Sun className="h-6 w-6 text-yellow-500" />;
|
||||
case "rain":
|
||||
return <CloudRain className="h-6 w-6 text-blue-500" />;
|
||||
case "snow":
|
||||
return <CloudSnow className="h-6 w-6 text-blue-300" />;
|
||||
case "clouds":
|
||||
return <Cloud className="h-6 w-6 text-gray-400" />;
|
||||
default:
|
||||
return <Wind className="h-6 w-6 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 날씨 지도 위젯
|
||||
* - 여러 도시의 날씨를 지도에 표시
|
||||
* - 실시간 날씨 정보 (온도, 습도, 풍속 등)
|
||||
* - Leaflet + 브이월드 지도 사용
|
||||
*/
|
||||
export default function WeatherMapWidget({ element, cities }: WeatherMapWidgetProps) {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 기본 도시 목록 (사용자가 지정하지 않은 경우)
|
||||
const defaultCities = [
|
||||
"서울",
|
||||
"부산",
|
||||
"인천",
|
||||
"대구",
|
||||
"광주",
|
||||
"대전",
|
||||
"울산",
|
||||
"세종",
|
||||
"제주",
|
||||
];
|
||||
|
||||
const targetCities = cities || defaultCities;
|
||||
|
||||
useEffect(() => {
|
||||
loadWeatherData();
|
||||
|
||||
// 자동 새로고침 (5분마다)
|
||||
const interval = setInterval(() => {
|
||||
loadWeatherData();
|
||||
}, 300000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const loadWeatherData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await getMultipleWeather(targetCities);
|
||||
|
||||
// 위도경도가 있는 데이터만 필터링
|
||||
const validData = data.filter((item) => item.lat && item.lng);
|
||||
|
||||
setWeatherData(validData);
|
||||
} catch (err: any) {
|
||||
console.error("날씨 데이터 로드 실패:", err);
|
||||
setError(err.message || "날씨 데이터를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && weatherData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
||||
<p className="text-sm text-muted-foreground">날씨 정보 로딩 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-destructive">
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (weatherData.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">날씨 데이터가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 지도 중심 (대한민국 중심)
|
||||
const center: [number, number] = [36.5, 127.5];
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<MapContainer
|
||||
center={center}
|
||||
zoom={7}
|
||||
scrollWheelZoom={true}
|
||||
style={{ height: "100%", width: "100%" }}
|
||||
className="rounded-lg"
|
||||
>
|
||||
{/* 브이월드 Base Map */}
|
||||
<TileLayer
|
||||
attribution='© <a href="http://www.vworld.kr">VWorld</a>'
|
||||
url={`https://api.vworld.kr/req/wmts/1.0.0/${VWORLD_API_KEY}/Base/{z}/{y}/{x}.png`}
|
||||
/>
|
||||
|
||||
{/* 날씨 마커 */}
|
||||
{weatherData.map((weather, index) => {
|
||||
if (!weather.lat || !weather.lng) return null;
|
||||
|
||||
return (
|
||||
<Marker key={index} position={[weather.lat, weather.lng]}>
|
||||
<Popup>
|
||||
<div className="min-w-[200px] p-2">
|
||||
{/* 도시명 */}
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold">{weather.city}</h3>
|
||||
{getWeatherIcon(weather.weatherMain)}
|
||||
</div>
|
||||
|
||||
{/* 날씨 설명 */}
|
||||
<p className="mb-3 text-sm text-muted-foreground">{weather.weatherDescription}</p>
|
||||
|
||||
{/* 날씨 정보 */}
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">온도</span>
|
||||
<span className="font-medium">{weather.temperature}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">체감온도</span>
|
||||
<span className="font-medium">{weather.feelsLike}°C</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">습도</span>
|
||||
<span className="font-medium">{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">풍속</span>
|
||||
<span className="font-medium">{weather.windSpeed} m/s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 타임스탬프 */}
|
||||
<div className="mt-3 border-t pt-2 text-[10px] text-muted-foreground">
|
||||
{new Date(weather.timestamp).toLocaleString("ko-KR", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -15,6 +15,8 @@ import { apiClient } from './client';
|
|||
export interface WeatherData {
|
||||
city: string;
|
||||
country: string;
|
||||
lat?: number;
|
||||
lng?: number;
|
||||
temperature: number;
|
||||
feelsLike: number;
|
||||
humidity: number;
|
||||
|
|
@ -79,6 +81,15 @@ export async function getWeather(
|
|||
return response.data.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 도시의 날씨 정보 일괄 조회
|
||||
* @param cities 도시명 배열
|
||||
*/
|
||||
export async function getMultipleWeather(cities: string[]): Promise<WeatherData[]> {
|
||||
const promises = cities.map(city => getWeather(city));
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* 환율 정보 조회
|
||||
* @param base 기준 통화 (기본값: KRW)
|
||||
|
|
|
|||
Loading…
Reference in New Issue