chore: develop 브랜치 최신화 머지 (conflict 해결)
- aerialService.ts: IMAGE_API_URL + stitchImages() 유지 (현재 브랜치 기준) - aerialRouter.ts: /stitch 라우트 유지 (현재 브랜치 기준) - RELEASE-NOTES.md: [Unreleased] + [2026-03-11] 병합
This commit is contained in:
커밋
dfe0ac8efe
@ -84,4 +84,4 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,4 +4,4 @@
|
|||||||
"project_type": "react-ts",
|
"project_type": "react-ts",
|
||||||
"gitea_url": "https://gitea.gc-si.dev",
|
"gitea_url": "https://gitea.gc-si.dev",
|
||||||
"custom_pre_commit": true
|
"custom_pre_commit": true
|
||||||
}
|
}
|
||||||
@ -4,7 +4,7 @@
|
|||||||
연동할 수 있도록 정리한 문서이다.
|
연동할 수 있도록 정리한 문서이다.
|
||||||
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
|
공통 기능을 추가/변경할 때 반드시 이 문서를 최신화할 것.
|
||||||
|
|
||||||
> **최종 갱신**: 2026-03-01 (CSS 리팩토링 + MapLibre GL + deck.gl 전환 반영)
|
> **최종 갱신**: 2026-03-11 (KHOA API 교체 + Vite CORS 프록시 추가)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -1312,6 +1312,25 @@ app.use(helmet({
|
|||||||
}));
|
}));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Vite 개발 서버 프록시
|
||||||
|
|
||||||
|
외부 API 이미지의 CORS 문제를 해결하기 위해 `vite.config.ts`에 프록시를 설정한다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3001',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/daily_ocean': {
|
||||||
|
target: 'https://www.khoa.go.kr',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
적용되는 보안 헤더:
|
적용되는 보안 헤더:
|
||||||
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
|
- `X-Content-Type-Options: nosniff` (MIME 스니핑 방지)
|
||||||
- `X-Frame-Options: DENY` (클릭재킹 방지)
|
- `X-Frame-Options: DENY` (클릭재킹 방지)
|
||||||
|
|||||||
@ -657,6 +657,7 @@ Settings -> Actions -> Secrets -> Add Secret
|
|||||||
|
|
||||||
- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다.
|
- API 호출이 CORS 에러를 발생시키면 백엔드 `FRONTEND_URL` 환경변수를 확인한다.
|
||||||
- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다.
|
- 개발 환경에서는 `localhost:5173`, `localhost:5174`, `localhost:3000`이 자동 허용된다.
|
||||||
|
- KHOA 해양 이미지(`/daily_ocean`)는 Vite 프록시 경유: `vite.config.ts` → `proxy` 설정 확인
|
||||||
|
|
||||||
**타입 에러:**
|
**타입 에러:**
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,29 @@
|
|||||||
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
- 팀 워크플로우 v1.6.0 동기화 (해시 기반 자동 최신화, push/mr/release 워크플로우 체크, 팀 관리 파일 gitignore 처리)
|
||||||
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
- 팀 워크플로우 v1.5.0 동기화 (스킬 7종 업데이트, version 스킬 신규, release-notes-guide 추가)
|
||||||
|
|
||||||
|
## [2026-03-11]
|
||||||
|
|
||||||
|
### 추가
|
||||||
|
- KHOA API 엔드포인트 교체 및 해양예측 오버레이 Canvas 렌더링 전환
|
||||||
|
- 기상 맵 컨트롤 컴포넌트 추가 및 KHOA API 연동 개선
|
||||||
|
- 기상 정보 기상 레이어 업데이트
|
||||||
|
- CCTV 안전관리 감지 기능 추가 (선박 출입, 침입 감지)
|
||||||
|
- 관리자 화면 고도화 — 사용자/권한/게시판/선박신호 패널
|
||||||
|
- CCTV 오일 감지 GPU 추론 연동 및 HNS 초기 핀 제거
|
||||||
|
- 유류오염보장계약 시드 데이터 추가 (1391건)
|
||||||
|
|
||||||
|
### 수정
|
||||||
|
- /orgs 라우트를 /:id 앞에 등록하여 라우트 매칭 수정
|
||||||
|
|
||||||
|
### 문서
|
||||||
|
- 프로젝트 문서 최신화 (KHOA API, Vite 프록시)
|
||||||
|
|
||||||
|
### 기타
|
||||||
|
- CLAUDE_BOT_TOKEN 갱신
|
||||||
|
- 팀 워크플로우 v1.6.1 동기화
|
||||||
|
- 팀 워크플로우 v1.6.0 동기화
|
||||||
|
- 팀 워크플로우 v1.5.0 동기화
|
||||||
|
|
||||||
## [2026-03-01]
|
## [2026-03-01]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { Source, Layer } from '@vis.gl/react-maplibre'
|
import { useMap } from '@vis.gl/react-maplibre'
|
||||||
import type { OceanForecastData } from '../services/khoaApi'
|
import type { OceanForecastData } from '../services/khoaApi'
|
||||||
|
|
||||||
interface OceanForecastOverlayProps {
|
interface OceanForecastOverlayProps {
|
||||||
@ -8,62 +8,118 @@ interface OceanForecastOverlayProps {
|
|||||||
visible?: boolean
|
visible?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// 한국 해역 범위 (MapLibre image source용 좌표 배열)
|
// 한국 해역 범위 [lon, lat]
|
||||||
// [left, bottom, right, top] → MapLibre coordinates 순서: [sw, nw, ne, se]
|
const BOUNDS = {
|
||||||
// [lon, lat] 순서
|
nw: [124.5, 38.5] as [number, number],
|
||||||
const KOREA_IMAGE_COORDINATES: [[number, number], [number, number], [number, number], [number, number]] = [
|
ne: [132.0, 38.5] as [number, number],
|
||||||
[124.5, 33.0], // 남서 (제주 남쪽)
|
se: [132.0, 33.0] as [number, number],
|
||||||
[124.5, 38.5], // 북서
|
sw: [124.5, 33.0] as [number, number],
|
||||||
[132.0, 38.5], // 북동 (동해 북쪽)
|
}
|
||||||
[132.0, 33.0], // 남동
|
|
||||||
]
|
// www.khoa.go.kr 이미지는 CORS 미지원 → Vite 프록시 경유
|
||||||
|
function toProxyUrl(url: string): string {
|
||||||
|
return url.replace('https://www.khoa.go.kr', '')
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OceanForecastOverlay
|
* OceanForecastOverlay
|
||||||
*
|
*
|
||||||
* 기존: react-leaflet ImageOverlay + LatLngBounds
|
* MapLibre raster layer는 deck.gl 캔버스보다 항상 아래 렌더링되므로,
|
||||||
* 전환: @vis.gl/react-maplibre Source(type=image) + Layer(type=raster)
|
* WindParticleLayer와 동일하게 canvas를 직접 map 컨테이너에 삽입하는 방식 사용.
|
||||||
*
|
* z-index 500으로 WindParticleLayer(450) 위에 렌더링.
|
||||||
* MapLibre image source는 Map 컴포넌트 자식으로 직접 렌더링 가능
|
|
||||||
*/
|
*/
|
||||||
export function OceanForecastOverlay({
|
export function OceanForecastOverlay({
|
||||||
forecast,
|
forecast,
|
||||||
opacity = 0.6,
|
opacity = 0.6,
|
||||||
visible = true,
|
visible = true,
|
||||||
}: OceanForecastOverlayProps) {
|
}: OceanForecastOverlayProps) {
|
||||||
const [loadedUrl, setLoadedUrl] = useState<string | null>(null)
|
const { current: mapRef } = useMap()
|
||||||
|
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
||||||
|
const imgRef = useRef<HTMLImageElement | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!forecast?.filePath) return
|
const map = mapRef?.getMap()
|
||||||
let cancelled = false
|
if (!map) return
|
||||||
const img = new Image()
|
|
||||||
img.onload = () => { if (!cancelled) setLoadedUrl(forecast.filePath) }
|
|
||||||
img.onerror = () => { if (!cancelled) setLoadedUrl(null) }
|
|
||||||
img.src = forecast.filePath
|
|
||||||
return () => { cancelled = true }
|
|
||||||
}, [forecast?.filePath])
|
|
||||||
|
|
||||||
const imageLoaded = !!loadedUrl && loadedUrl === forecast?.filePath
|
const container = map.getContainer()
|
||||||
|
|
||||||
if (!visible || !forecast || !imageLoaded) {
|
// canvas 생성 (최초 1회)
|
||||||
return null
|
if (!canvasRef.current) {
|
||||||
}
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.style.position = 'absolute'
|
||||||
|
canvas.style.top = '0'
|
||||||
|
canvas.style.left = '0'
|
||||||
|
canvas.style.pointerEvents = 'none'
|
||||||
|
canvas.style.zIndex = '500' // WindParticleLayer(450) 위
|
||||||
|
container.appendChild(canvas)
|
||||||
|
canvasRef.current = canvas
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const canvas = canvasRef.current
|
||||||
<Source
|
|
||||||
id="ocean-forecast-image"
|
if (!visible || !forecast?.imgFilePath) {
|
||||||
type="image"
|
canvas.style.display = 'none'
|
||||||
url={forecast.filePath}
|
return
|
||||||
coordinates={KOREA_IMAGE_COORDINATES}
|
}
|
||||||
>
|
|
||||||
<Layer
|
canvas.style.display = 'block'
|
||||||
id="ocean-forecast-raster"
|
const proxyUrl = toProxyUrl(forecast.imgFilePath)
|
||||||
type="raster"
|
|
||||||
paint={{
|
function draw() {
|
||||||
'raster-opacity': opacity,
|
const img = imgRef.current
|
||||||
'raster-resampling': 'linear',
|
if (!canvas || !img || !img.complete || img.naturalWidth === 0) return
|
||||||
}}
|
|
||||||
/>
|
const { clientWidth: w, clientHeight: h } = container
|
||||||
</Source>
|
canvas.width = w
|
||||||
)
|
canvas.height = h
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
// 4개 꼭짓점을 픽셀 좌표로 변환
|
||||||
|
const nw = map!.project(BOUNDS.nw)
|
||||||
|
const ne = map!.project(BOUNDS.ne)
|
||||||
|
const sw = map!.project(BOUNDS.sw)
|
||||||
|
|
||||||
|
const x = Math.min(nw.x, sw.x)
|
||||||
|
const y = nw.y
|
||||||
|
const w2 = ne.x - nw.x
|
||||||
|
const h2 = sw.y - nw.y
|
||||||
|
|
||||||
|
ctx.globalAlpha = opacity
|
||||||
|
ctx.drawImage(img, x, y, w2, h2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지가 바뀌었으면 새로 로드
|
||||||
|
if (!imgRef.current || imgRef.current.dataset.src !== proxyUrl) {
|
||||||
|
const img = new Image()
|
||||||
|
img.dataset.src = proxyUrl
|
||||||
|
img.onload = draw
|
||||||
|
img.src = proxyUrl
|
||||||
|
imgRef.current = img
|
||||||
|
} else {
|
||||||
|
draw()
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on('move', draw)
|
||||||
|
map.on('zoom', draw)
|
||||||
|
map.on('resize', draw)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('move', draw)
|
||||||
|
map.off('zoom', draw)
|
||||||
|
map.off('resize', draw)
|
||||||
|
}
|
||||||
|
}, [mapRef, visible, forecast?.imgFilePath, opacity])
|
||||||
|
|
||||||
|
// 언마운트 시 canvas 제거
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
canvasRef.current?.remove()
|
||||||
|
canvasRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -77,8 +77,6 @@ export function WeatherMapOverlay({
|
|||||||
stations.map((station) => {
|
stations.map((station) => {
|
||||||
const isSelected = selectedStationId === station.id
|
const isSelected = selectedStationId === station.id
|
||||||
const color = getWindHexColor(station.wind.speed, isSelected)
|
const color = getWindHexColor(station.wind.speed, isSelected)
|
||||||
const size = Math.min(40 + station.wind.speed * 2, 80)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Marker
|
<Marker
|
||||||
key={`wind-${station.id}`}
|
key={`wind-${station.id}`}
|
||||||
@ -87,35 +85,34 @@ export function WeatherMapOverlay({
|
|||||||
anchor="center"
|
anchor="center"
|
||||||
onClick={() => onStationClick(station)}
|
onClick={() => onStationClick(station)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex items-center gap-1 cursor-pointer">
|
||||||
style={{
|
<div style={{ transform: `rotate(${station.wind.direction}deg)` }}>
|
||||||
width: size,
|
<svg
|
||||||
height: size,
|
width={24}
|
||||||
transform: `rotate(${station.wind.direction}deg)`,
|
height={24}
|
||||||
}}
|
viewBox="0 0 24 24"
|
||||||
className="flex items-center justify-center cursor-pointer"
|
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
||||||
>
|
>
|
||||||
<svg
|
{/* 위쪽이 바람 방향을 나타내는 삼각형 */}
|
||||||
width={size}
|
<polygon
|
||||||
height={size}
|
points="12,2 4,22 12,16 20,22"
|
||||||
viewBox="0 0 24 24"
|
fill={color}
|
||||||
style={{ filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.3))' }}
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{ color, textShadow: '0 1px 3px rgba(0,0,0,0.8)' }}
|
||||||
|
className="text-xs font-bold leading-none"
|
||||||
>
|
>
|
||||||
<path
|
{station.wind.speed.toFixed(1)}
|
||||||
d="M12 2L12 20M12 2L8 6M12 2L16 6M12 20L8 16M12 20L16 16"
|
</span>
|
||||||
stroke={color}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
fill="none"
|
|
||||||
strokeLinecap="round"
|
|
||||||
/>
|
|
||||||
<circle cx="12" cy="12" r="3" fill={color} opacity="0.8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* 기상 데이터 라벨 — MapLibre Marker */}
|
{/* 기상 데이터 라벨 — 임시 비활성화
|
||||||
{enabledLayers.has('labels') &&
|
{enabledLayers.has('labels') &&
|
||||||
stations.map((station) => {
|
stations.map((station) => {
|
||||||
const isSelected = selectedStationId === station.id
|
const isSelected = selectedStationId === station.id
|
||||||
@ -139,7 +136,6 @@ export function WeatherMapOverlay({
|
|||||||
}}
|
}}
|
||||||
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
|
className="rounded-[10px] p-2 flex flex-col gap-1 min-w-[70px] cursor-pointer"
|
||||||
>
|
>
|
||||||
{/* 관측소명 */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
color: textColor,
|
color: textColor,
|
||||||
@ -150,8 +146,6 @@ export function WeatherMapOverlay({
|
|||||||
>
|
>
|
||||||
{station.name}
|
{station.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 수온 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -160,22 +154,12 @@ export function WeatherMapOverlay({
|
|||||||
🌡️
|
🌡️
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.temperature.current.toFixed(1)}
|
{station.temperature.current.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>°C</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
°C
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 파고 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -184,22 +168,12 @@ export function WeatherMapOverlay({
|
|||||||
🌊
|
🌊
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.wave.height.toFixed(1)}
|
{station.wave.height.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
m
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 풍속 */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<div
|
<div
|
||||||
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
className="w-6 h-6 rounded-full flex items-center justify-center text-[11px] text-white font-bold"
|
||||||
@ -208,24 +182,17 @@ export function WeatherMapOverlay({
|
|||||||
💨
|
💨
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-0.5">
|
<div className="flex items-baseline gap-0.5">
|
||||||
<span
|
<span className="text-sm font-bold text-white" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>
|
||||||
className="text-sm font-bold text-white"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
{station.wind.speed.toFixed(1)}
|
{station.wind.speed.toFixed(1)}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-[10px] text-white opacity-90" style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}>m/s</span>
|
||||||
className="text-[10px] text-white opacity-90"
|
|
||||||
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.5)' }}
|
|
||||||
>
|
|
||||||
m/s
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Marker>
|
</Marker>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
*/}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,7 @@ function WeatherMapInner({
|
|||||||
export function WeatherView() {
|
export function WeatherView() {
|
||||||
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
const { weatherStations, loading, error, lastUpdate } = useWeatherData(BASE_STATIONS)
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedForecast,
|
selectedForecast,
|
||||||
availableTimes,
|
availableTimes,
|
||||||
@ -238,7 +237,7 @@ export function WeatherView() {
|
|||||||
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
const [selectedLocation, setSelectedLocation] = useState<{ lat: number; lon: number } | null>(
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind', 'labels']))
|
const [enabledLayers, setEnabledLayers] = useState<Set<string>>(new Set(['wind']))
|
||||||
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
const [oceanForecastOpacity, setOceanForecastOpacity] = useState(0.6)
|
||||||
|
|
||||||
// 첫 관측소 자동 선택 (파생 값)
|
// 첫 관측소 자동 선택 (파생 값)
|
||||||
@ -343,12 +342,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 +364,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>
|
||||||
|
|
||||||
@ -396,6 +392,7 @@ export function WeatherView() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
<span className="text-xs text-text-2">🌬️ 바람 벡터</span>
|
||||||
</label>
|
</label>
|
||||||
|
{/* 기상 데이터 레이어 — 임시 비활성화
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -405,6 +402,7 @@ export function WeatherView() {
|
|||||||
/>
|
/>
|
||||||
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
<span className="text-xs text-text-2">📊 기상 데이터</span>
|
||||||
</label>
|
</label>
|
||||||
|
*/}
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@ -482,8 +480,8 @@ export function WeatherView() {
|
|||||||
key={`${time.day}-${time.hour}`}
|
key={`${time.day}-${time.hour}`}
|
||||||
onClick={() => selectForecast(time.day, time.hour)}
|
onClick={() => selectForecast(time.day, time.hour)}
|
||||||
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
className={`w-full px-2 py-1 text-xs rounded transition-colors ${
|
||||||
selectedForecast?.day === time.day &&
|
selectedForecast?.ofcFrcstYmd === time.day &&
|
||||||
selectedForecast?.hour === time.hour
|
selectedForecast?.ofcFrcstTm === time.hour
|
||||||
? 'bg-primary-cyan text-bg-0 font-semibold'
|
? 'bg-primary-cyan text-bg-0 font-semibold'
|
||||||
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
: 'bg-bg-2 text-text-3 hover:bg-bg-3'
|
||||||
}`}
|
}`}
|
||||||
@ -499,9 +497,9 @@ export function WeatherView() {
|
|||||||
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
{oceanError && <div className="text-xs text-status-red">오류 발생</div>}
|
||||||
{selectedForecast && (
|
{selectedForecast && (
|
||||||
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
<div className="text-xs text-text-3 pt-2 border-t border-border">
|
||||||
현재: {selectedForecast.name} •{' '}
|
현재: {selectedForecast.ofcBrnchNm} •{' '}
|
||||||
{selectedForecast.day.slice(4, 6)}/{selectedForecast.day.slice(6, 8)}{' '}
|
{selectedForecast.ofcFrcstYmd.slice(4, 6)}/{selectedForecast.ofcFrcstYmd.slice(6, 8)}{' '}
|
||||||
{selectedForecast.hour}:00
|
{selectedForecast.ofcFrcstTm}:00
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -70,9 +70,9 @@ export function useOceanForecast(
|
|||||||
// 사용 가능한 시간대 목록 생성
|
// 사용 가능한 시간대 목록 생성
|
||||||
const availableTimes = forecasts
|
const availableTimes = forecasts
|
||||||
.map((f) => ({
|
.map((f) => ({
|
||||||
day: f.day,
|
day: f.ofcFrcstYmd,
|
||||||
hour: f.hour,
|
hour: f.ofcFrcstTm,
|
||||||
label: `${f.day.slice(4, 6)}/${f.day.slice(6, 8)} ${f.hour}:00`
|
label: `${f.ofcFrcstYmd.slice(4, 6)}/${f.ofcFrcstYmd.slice(6, 8)} ${f.ofcFrcstTm}:00`
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))
|
.sort((a, b) => `${a.day}${a.hour}`.localeCompare(`${b.day}${b.hour}`))
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
// API Key를 환경변수에서 로드 (소스코드 노출 방지)
|
||||||
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
const API_KEY = import.meta.env.VITE_DATA_GO_KR_API_KEY || ''
|
||||||
const BASE_URL = 'https://www.khoa.go.kr/api/oceangrid/DataType/search.do'
|
const BASE_URL = 'https://apis.data.go.kr/1192136/oceanCondition/GetOceanConditionApiService'
|
||||||
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
const RECENT_OBS_URL = 'https://apis.data.go.kr/1192136/dtRecent/GetDTRecentApiService'
|
||||||
|
|
||||||
// 지역 유형 (총 20개 지역)
|
// 지역 유형 (총 20개 지역)
|
||||||
@ -22,20 +22,21 @@ export const OCEAN_REGIONS = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export interface OceanForecastData {
|
export interface OceanForecastData {
|
||||||
name: string // 지역명
|
imgFileNm: string // 이미지 파일명
|
||||||
type: string // 지역유형
|
imgFilePath: string // 이미지 파일경로
|
||||||
day: string // 예보날짜 (YYYYMMDD)
|
ofcBrnchId: string // 해황예보도 지점코드
|
||||||
hour: string // 예보시간 (HH)
|
ofcBrnchNm: string // 해황예보도 지점이름
|
||||||
fileName: string // 이미지 파일명
|
ofcFrcstTm: string // 해황예보도 예보시각
|
||||||
filePath: string // 해양예측 이미지 URL
|
ofcFrcstYmd: string // 해황예보도 예보일자
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OceanForecastResponse {
|
interface OceanForecastApiResponse {
|
||||||
result: {
|
header: { resultCode: string; resultMsg: string }
|
||||||
data: OceanForecastData[]
|
body: {
|
||||||
meta: {
|
items: { item: OceanForecastData[] }
|
||||||
totalCount: number
|
pageNo: number
|
||||||
}
|
numOfRows: number
|
||||||
|
totalCount: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,9 +50,9 @@ export async function getOceanForecast(
|
|||||||
): Promise<OceanForecastData[]> {
|
): Promise<OceanForecastData[]> {
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
ServiceKey: API_KEY,
|
serviceKey: API_KEY,
|
||||||
type: regionType,
|
areaCode: regionType,
|
||||||
ResultType: 'json'
|
type: 'json',
|
||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${BASE_URL}?${params}`)
|
const response = await fetch(`${BASE_URL}?${params}`)
|
||||||
@ -60,20 +61,8 @@ export async function getOceanForecast(
|
|||||||
throw new Error(`HTTP Error: ${response.status}`)
|
throw new Error(`HTTP Error: ${response.status}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json() as OceanForecastApiResponse
|
||||||
|
return data?.body?.items?.item ?? []
|
||||||
// API 응답 구조에 따라 데이터 추출
|
|
||||||
if (data?.result?.data) {
|
|
||||||
return data.result.data
|
|
||||||
}
|
|
||||||
|
|
||||||
// 응답이 배열 형태인 경우
|
|
||||||
if (Array.isArray(data)) {
|
|
||||||
return data
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn('Unexpected API response structure:', data)
|
|
||||||
return []
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('해황예보도 조회 오류:', error)
|
console.error('해황예보도 조회 오류:', error)
|
||||||
@ -89,10 +78,9 @@ export async function getOceanForecast(
|
|||||||
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
export function getLatestForecast(forecasts: OceanForecastData[]): OceanForecastData | null {
|
||||||
if (!forecasts || forecasts.length === 0) return null
|
if (!forecasts || forecasts.length === 0) return null
|
||||||
|
|
||||||
// 날짜와 시간을 기준으로 정렬
|
|
||||||
const sorted = [...forecasts].sort((a, b) => {
|
const sorted = [...forecasts].sort((a, b) => {
|
||||||
const dateTimeA = `${a.day}${a.hour}`
|
const dateTimeA = `${a.ofcFrcstYmd}${a.ofcFrcstTm}`
|
||||||
const dateTimeB = `${b.day}${b.hour}`
|
const dateTimeB = `${b.ofcFrcstYmd}${b.ofcFrcstTm}`
|
||||||
return dateTimeB.localeCompare(dateTimeA)
|
return dateTimeB.localeCompare(dateTimeA)
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -112,7 +100,7 @@ export function getForecastByTime(
|
|||||||
targetHour: string
|
targetHour: string
|
||||||
): OceanForecastData | null {
|
): OceanForecastData | null {
|
||||||
return (
|
return (
|
||||||
forecasts.find((f) => f.day === targetDay && f.hour === targetHour) || null
|
forecasts.find((f) => f.ofcFrcstYmd === targetDay && f.ofcFrcstTm === targetHour) || null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,23 +145,25 @@ export async function getRecentObservation(obsCode: string): Promise<RecentObser
|
|||||||
})
|
})
|
||||||
|
|
||||||
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
const response = await fetch(`${RECENT_OBS_URL}?${params}`)
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
const item = data?.result?.data?.[0]
|
const item = data.body ? data.body.items.item[0] : null
|
||||||
if (!item) return null
|
if (!item) return null
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
water_temp: item.water_temp != null ? parseFloat(item.water_temp) : null,
|
water_temp: item.wtem != null ? parseFloat(item.wtem) : null,
|
||||||
air_temp: item.air_temp != null ? parseFloat(item.air_temp) : null,
|
air_temp: item.artmp != null ? parseFloat(item.artmp) : null,
|
||||||
air_pres: item.air_pres != null ? parseFloat(item.air_pres) : null,
|
air_pres: item.atmpr != null ? parseFloat(item.atmpr) : null,
|
||||||
wind_dir: item.wind_dir != null ? parseFloat(item.wind_dir) : null,
|
wind_dir: item.wndrct != null ? parseFloat(item.wndrct) : null,
|
||||||
wind_speed: item.wind_speed != null ? parseFloat(item.wind_speed) : null,
|
wind_speed: item.wspd != null ? parseFloat(item.wspd) : null,
|
||||||
current_dir: item.current_dir != null ? parseFloat(item.current_dir) : null,
|
current_dir: item.crdir != null ? parseFloat(item.crdir) : null,
|
||||||
current_speed: item.current_speed != null ? parseFloat(item.current_speed) : null,
|
current_speed: item.crsp != null ? parseFloat(item.crsp) : null,
|
||||||
tide_level: item.tide_level != null ? parseFloat(item.tide_level) : null,
|
tide_level: item.bscTdlvHgt != null ? parseFloat(item.bscTdlvHgt) : null,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
console.error(`관측소 ${obsCode} 데이터 조회 오류:`, error)
|
||||||
|
|||||||
@ -12,6 +12,10 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3001',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
'/daily_ocean': {
|
||||||
|
target: 'https://www.khoa.go.kr',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user