feat(weather): 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
This commit is contained in:
부모
4d218f9fdd
커밋
174f4eb9f2
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -558,7 +558,6 @@
|
|||||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/body-parser": "*",
|
"@types/body-parser": "*",
|
||||||
"@types/express-serve-static-core": "^5.0.0",
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
@ -1992,7 +1991,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"pg-connection-string": "^2.11.0",
|
"pg-connection-string": "^2.11.0",
|
||||||
"pg-pool": "^3.12.0",
|
"pg-pool": "^3.12.0",
|
||||||
|
|||||||
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
48
frontend/src/tabs/weather/components/WeatherMapControls.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { useMap } from '@vis.gl/react-maplibre'
|
||||||
|
|
||||||
|
interface WeatherMapControlsProps {
|
||||||
|
center: [number, number]
|
||||||
|
zoom: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WeatherMapControls({ center, zoom }: WeatherMapControlsProps) {
|
||||||
|
const { current: map } = useMap()
|
||||||
|
|
||||||
|
const buttons = [
|
||||||
|
{
|
||||||
|
label: '+',
|
||||||
|
tooltip: '확대',
|
||||||
|
onClick: () => map?.zoomIn(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '−',
|
||||||
|
tooltip: '축소',
|
||||||
|
onClick: () => map?.zoomOut(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '🎯',
|
||||||
|
tooltip: '한국 해역 초기화',
|
||||||
|
onClick: () => map?.flyTo({ center, zoom, duration: 1000 }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute top-4 right-4 z-10">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{buttons.map(({ label, tooltip, onClick }) => (
|
||||||
|
<div key={tooltip} className="relative group">
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
<div className="absolute right-full top-1/2 -translate-y-1/2 mr-2 px-2 py-1 text-xs bg-bg-0 text-text-1 border border-border rounded whitespace-nowrap opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity z-20">
|
||||||
|
{tooltip}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useCallback } from 'react'
|
import { useState, useMemo, useCallback } from 'react'
|
||||||
import { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||||
import type { Layer } from '@deck.gl/core'
|
import type { Layer } from '@deck.gl/core'
|
||||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||||
@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
|||||||
import { WindParticleLayer } from './WindParticleLayer'
|
import { WindParticleLayer } from './WindParticleLayer'
|
||||||
import { useWeatherData } from '../hooks/useWeatherData'
|
import { useWeatherData } from '../hooks/useWeatherData'
|
||||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||||
|
import { WeatherMapControls } from './WeatherMapControls'
|
||||||
|
|
||||||
type TimeOffset = '0' | '3' | '6' | '9'
|
type TimeOffset = '0' | '3' | '6' | '9'
|
||||||
|
|
||||||
@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 줌 컨트롤
|
|
||||||
function WeatherMapControls() {
|
|
||||||
const { current: map } = useMap()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="absolute top-4 right-4 z-10">
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => map?.zoomIn()}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => map?.zoomOut()}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:bg-bg-hover hover:text-text-1 transition-all text-base"
|
|
||||||
>
|
|
||||||
−
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
map?.flyTo({ center: WEATHER_MAP_CENTER, zoom: WEATHER_MAP_ZOOM, duration: 1000 })
|
|
||||||
}
|
|
||||||
className="w-[38px] h-[38px] bg-[rgba(18,25,41,0.9)] backdrop-blur-xl border border-border rounded-sm text-text-2 flex items-center justify-center hover:text-text-1 transition-all text-sm"
|
|
||||||
>
|
|
||||||
🎯
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
* WeatherMapInner — Map 컴포넌트 내부 (useMap / useControl 사용 가능 영역)
|
||||||
*/
|
*/
|
||||||
@ -159,6 +128,9 @@ interface WeatherMapInnerProps {
|
|||||||
oceanForecastOpacity: number
|
oceanForecastOpacity: number
|
||||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||||||
onStationClick: (station: WeatherStation) => void
|
onStationClick: (station: WeatherStation) => void
|
||||||
|
mapCenter: [number, number]
|
||||||
|
mapZoom: number
|
||||||
|
clickedLocation: { lat: number; lon: number } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function WeatherMapInner({
|
function WeatherMapInner({
|
||||||
@ -168,6 +140,9 @@ function WeatherMapInner({
|
|||||||
oceanForecastOpacity,
|
oceanForecastOpacity,
|
||||||
selectedForecast,
|
selectedForecast,
|
||||||
onStationClick,
|
onStationClick,
|
||||||
|
mapCenter,
|
||||||
|
mapZoom,
|
||||||
|
clickedLocation,
|
||||||
}: WeatherMapInnerProps) {
|
}: WeatherMapInnerProps) {
|
||||||
// deck.gl layers 조합
|
// deck.gl layers 조합
|
||||||
const weatherDeckLayers = useWeatherDeckLayers(
|
const weatherDeckLayers = useWeatherDeckLayers(
|
||||||
@ -216,8 +191,31 @@ function WeatherMapInner({
|
|||||||
stations={weatherStations}
|
stations={weatherStations}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 클릭 위치 마커 */}
|
||||||
|
{clickedLocation && (
|
||||||
|
<Marker
|
||||||
|
longitude={clickedLocation.lon}
|
||||||
|
latitude={clickedLocation.lat}
|
||||||
|
anchor="bottom"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center pointer-events-none">
|
||||||
|
{/* 펄스 링 */}
|
||||||
|
<div className="relative flex items-center justify-center">
|
||||||
|
<div className="absolute w-8 h-8 rounded-full border-2 border-primary-cyan animate-ping opacity-60" />
|
||||||
|
<div className="w-4 h-4 rounded-full bg-primary-cyan border-2 border-white shadow-lg" />
|
||||||
|
</div>
|
||||||
|
{/* 핀 꼬리 */}
|
||||||
|
<div className="w-px h-3 bg-primary-cyan" />
|
||||||
|
{/* 좌표 라벨 */}
|
||||||
|
<div className="px-2 py-1 bg-bg-0/90 border border-primary-cyan rounded text-[10px] text-primary-cyan whitespace-nowrap backdrop-blur-sm">
|
||||||
|
{clickedLocation.lat.toFixed(3)}°N {clickedLocation.lon.toFixed(3)}°E
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Marker>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 줌 컨트롤 */}
|
{/* 줌 컨트롤 */}
|
||||||
<WeatherMapControls />
|
<WeatherMapControls center={mapCenter} zoom={mapZoom} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -225,6 +223,8 @@ function WeatherMapInner({
|
|||||||
export function WeatherView() {
|
export function WeatherView() {
|
||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
|
||||||
|
console.log(weatherStations,'날씨');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedForecast,
|
selectedForecast,
|
||||||
availableTimes,
|
availableTimes,
|
||||||
@ -343,12 +343,6 @@ export function WeatherView() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
|
|
||||||
<div className="px-6">
|
|
||||||
<button className="px-4 py-2 text-xs font-semibold rounded bg-status-red text-white hover:opacity-90 transition-opacity">
|
|
||||||
🚨 예보전송
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Map */}
|
{/* Map */}
|
||||||
@ -371,6 +365,9 @@ export function WeatherView() {
|
|||||||
oceanForecastOpacity={oceanForecastOpacity}
|
oceanForecastOpacity={oceanForecastOpacity}
|
||||||
selectedForecast={selectedForecast}
|
selectedForecast={selectedForecast}
|
||||||
onStationClick={handleStationClick}
|
onStationClick={handleStationClick}
|
||||||
|
mapCenter={WEATHER_MAP_CENTER}
|
||||||
|
mapZoom={WEATHER_MAP_ZOOM}
|
||||||
|
clickedLocation={selectedLocation}
|
||||||
/>
|
/>
|
||||||
</Map>
|
</Map>
|
||||||
|
|
||||||
|
|||||||
@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const obs = await getRecentObservation(obsCode)
|
const obs = await getRecentObservation(obsCode)
|
||||||
|
|
||||||
|
|
||||||
if (obs) {
|
if (obs) {
|
||||||
const r = (n: number) => Math.round(n * 10) / 10
|
const r = (n: number) => Math.round(n * 10) / 10
|
||||||
|
|||||||
@ -157,6 +157,8 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
|
|||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
||||||
|
console.log(response,'리스폰스');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user