feat(weather): 기상 정보 기상 레이어 업데이트 #78
2
backend/package-lock.json
generated
2
backend/package-lock.json
generated
@ -558,7 +558,6 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@ -1992,7 +1991,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.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 { Map, useControl, useMap } from '@vis.gl/react-maplibre'
|
||||
import { Map, Marker, useControl } from '@vis.gl/react-maplibre'
|
||||
import { MapboxOverlay } from '@deck.gl/mapbox'
|
||||
import type { Layer } from '@deck.gl/core'
|
||||
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl'
|
||||
@ -12,6 +12,7 @@ import { useWaterTemperatureLayers } from './WaterTemperatureLayer'
|
||||
import { WindParticleLayer } from './WindParticleLayer'
|
||||
import { useWeatherData } from '../hooks/useWeatherData'
|
||||
import { useOceanForecast } from '../hooks/useOceanForecast'
|
||||
import { WeatherMapControls } from './WeatherMapControls'
|
||||
|
||||
type TimeOffset = '0' | '3' | '6' | '9'
|
||||
|
||||
@ -117,38 +118,6 @@ function DeckGLOverlay({ layers }: { layers: Layer[] }) {
|
||||
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 사용 가능 영역)
|
||||
*/
|
||||
@ -159,6 +128,9 @@ interface WeatherMapInnerProps {
|
||||
oceanForecastOpacity: number
|
||||
selectedForecast: ReturnType<typeof useOceanForecast>['selectedForecast']
|
||||
onStationClick: (station: WeatherStation) => void
|
||||
mapCenter: [number, number]
|
||||
mapZoom: number
|
||||
clickedLocation: { lat: number; lon: number } | null
|
||||
}
|
||||
|
||||
function WeatherMapInner({
|
||||
@ -168,6 +140,9 @@ function WeatherMapInner({
|
||||
oceanForecastOpacity,
|
||||
selectedForecast,
|
||||
onStationClick,
|
||||
mapCenter,
|
||||
mapZoom,
|
||||
clickedLocation,
|
||||
}: WeatherMapInnerProps) {
|
||||
// deck.gl layers 조합
|
||||
const weatherDeckLayers = useWeatherDeckLayers(
|
||||
@ -216,8 +191,31 @@ function WeatherMapInner({
|
||||
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() {
|
||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||
|
||||
console.log(weatherStations,'날씨');
|
||||
|
||||
const {
|
||||
selectedForecast,
|
||||
availableTimes,
|
||||
@ -343,12 +343,6 @@ export function WeatherView() {
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
{/* Map */}
|
||||
@ -371,6 +365,9 @@ export function WeatherView() {
|
||||
oceanForecastOpacity={oceanForecastOpacity}
|
||||
selectedForecast={selectedForecast}
|
||||
onStationClick={handleStationClick}
|
||||
mapCenter={WEATHER_MAP_CENTER}
|
||||
mapZoom={WEATHER_MAP_ZOOM}
|
||||
clickedLocation={selectedLocation}
|
||||
/>
|
||||
</Map>
|
||||
|
||||
|
||||
@ -87,6 +87,7 @@ export function useWeatherData(stations: WeatherStation[]) {
|
||||
}
|
||||
|
||||
const obs = await getRecentObservation(obsCode)
|
||||
|
||||
|
||||
if (obs) {
|
||||
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}`)
|
||||
console.log(response,'리스폰스');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user