@uniai-fe/uds-templates 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -27
- package/package.json +6 -5
- package/src/cctv/apis/client.ts +5 -2
- package/src/cctv/hooks/useRtcStream.ts +111 -2
- package/src/weather/apis/client.ts +127 -7
- package/src/weather/apis/server.ts +114 -5
- package/src/weather/types/api.ts +50 -1
- package/src/weather/types/base.ts +16 -0
- package/src/weather/utils/location.ts +81 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Next.js 서비스에서 primitives와 동일한 방식으로 **Raw TypeScript**
|
|
|
11
11
|
- `@uniai-fe/uds-foundation@^0.0.1` (CSS 토큰 + reset)
|
|
12
12
|
- `@uniai-fe/uds-primitives@^0.0.2` (템플릿 내부 UI 컴포넌트)
|
|
13
13
|
- `react` `>= 19`, `react-dom` `>= 19`
|
|
14
|
-
- Storybook/로컬
|
|
14
|
+
- Storybook/로컬 개발과 서비스 앱 모두 style load 순서를 명시해야 합니다. 자세한 기준은 아래 "스타일 전역 주입"을 따릅니다.
|
|
15
15
|
|
|
16
16
|
> 템플릿은 primitives 위에서 동작하므로, `@uniai-fe/uds-primitives`가 설치되어 있지 않으면 빌드 타임에 peer dependency 경고가 발생합니다.
|
|
17
17
|
|
|
@@ -21,16 +21,30 @@ Next.js 서비스에서 primitives와 동일한 방식으로 **Raw TypeScript**
|
|
|
21
21
|
- 로그인/회원가입/아이디·비밀번호 찾기 등 `/auth/**`
|
|
22
22
|
- 로그인 이후 화면을 위한 모바일 페이지 프레임 `/page-frame/mobile`(PC는 추후 `/page-frame/pc`)
|
|
23
23
|
- Raw TS 배포:
|
|
24
|
-
- `package.json`
|
|
24
|
+
- `package.json` root export가 `src/index.tsx`를 가리키며, 번들 없이 root namespace에서 즉시 import 가능한 형태로 제공합니다.
|
|
25
25
|
- 디자인시스템 일관성:
|
|
26
|
-
- 스타일은
|
|
26
|
+
- 스타일은 foundation style entry가 제공하는 CSS 변수(토큰)를 통해만 정의됩니다.
|
|
27
27
|
- UI 요소는 모두 `@uniai-fe/uds-primitives` 컴포넌트를 조합해 구성합니다.
|
|
28
28
|
- 역할 분리:
|
|
29
29
|
- **templates**는 레이아웃/플로우/상태 표현까지 담당하고,
|
|
30
30
|
- API 호출, 인증 토큰 관리, 라우팅, i18n 등 비즈니스 로직은 서비스 앱에서 구현합니다.
|
|
31
31
|
|
|
32
|
+
## Public import surface
|
|
33
|
+
|
|
34
|
+
`@uniai-fe/uds-templates`의 공식 안정 public surface는 root namespace import다.
|
|
35
|
+
|
|
36
|
+
```tsx
|
|
37
|
+
import { Auth, Modal, Frame } from "@uniai-fe/uds-templates";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
- `@uniai-fe/uds-templates/auth`, `@uniai-fe/uds-templates/modal`, `@uniai-fe/uds-templates/page-frame` 같은 category subpath는 현재 package export map에 없으므로 공식 사용 예시로 안내하지 않는다.
|
|
41
|
+
- root에는 UI template namespace뿐 아니라 일부 API/helper/state surface도 함께 포함되어 있다. 이 책임 경계는 숨기지 않고 후속 package/API contract gate에서 정리한다.
|
|
42
|
+
- category subpath 공개 여부는 package contract gate 후보로 남긴다.
|
|
43
|
+
|
|
32
44
|
## 확인 완료 도구 목록
|
|
33
45
|
|
|
46
|
+
아래 `/modal`, `/weather` 같은 표기는 API inventory를 묶는 카테고리 label이다. 실제 consumer import path는 현재 root namespace import를 기준으로 한다.
|
|
47
|
+
|
|
34
48
|
- `/modal`
|
|
35
49
|
- `Modal.Provider`
|
|
36
50
|
- `Modal.StackProvider`
|
|
@@ -189,15 +203,14 @@ export default nextConfig;
|
|
|
189
203
|
|
|
190
204
|
### 2) 스타일 전역 주입
|
|
191
205
|
|
|
192
|
-
|
|
206
|
+
templates style entry는 templates styles만 제공하며 foundation/primitives style을 다시 로드하지 않습니다. 서비스 앱은 app root 또는 global stylesheet에서 `foundation -> primitives -> templates` 순서로 style entry를 명시적으로 로드해야 합니다.
|
|
193
207
|
|
|
194
|
-
|
|
195
|
-
/* app/globals.scss (Sass 사용 프로젝트) */
|
|
196
|
-
@use "@uniai-fe/uds-templates/styles";
|
|
197
|
-
```
|
|
208
|
+
CSS-only 소비:
|
|
198
209
|
|
|
199
210
|
```ts
|
|
200
|
-
// app/layout.tsx
|
|
211
|
+
// app/layout.tsx
|
|
212
|
+
import "@uniai-fe/uds-foundation/css";
|
|
213
|
+
import "@uniai-fe/uds-primitives/css";
|
|
201
214
|
import "@uniai-fe/uds-templates/css";
|
|
202
215
|
|
|
203
216
|
export default function RootLayout(props: { children: React.ReactNode }) {
|
|
@@ -209,23 +222,40 @@ export default function RootLayout(props: { children: React.ReactNode }) {
|
|
|
209
222
|
}
|
|
210
223
|
```
|
|
211
224
|
|
|
225
|
+
Sass 소비:
|
|
226
|
+
|
|
227
|
+
```scss
|
|
228
|
+
/* app/globals.scss */
|
|
229
|
+
@use "@uniai-fe/uds-foundation/scss";
|
|
230
|
+
@use "@uniai-fe/uds-primitives/styles";
|
|
231
|
+
@use "@uniai-fe/uds-templates/styles";
|
|
232
|
+
```
|
|
233
|
+
|
|
212
234
|
## 간단 사용 예시
|
|
213
235
|
|
|
236
|
+
아래는 root namespace import와 prop shape를 보여주는 개념 예시다. App Router server page에서 직접 event handler를 넘기지 말고, client wrapper 또는 서비스 앱 로그인 컨테이너에서 조합한다.
|
|
237
|
+
|
|
214
238
|
```tsx
|
|
215
|
-
|
|
216
|
-
|
|
239
|
+
"use client";
|
|
240
|
+
|
|
241
|
+
import { Auth } from "@uniai-fe/uds-templates";
|
|
217
242
|
|
|
218
|
-
export
|
|
243
|
+
export function LoginTemplateExample() {
|
|
219
244
|
return (
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
245
|
+
<Auth.Login.Container
|
|
246
|
+
header={<img src="/logo.svg" alt="서비스 로고" />}
|
|
247
|
+
fieldOptions={{
|
|
248
|
+
onLogin: async (values) => {
|
|
249
|
+
// 실제 로그인 API 호출은 서비스 앱에서 구현
|
|
250
|
+
// await login(values);
|
|
251
|
+
},
|
|
224
252
|
}}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
253
|
+
linkOptions={{
|
|
254
|
+
find: {
|
|
255
|
+
id: "/auth/find-id",
|
|
256
|
+
password: "/auth/find-password",
|
|
257
|
+
},
|
|
258
|
+
signup: "/auth/signup",
|
|
229
259
|
}}
|
|
230
260
|
/>
|
|
231
261
|
);
|
|
@@ -235,7 +265,7 @@ export default function LoginPage() {
|
|
|
235
265
|
> 위 예시는 개념을 설명하기 위한 형태이며,
|
|
236
266
|
> 실제 props 구조/이름은 `CONTEXT-AUTH.md`에서 확정·관리합니다.
|
|
237
267
|
|
|
238
|
-
> modules 레포 내부
|
|
268
|
+
> modules 레포 내부 Storybook은 local render setup으로 `@uniai-fe/uds-foundation/css` → `@uniai-fe/uds-primitives/styles` → `@uniai-fe/uds-templates/styles`를 사용한다. 이 설정은 Storybook 렌더링 검증용이며, 외부 consumer setup은 위 CSS-only/Sass 계약을 따른다.
|
|
239
269
|
|
|
240
270
|
### 최근 업데이트
|
|
241
271
|
|
|
@@ -245,9 +275,10 @@ export default function LoginPage() {
|
|
|
245
275
|
|
|
246
276
|
### 토큰 스코프 & ThemeProvider
|
|
247
277
|
|
|
248
|
-
- templates
|
|
249
|
-
- 서비스 앱은 foundation ThemeProvider가 주입하는 `.uds-theme-root`를 루트에
|
|
250
|
-
-
|
|
278
|
+
- templates CSS/styles는 templates module styles만 제공하며 upstream foundation/primitives styles를 초기화하지 않습니다.
|
|
279
|
+
- 서비스 앱은 foundation ThemeProvider가 주입하는 `.uds-theme-root`를 루트에 유지할 수 있지만, ThemeProvider는 CSS를 import하지 않습니다.
|
|
280
|
+
- CSS-only 소비자는 `@uniai-fe/uds-foundation/css` → `@uniai-fe/uds-primitives/css` → `@uniai-fe/uds-templates/css` 순서를 사용합니다.
|
|
281
|
+
- Sass 소비자는 `@uniai-fe/uds-foundation/scss` → `@uniai-fe/uds-primitives/styles` → `@uniai-fe/uds-templates/styles` 순서를 사용합니다.
|
|
251
282
|
|
|
252
283
|
## Modal 모듈 사용법
|
|
253
284
|
|
|
@@ -260,8 +291,10 @@ ui-legacy에서 사용하던 모달 스택/옵션을 templates 레이어로 옮
|
|
|
260
291
|
|
|
261
292
|
```tsx
|
|
262
293
|
// app/layout.tsx
|
|
263
|
-
import "@uniai-fe/uds-
|
|
264
|
-
import
|
|
294
|
+
import "@uniai-fe/uds-foundation/css";
|
|
295
|
+
import "@uniai-fe/uds-primitives/css";
|
|
296
|
+
import "@uniai-fe/uds-templates/css";
|
|
297
|
+
import { Modal } from "@uniai-fe/uds-templates";
|
|
265
298
|
|
|
266
299
|
export default function RootLayout({
|
|
267
300
|
children,
|
|
@@ -284,7 +317,7 @@ export default function RootLayout({
|
|
|
284
317
|
|
|
285
318
|
```tsx
|
|
286
319
|
// 예: Alert/Confirm 호출
|
|
287
|
-
import { Modal } from "@uniai-fe/uds-templates
|
|
320
|
+
import { Modal } from "@uniai-fe/uds-templates";
|
|
288
321
|
|
|
289
322
|
export function ExampleActions() {
|
|
290
323
|
const { newModal } = Modal.useModal();
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniai-fe/uds-templates",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "UNIAI Design System; UI Templates Package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
7
7
|
"sideEffects": [
|
|
8
|
-
"./src/**/*.scss"
|
|
8
|
+
"./src/**/*.scss",
|
|
9
|
+
"./dist/styles.css"
|
|
9
10
|
],
|
|
10
11
|
"license": "MIT",
|
|
11
12
|
"homepage": "https://www.uniai.co.kr/",
|
|
@@ -70,9 +71,9 @@
|
|
|
70
71
|
},
|
|
71
72
|
"devDependencies": {
|
|
72
73
|
"@svgr/webpack": "^8.1.0",
|
|
73
|
-
"@tanstack/react-query": "^5.100.
|
|
74
|
+
"@tanstack/react-query": "^5.100.11",
|
|
74
75
|
"@types/node": "^24.12.3",
|
|
75
|
-
"@types/react": "^19.2.
|
|
76
|
+
"@types/react": "^19.2.15",
|
|
76
77
|
"@types/react-dom": "^19.2.3",
|
|
77
78
|
"@uniai-fe/eslint-config": "workspace:*",
|
|
78
79
|
"@uniai-fe/next-devkit": "workspace:*",
|
|
@@ -89,7 +90,7 @@
|
|
|
89
90
|
"jotai": "^2.20.0",
|
|
90
91
|
"next": "^15.5.18",
|
|
91
92
|
"prettier": "^3.8.3",
|
|
92
|
-
"react-hook-form": "^7.
|
|
93
|
+
"react-hook-form": "^7.76.0",
|
|
93
94
|
"sass": "^1.99.0",
|
|
94
95
|
"typescript": "5.9.3"
|
|
95
96
|
}
|
package/src/cctv/apis/client.ts
CHANGED
|
@@ -7,6 +7,9 @@ import type {
|
|
|
7
7
|
API_Res_CctvRtcToken,
|
|
8
8
|
} from "../types";
|
|
9
9
|
|
|
10
|
+
export const CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS = 5 * 60 * 1000;
|
|
11
|
+
export const CCTV_RTC_TOKEN_GC_TIME_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
10
13
|
export const getClientCctvCompanyList = async ({
|
|
11
14
|
url,
|
|
12
15
|
}: {
|
|
@@ -54,8 +57,8 @@ export const useQueryCctvRtcToken = ({
|
|
|
54
57
|
queryKey: ["cctv_rtc_token", username, company_id, cam_id, url],
|
|
55
58
|
queryFn: () => postCctvRtcToken({ company_id, cam_id, username, url }),
|
|
56
59
|
enabled: Boolean(username && company_id && cam_id),
|
|
57
|
-
staleTime:
|
|
58
|
-
gcTime:
|
|
60
|
+
staleTime: CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
|
|
61
|
+
gcTime: CCTV_RTC_TOKEN_GC_TIME_MS,
|
|
59
62
|
refetchOnMount: false,
|
|
60
63
|
refetchOnReconnect: false,
|
|
61
64
|
refetchOnWindowFocus: false,
|
|
@@ -5,7 +5,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
5
5
|
import { useSetAtom } from "jotai";
|
|
6
6
|
|
|
7
7
|
// 토큰 발급 쿼리와 API URL 컨텍스트 훅, 타입, react-hook-form 유틸.
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS,
|
|
10
|
+
useQueryCctvRtcToken,
|
|
11
|
+
} from "../apis/client";
|
|
9
12
|
import {
|
|
10
13
|
useCctvApiUrl,
|
|
11
14
|
useCctvRtcStreamRegistry,
|
|
@@ -20,6 +23,52 @@ import { getIsLive } from "../utils/video-state";
|
|
|
20
23
|
import { useFormContext, useWatch } from "react-hook-form";
|
|
21
24
|
|
|
22
25
|
const AUTO_RECONNECT_INTERVAL_MS = 3000;
|
|
26
|
+
const TOKEN_EXPIRY_SAFETY_MS = 30 * 1000;
|
|
27
|
+
|
|
28
|
+
const decodeBase64Url = (value: string): string | null => {
|
|
29
|
+
try {
|
|
30
|
+
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
31
|
+
const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "=");
|
|
32
|
+
|
|
33
|
+
return atob(padded);
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const getJwtExpiresAt = (token: string): number | null => {
|
|
40
|
+
const payload = token.split(".")[1];
|
|
41
|
+
if (!payload) return null;
|
|
42
|
+
|
|
43
|
+
const decodedPayload = decodeBase64Url(payload);
|
|
44
|
+
if (!decodedPayload) return null;
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const parsedPayload = JSON.parse(decodedPayload) as { exp?: unknown };
|
|
48
|
+
const exp = parsedPayload.exp;
|
|
49
|
+
|
|
50
|
+
return typeof exp === "number" && Number.isFinite(exp) ? exp * 1000 : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const canUseTokenForNewConnection = ({
|
|
57
|
+
dataUpdatedAt,
|
|
58
|
+
token,
|
|
59
|
+
}: {
|
|
60
|
+
dataUpdatedAt: number;
|
|
61
|
+
token: string;
|
|
62
|
+
}): boolean => {
|
|
63
|
+
const expiresAt = getJwtExpiresAt(token);
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
|
|
66
|
+
if (expiresAt) {
|
|
67
|
+
return now + TOKEN_EXPIRY_SAFETY_MS < expiresAt;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return now - dataUpdatedAt <= CCTV_RTC_TOKEN_FALLBACK_MAX_AGE_MS;
|
|
71
|
+
};
|
|
23
72
|
|
|
24
73
|
/**
|
|
25
74
|
* CCTV 영상 스트림을 WebRTC로 연결하는 커스텀 훅.
|
|
@@ -60,6 +109,7 @@ export function useCctvRtcStream({
|
|
|
60
109
|
const activeStreamKeyRef = useRef<string | null>(null);
|
|
61
110
|
const activeStreamIdentityKeyRef = useRef<string | null>(null);
|
|
62
111
|
const lastAutoReconnectAtRef = useRef(0);
|
|
112
|
+
const staleTokenRefreshKeyRef = useRef<string | null>(null);
|
|
63
113
|
|
|
64
114
|
// RTCPeerConnectionState를 관찰해 UI에 노출하기 위한 상태값.
|
|
65
115
|
const [connectionState, setConnectionState] =
|
|
@@ -95,7 +145,7 @@ export function useCctvRtcStream({
|
|
|
95
145
|
return `${cam.cam_rtc.replace(/\/$/, "")}/whep${query}`;
|
|
96
146
|
}, [cam?.cam_rtc, username]);
|
|
97
147
|
|
|
98
|
-
const
|
|
148
|
+
const streamKeyCandidate = useMemo(() => {
|
|
99
149
|
if (!cam?.cam_id || !cam.cam_online || !endpoint || !tokenQuery.data?.token)
|
|
100
150
|
return "";
|
|
101
151
|
|
|
@@ -126,6 +176,65 @@ export function useCctvRtcStream({
|
|
|
126
176
|
setHasConnected(false);
|
|
127
177
|
}, [streamIdentityKey]);
|
|
128
178
|
|
|
179
|
+
const hasReusableRegistryStream = useMemo(() => {
|
|
180
|
+
if (!streamKeyCandidate) return false;
|
|
181
|
+
|
|
182
|
+
const snapshot = streamRegistry.getSnapshot(streamKeyCandidate);
|
|
183
|
+
|
|
184
|
+
return Boolean(
|
|
185
|
+
snapshot.stream ||
|
|
186
|
+
snapshot.isStreaming ||
|
|
187
|
+
snapshot.connectionState === "connected",
|
|
188
|
+
);
|
|
189
|
+
}, [streamKeyCandidate, streamRegistry]);
|
|
190
|
+
|
|
191
|
+
const canUseTokenForStream = useMemo(() => {
|
|
192
|
+
if (!streamKeyCandidate || !tokenQuery.data?.token) return false;
|
|
193
|
+
if (activeStreamKeyRef.current === streamKeyCandidate) return true;
|
|
194
|
+
if (hasReusableRegistryStream) return true;
|
|
195
|
+
|
|
196
|
+
return canUseTokenForNewConnection({
|
|
197
|
+
dataUpdatedAt: tokenQuery.dataUpdatedAt,
|
|
198
|
+
token: tokenQuery.data.token,
|
|
199
|
+
});
|
|
200
|
+
}, [
|
|
201
|
+
hasReusableRegistryStream,
|
|
202
|
+
streamKeyCandidate,
|
|
203
|
+
tokenQuery.data?.token,
|
|
204
|
+
tokenQuery.dataUpdatedAt,
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const streamKey = canUseTokenForStream ? streamKeyCandidate : "";
|
|
208
|
+
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!streamKeyCandidate || canUseTokenForStream) {
|
|
211
|
+
staleTokenRefreshKeyRef.current = null;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (
|
|
216
|
+
!tokenQuery.data?.token ||
|
|
217
|
+
tokenQuery.isFetching ||
|
|
218
|
+
tokenQuery.isError
|
|
219
|
+
) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (staleTokenRefreshKeyRef.current === streamKeyCandidate) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
staleTokenRefreshKeyRef.current = streamKeyCandidate;
|
|
228
|
+
void refetchRtcToken();
|
|
229
|
+
}, [
|
|
230
|
+
canUseTokenForStream,
|
|
231
|
+
refetchRtcToken,
|
|
232
|
+
streamKeyCandidate,
|
|
233
|
+
tokenQuery.data?.token,
|
|
234
|
+
tokenQuery.isError,
|
|
235
|
+
tokenQuery.isFetching,
|
|
236
|
+
]);
|
|
237
|
+
|
|
129
238
|
// 토큰과 endpoint가 준비되면 WebRTC 스트림을 연결한다.
|
|
130
239
|
useEffect(() => {
|
|
131
240
|
const currentVideo = videoRef.current;
|
|
@@ -35,6 +35,14 @@ const hasQueryValue = (value: unknown): boolean =>
|
|
|
35
35
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
36
36
|
* @param {number|null} [params.lat] 위도
|
|
37
37
|
* @param {number|null} [params.lng] 경도
|
|
38
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
39
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
40
|
+
* @param {string} [params.base_date] 현재/예보 단일 route 재현용 날짜
|
|
41
|
+
* @param {string} [params.base_time] 현재/예보 단일 route 재현용 시각
|
|
42
|
+
* @param {string} [params.now_base_date] summary 현재 날씨 재현용 날짜
|
|
43
|
+
* @param {string} [params.now_base_time] summary 현재 날씨 재현용 시각
|
|
44
|
+
* @param {string} [params.forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
45
|
+
* @param {string} [params.forecast_base_time] summary 예보 날씨 재현용 시각
|
|
38
46
|
* @param {string} [params.locale] 요청 언어 코드
|
|
39
47
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
40
48
|
* @return {API_Req_Weather} local route query 파라미터
|
|
@@ -43,12 +51,28 @@ export const getWeatherClientParams = ({
|
|
|
43
51
|
farm_idx,
|
|
44
52
|
lat,
|
|
45
53
|
lng,
|
|
54
|
+
nx,
|
|
55
|
+
ny,
|
|
56
|
+
base_date,
|
|
57
|
+
base_time,
|
|
58
|
+
now_base_date,
|
|
59
|
+
now_base_time,
|
|
60
|
+
forecast_base_date,
|
|
61
|
+
forecast_base_time,
|
|
46
62
|
locale,
|
|
47
63
|
include_raw,
|
|
48
64
|
}: API_Req_Weather): API_Req_Weather => ({
|
|
49
65
|
...(hasQueryValue(farm_idx) ? { farm_idx } : {}),
|
|
50
66
|
...(hasQueryValue(lat) ? { lat } : {}),
|
|
51
67
|
...(hasQueryValue(lng) ? { lng } : {}),
|
|
68
|
+
...(hasQueryValue(nx) ? { nx } : {}),
|
|
69
|
+
...(hasQueryValue(ny) ? { ny } : {}),
|
|
70
|
+
...(hasQueryValue(base_date) ? { base_date } : {}),
|
|
71
|
+
...(hasQueryValue(base_time) ? { base_time } : {}),
|
|
72
|
+
...(hasQueryValue(now_base_date) ? { now_base_date } : {}),
|
|
73
|
+
...(hasQueryValue(now_base_time) ? { now_base_time } : {}),
|
|
74
|
+
...(hasQueryValue(forecast_base_date) ? { forecast_base_date } : {}),
|
|
75
|
+
...(hasQueryValue(forecast_base_time) ? { forecast_base_time } : {}),
|
|
52
76
|
...(locale ? { locale } : {}),
|
|
53
77
|
...(typeof include_raw === "boolean" ? { include_raw } : {}),
|
|
54
78
|
});
|
|
@@ -65,10 +89,12 @@ const getWeatherRoutePath = (
|
|
|
65
89
|
): string => routePath || fallbackRoutePath;
|
|
66
90
|
|
|
67
91
|
/**
|
|
68
|
-
* Weather Client;
|
|
92
|
+
* Weather Client; 현재/예보 조회가 가능한 위치 입력인지 확인.
|
|
69
93
|
* @param {API_Req_Weather} params 날씨 요청 파라미터
|
|
70
94
|
* @param {number|null} [params.lat] 위도
|
|
71
95
|
* @param {number|null} [params.lng] 경도
|
|
96
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
97
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
72
98
|
* @return {boolean} 위치 기반 weather query 실행 가능 여부
|
|
73
99
|
*/
|
|
74
100
|
const isWeatherLocationAvailable = ({ lat, lng }: API_Req_Weather): boolean =>
|
|
@@ -79,6 +105,23 @@ const isWeatherLocationAvailable = ({ lat, lng }: API_Req_Weather): boolean =>
|
|
|
79
105
|
isValidNumber(lat) &&
|
|
80
106
|
isValidNumber(lng);
|
|
81
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Weather Client; backend 격자 입력이 사용 가능한지 확인.
|
|
110
|
+
* @param {API_Req_Weather} params 날씨 요청 파라미터
|
|
111
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
112
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
113
|
+
* @return {boolean} 격자 기반 weather query 실행 가능 여부
|
|
114
|
+
*/
|
|
115
|
+
const isWeatherGridAvailable = ({ nx, ny }: API_Req_Weather): boolean =>
|
|
116
|
+
nx !== null &&
|
|
117
|
+
ny !== null &&
|
|
118
|
+
typeof nx !== "undefined" &&
|
|
119
|
+
typeof ny !== "undefined" &&
|
|
120
|
+
isValidNumber(nx) &&
|
|
121
|
+
isValidNumber(ny) &&
|
|
122
|
+
Number(nx) > 0 &&
|
|
123
|
+
Number(ny) > 0;
|
|
124
|
+
|
|
82
125
|
/**
|
|
83
126
|
* Weather Client; 특보 조회가 가능한 농장 식별자인지 확인.
|
|
84
127
|
* @param {API_Req_WeatherAlert} params 특보 요청 파라미터
|
|
@@ -88,21 +131,66 @@ const isWeatherLocationAvailable = ({ lat, lng }: API_Req_Weather): boolean =>
|
|
|
88
131
|
const isWeatherFarmAvailable = ({ farm_idx }: API_Req_WeatherAlert): boolean =>
|
|
89
132
|
hasQueryValue(farm_idx) && Number(farm_idx) > 0;
|
|
90
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Weather Client; summary 조회에 필요한 입력인지 확인.
|
|
136
|
+
* @param {API_Req_Weather} params summary 요청 파라미터
|
|
137
|
+
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
138
|
+
* @return {boolean} summary query 실행 가능 여부
|
|
139
|
+
*/
|
|
140
|
+
const isWeatherSummaryAvailable = (params: API_Req_Weather): boolean =>
|
|
141
|
+
isWeatherFarmAvailable({ farm_idx: params.farm_idx ?? null }) &&
|
|
142
|
+
(isWeatherLocationAvailable(params) || isWeatherGridAvailable(params));
|
|
143
|
+
|
|
91
144
|
/**
|
|
92
145
|
* Weather Client; React Query key에 사용할 weather 요청 값을 정렬.
|
|
93
146
|
* @param {API_Req_Weather} params 날씨 요청 파라미터
|
|
94
147
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
95
148
|
* @param {number|null} [params.lat] 위도
|
|
96
149
|
* @param {number|null} [params.lng] 경도
|
|
150
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
151
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
152
|
+
* @param {string} [params.base_date] 현재/예보 단일 route 재현용 날짜
|
|
153
|
+
* @param {string} [params.base_time] 현재/예보 단일 route 재현용 시각
|
|
154
|
+
* @param {string} [params.now_base_date] summary 현재 날씨 재현용 날짜
|
|
155
|
+
* @param {string} [params.now_base_time] summary 현재 날씨 재현용 시각
|
|
156
|
+
* @param {string} [params.forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
157
|
+
* @param {string} [params.forecast_base_time] summary 예보 날씨 재현용 시각
|
|
97
158
|
* @param {string} [params.locale] 요청 언어 코드
|
|
98
159
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
99
160
|
* @return {readonly unknown[]} React Query key 값
|
|
100
161
|
*/
|
|
101
162
|
const getWeatherQueryKeyValues = (params: API_Req_Weather) => {
|
|
102
|
-
const {
|
|
103
|
-
|
|
163
|
+
const {
|
|
164
|
+
farm_idx,
|
|
165
|
+
lat,
|
|
166
|
+
lng,
|
|
167
|
+
nx,
|
|
168
|
+
ny,
|
|
169
|
+
base_date,
|
|
170
|
+
base_time,
|
|
171
|
+
now_base_date,
|
|
172
|
+
now_base_time,
|
|
173
|
+
forecast_base_date,
|
|
174
|
+
forecast_base_time,
|
|
175
|
+
locale,
|
|
176
|
+
include_raw,
|
|
177
|
+
} = getWeatherClientParams(params);
|
|
104
178
|
|
|
105
|
-
return [
|
|
179
|
+
return [
|
|
180
|
+
farm_idx,
|
|
181
|
+
lat,
|
|
182
|
+
lng,
|
|
183
|
+
nx,
|
|
184
|
+
ny,
|
|
185
|
+
base_date,
|
|
186
|
+
base_time,
|
|
187
|
+
now_base_date,
|
|
188
|
+
now_base_time,
|
|
189
|
+
forecast_base_date,
|
|
190
|
+
forecast_base_time,
|
|
191
|
+
locale,
|
|
192
|
+
include_raw,
|
|
193
|
+
] as const;
|
|
106
194
|
};
|
|
107
195
|
|
|
108
196
|
/**
|
|
@@ -112,6 +200,10 @@ const getWeatherQueryKeyValues = (params: API_Req_Weather) => {
|
|
|
112
200
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
113
201
|
* @param {number|null} [params.lat] 위도
|
|
114
202
|
* @param {number|null} [params.lng] 경도
|
|
203
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
204
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
205
|
+
* @param {string} [params.base_date] 현재 날씨 재현용 날짜
|
|
206
|
+
* @param {string} [params.base_time] 현재 날씨 재현용 시각
|
|
115
207
|
* @param {string} [params.locale] 요청 언어 코드
|
|
116
208
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
117
209
|
* @param {WeatherClientRouteOptions} [options] local route 옵션
|
|
@@ -138,6 +230,10 @@ export const getWeatherNow = async <Data = API_Res_WeatherNow>(
|
|
|
138
230
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
139
231
|
* @param {number|null} [params.lat] 위도
|
|
140
232
|
* @param {number|null} [params.lng] 경도
|
|
233
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
234
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
235
|
+
* @param {string} [params.base_date] 예보 날씨 재현용 날짜
|
|
236
|
+
* @param {string} [params.base_time] 예보 날씨 재현용 시각
|
|
141
237
|
* @param {string} [params.locale] 요청 언어 코드
|
|
142
238
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
143
239
|
* @param {WeatherClientRouteOptions} [options] local route 옵션
|
|
@@ -164,6 +260,12 @@ export const getWeatherForecast = async <Data = API_Res_WeatherForecast>(
|
|
|
164
260
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
165
261
|
* @param {number|null} [params.lat] 위도
|
|
166
262
|
* @param {number|null} [params.lng] 경도
|
|
263
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
264
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
265
|
+
* @param {string} [params.now_base_date] summary 현재 날씨 재현용 날짜
|
|
266
|
+
* @param {string} [params.now_base_time] summary 현재 날씨 재현용 시각
|
|
267
|
+
* @param {string} [params.forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
268
|
+
* @param {string} [params.forecast_base_time] summary 예보 날씨 재현용 시각
|
|
167
269
|
* @param {string} [params.locale] 요청 언어 코드
|
|
168
270
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
169
271
|
* @param {WeatherClientRouteOptions} [options] local route 옵션
|
|
@@ -212,6 +314,10 @@ export const getWeatherAlert = async <Data = API_Res_WeatherAlert>(
|
|
|
212
314
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
213
315
|
* @param {number|null} [params.lat] 위도
|
|
214
316
|
* @param {number|null} [params.lng] 경도
|
|
317
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
318
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
319
|
+
* @param {string} [params.base_date] 현재 날씨 재현용 날짜
|
|
320
|
+
* @param {string} [params.base_time] 현재 날씨 재현용 시각
|
|
215
321
|
* @param {string} [params.locale] 요청 언어 코드
|
|
216
322
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
217
323
|
* @param {WeatherClientQueryOptions} [options] query 옵션
|
|
@@ -231,7 +337,9 @@ export const useQueryWeatherNow = <Data = API_Res_WeatherNow>(
|
|
|
231
337
|
return useQuery({
|
|
232
338
|
queryKey: ["weather_now", routePath, ...getWeatherQueryKeyValues(params)],
|
|
233
339
|
queryFn: () => getWeatherNow<Data>(params, { routePath }),
|
|
234
|
-
enabled:
|
|
340
|
+
enabled:
|
|
341
|
+
(options.enabled ?? true) &&
|
|
342
|
+
(isWeatherLocationAvailable(params) || isWeatherGridAvailable(params)),
|
|
235
343
|
staleTime: 10 * 60 * 1000,
|
|
236
344
|
refetchInterval: 5 * 60 * 1000,
|
|
237
345
|
refetchOnWindowFocus: true,
|
|
@@ -245,6 +353,10 @@ export const useQueryWeatherNow = <Data = API_Res_WeatherNow>(
|
|
|
245
353
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
246
354
|
* @param {number|null} [params.lat] 위도
|
|
247
355
|
* @param {number|null} [params.lng] 경도
|
|
356
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
357
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
358
|
+
* @param {string} [params.base_date] 예보 날씨 재현용 날짜
|
|
359
|
+
* @param {string} [params.base_time] 예보 날씨 재현용 시각
|
|
248
360
|
* @param {string} [params.locale] 요청 언어 코드
|
|
249
361
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
250
362
|
* @param {WeatherClientQueryOptions} [options] query 옵션
|
|
@@ -268,7 +380,9 @@ export const useQueryWeatherForecast = <Data = API_Res_WeatherForecast>(
|
|
|
268
380
|
...getWeatherQueryKeyValues(params),
|
|
269
381
|
],
|
|
270
382
|
queryFn: () => getWeatherForecast<Data>(params, { routePath }),
|
|
271
|
-
enabled:
|
|
383
|
+
enabled:
|
|
384
|
+
(options.enabled ?? true) &&
|
|
385
|
+
(isWeatherLocationAvailable(params) || isWeatherGridAvailable(params)),
|
|
272
386
|
staleTime: 30 * 60 * 1000,
|
|
273
387
|
});
|
|
274
388
|
};
|
|
@@ -280,6 +394,12 @@ export const useQueryWeatherForecast = <Data = API_Res_WeatherForecast>(
|
|
|
280
394
|
* @param {number|string|null} [params.farm_idx] 농장 식별자
|
|
281
395
|
* @param {number|null} [params.lat] 위도
|
|
282
396
|
* @param {number|null} [params.lng] 경도
|
|
397
|
+
* @param {number|null} [params.nx] backend 격자 X
|
|
398
|
+
* @param {number|null} [params.ny] backend 격자 Y
|
|
399
|
+
* @param {string} [params.now_base_date] summary 현재 날씨 재현용 날짜
|
|
400
|
+
* @param {string} [params.now_base_time] summary 현재 날씨 재현용 시각
|
|
401
|
+
* @param {string} [params.forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
402
|
+
* @param {string} [params.forecast_base_time] summary 예보 날씨 재현용 시각
|
|
283
403
|
* @param {string} [params.locale] 요청 언어 코드
|
|
284
404
|
* @param {boolean} [params.include_raw] 원본 응답 포함 여부
|
|
285
405
|
* @param {WeatherClientQueryOptions} [options] query 옵션
|
|
@@ -303,7 +423,7 @@ export const useQueryWeatherSummary = <Data = API_Res_WeatherSummary>(
|
|
|
303
423
|
...getWeatherQueryKeyValues(params),
|
|
304
424
|
],
|
|
305
425
|
queryFn: () => getWeatherSummary<Data>(params, { routePath }),
|
|
306
|
-
enabled: (options.enabled ?? true) &&
|
|
426
|
+
enabled: (options.enabled ?? true) && isWeatherSummaryAvailable(params),
|
|
307
427
|
staleTime: 10 * 60 * 1000,
|
|
308
428
|
});
|
|
309
429
|
};
|
|
@@ -4,6 +4,7 @@ import { generateQueryUrl, nextAPILog } from "@uniai-fe/util-api";
|
|
|
4
4
|
import { setDebugResponseHeaders } from "@uniai-fe/util-next";
|
|
5
5
|
import type { NextResponse } from "next/server";
|
|
6
6
|
import WEATHER_RESPONSE from "../data/response";
|
|
7
|
+
import { convertWeatherCoordinateToGrid } from "../utils";
|
|
7
8
|
import type {
|
|
8
9
|
API_Res_WeatherAlert,
|
|
9
10
|
API_Res_WeatherForecast,
|
|
@@ -23,34 +24,136 @@ const QUERY_URL = {
|
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
const { API_RES_BASE } = WEATHER_RESPONSE;
|
|
26
|
-
|
|
27
|
+
type WeatherBackendRouteKind = keyof typeof QUERY_URL;
|
|
28
|
+
|
|
29
|
+
const BACKEND_COMMON_QUERY_KEYS = [
|
|
27
30
|
"farm_idx",
|
|
28
31
|
"lat",
|
|
29
32
|
"lng",
|
|
30
33
|
"locale",
|
|
31
34
|
"include_raw",
|
|
32
35
|
] as const;
|
|
36
|
+
const BACKEND_DATE_TIME_QUERY_PAIRS = {
|
|
37
|
+
now: [["base_date", "base_time"]],
|
|
38
|
+
forecast: [["base_date", "base_time"]],
|
|
39
|
+
summary: [
|
|
40
|
+
["now_base_date", "now_base_time"],
|
|
41
|
+
["forecast_base_date", "forecast_base_time"],
|
|
42
|
+
],
|
|
43
|
+
alert: [],
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Weather backend; query 값 존재 여부 확인.
|
|
48
|
+
* @param {string | null} value 확인할 query 값
|
|
49
|
+
* @return {boolean} query 값 존재 여부
|
|
50
|
+
*/
|
|
51
|
+
const hasSearchParamValue = (value: string | null): value is string =>
|
|
52
|
+
value !== null && value !== "";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Weather backend; query 값을 숫자로 변환한다.
|
|
56
|
+
* @param {URLSearchParams} searchParams service app local route query
|
|
57
|
+
* @param {string} key 숫자로 읽을 query key
|
|
58
|
+
* @return {number | null} 변환된 숫자 또는 null
|
|
59
|
+
*/
|
|
60
|
+
const getSearchParamNumber = (
|
|
61
|
+
searchParams: URLSearchParams,
|
|
62
|
+
key: string,
|
|
63
|
+
): number | null => {
|
|
64
|
+
const value = searchParams.get(key);
|
|
65
|
+
|
|
66
|
+
if (!hasSearchParamValue(value)) return null;
|
|
67
|
+
|
|
68
|
+
const parsed = Number(value);
|
|
69
|
+
|
|
70
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Weather backend; 재현용 date/time query를 pair 단위로 전달한다.
|
|
75
|
+
* @param {URLSearchParams} params backend 전달 query
|
|
76
|
+
* @param {URLSearchParams} searchParams service app local route query
|
|
77
|
+
* @param {WeatherBackendRouteKind} routeKind backend weather route 종류
|
|
78
|
+
*/
|
|
79
|
+
const setBackendDateTimeSearchParams = (
|
|
80
|
+
params: URLSearchParams,
|
|
81
|
+
searchParams: URLSearchParams,
|
|
82
|
+
routeKind: WeatherBackendRouteKind,
|
|
83
|
+
): void => {
|
|
84
|
+
BACKEND_DATE_TIME_QUERY_PAIRS[routeKind].forEach(([dateKey, timeKey]) => {
|
|
85
|
+
const date = searchParams.get(dateKey);
|
|
86
|
+
const time = searchParams.get(timeKey);
|
|
87
|
+
|
|
88
|
+
if (!hasSearchParamValue(date) || !hasSearchParamValue(time)) return;
|
|
89
|
+
|
|
90
|
+
params.set(dateKey, date);
|
|
91
|
+
params.set(timeKey, time);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Weather backend; nx/ny query를 직접 값 또는 lat/lng 변환값으로 전달한다.
|
|
97
|
+
* @param {URLSearchParams} params backend 전달 query
|
|
98
|
+
* @param {URLSearchParams} searchParams service app local route query
|
|
99
|
+
*/
|
|
100
|
+
const setBackendGridSearchParams = (
|
|
101
|
+
params: URLSearchParams,
|
|
102
|
+
searchParams: URLSearchParams,
|
|
103
|
+
): void => {
|
|
104
|
+
const providedNx = getSearchParamNumber(searchParams, "nx");
|
|
105
|
+
const providedNy = getSearchParamNumber(searchParams, "ny");
|
|
106
|
+
const { nx, ny } =
|
|
107
|
+
providedNx !== null && providedNy !== null
|
|
108
|
+
? { nx: providedNx, ny: providedNy }
|
|
109
|
+
: convertWeatherCoordinateToGrid({
|
|
110
|
+
lat: getSearchParamNumber(searchParams, "lat"),
|
|
111
|
+
lng: getSearchParamNumber(searchParams, "lng"),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
if (nx !== null && nx > 0) params.set("nx", String(nx));
|
|
115
|
+
if (ny !== null && ny > 0) params.set("ny", String(ny));
|
|
116
|
+
};
|
|
33
117
|
|
|
34
118
|
/**
|
|
35
119
|
* Weather backend; client route query를 backend 허용 query로 정리.
|
|
120
|
+
* @param {WeatherBackendRouteKind} routeKind backend weather route 종류
|
|
36
121
|
* @param {URLSearchParams} searchParams service app local route query
|
|
37
122
|
* @param {string} [searchParams.farm_idx] 농장 식별자
|
|
38
123
|
* @param {string} [searchParams.lat] 위도
|
|
39
124
|
* @param {string} [searchParams.lng] 경도
|
|
125
|
+
* @param {string} [searchParams.nx] backend 격자 X
|
|
126
|
+
* @param {string} [searchParams.ny] backend 격자 Y
|
|
127
|
+
* @param {string} [searchParams.base_date] 현재/예보 단일 route 재현용 날짜
|
|
128
|
+
* @param {string} [searchParams.base_time] 현재/예보 단일 route 재현용 시각
|
|
129
|
+
* @param {string} [searchParams.now_base_date] summary 현재 날씨 재현용 날짜
|
|
130
|
+
* @param {string} [searchParams.now_base_time] summary 현재 날씨 재현용 시각
|
|
131
|
+
* @param {string} [searchParams.forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
132
|
+
* @param {string} [searchParams.forecast_base_time] summary 예보 날씨 재현용 시각
|
|
40
133
|
* @param {string} [searchParams.locale] 요청 언어 코드
|
|
41
134
|
* @param {string} [searchParams.include_raw] 원본 응답 포함 여부
|
|
42
135
|
* @return {URLSearchParams} backend 전달 query
|
|
43
136
|
*/
|
|
44
137
|
const getBackendSearchParams = (
|
|
138
|
+
routeKind: WeatherBackendRouteKind,
|
|
45
139
|
searchParams: URLSearchParams,
|
|
46
140
|
): URLSearchParams => {
|
|
47
141
|
const params = new URLSearchParams();
|
|
48
142
|
|
|
49
|
-
|
|
143
|
+
if (routeKind === "alert") {
|
|
144
|
+
const farmIdx = searchParams.get("farm_idx");
|
|
145
|
+
if (hasSearchParamValue(farmIdx)) params.set("farm_idx", farmIdx);
|
|
146
|
+
return params;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
BACKEND_COMMON_QUERY_KEYS.forEach(key => {
|
|
50
150
|
const value = searchParams.get(key);
|
|
51
|
-
if (value
|
|
151
|
+
if (hasSearchParamValue(value)) params.set(key, value);
|
|
52
152
|
});
|
|
53
153
|
|
|
154
|
+
setBackendGridSearchParams(params, searchParams);
|
|
155
|
+
setBackendDateTimeSearchParams(params, searchParams, routeKind);
|
|
156
|
+
|
|
54
157
|
return params;
|
|
55
158
|
};
|
|
56
159
|
|
|
@@ -124,6 +227,7 @@ const getWeatherSummaryDefault = (): API_Res_WeatherSummary => ({
|
|
|
124
227
|
* @param {string} params.domain backend API domain
|
|
125
228
|
* @param {string} params.routeUrl local route URL
|
|
126
229
|
* @param {string} params.queryUrl backend weather route URL
|
|
230
|
+
* @param {"now"|"forecast"|"summary"|"alert"} params.routeKind backend weather route 종류
|
|
127
231
|
* @param {URLSearchParams} params.searchParams client route query
|
|
128
232
|
* @param {Data} params.fallback 실패 시 반환할 fallback 응답
|
|
129
233
|
* @return {Promise<Data>} backend 응답 또는 fallback
|
|
@@ -132,6 +236,7 @@ const fetchWeatherBackend = async <Data>({
|
|
|
132
236
|
domain,
|
|
133
237
|
routeUrl,
|
|
134
238
|
queryUrl,
|
|
239
|
+
routeKind,
|
|
135
240
|
searchParams,
|
|
136
241
|
fallback,
|
|
137
242
|
}: WeatherBackendFetchParams<Data>): Promise<Data> => {
|
|
@@ -142,7 +247,7 @@ const fetchWeatherBackend = async <Data>({
|
|
|
142
247
|
return fallback;
|
|
143
248
|
}
|
|
144
249
|
|
|
145
|
-
const backendParams = getBackendSearchParams(searchParams);
|
|
250
|
+
const backendParams = getBackendSearchParams(routeKind, searchParams);
|
|
146
251
|
const url = generateQueryUrl({
|
|
147
252
|
domain,
|
|
148
253
|
routeUrl,
|
|
@@ -185,6 +290,7 @@ export const routeWeatherNow = async ({
|
|
|
185
290
|
domain,
|
|
186
291
|
routeUrl,
|
|
187
292
|
queryUrl: QUERY_URL.now,
|
|
293
|
+
routeKind: "now",
|
|
188
294
|
searchParams,
|
|
189
295
|
fallback: getWeatherNowDefault(),
|
|
190
296
|
});
|
|
@@ -207,6 +313,7 @@ export const routeWeatherForecast = async ({
|
|
|
207
313
|
domain,
|
|
208
314
|
routeUrl,
|
|
209
315
|
queryUrl: QUERY_URL.forecast,
|
|
316
|
+
routeKind: "forecast",
|
|
210
317
|
searchParams,
|
|
211
318
|
fallback: getWeatherForecastDefault(),
|
|
212
319
|
});
|
|
@@ -229,6 +336,7 @@ export const routeWeatherSummary = async ({
|
|
|
229
336
|
domain,
|
|
230
337
|
routeUrl,
|
|
231
338
|
queryUrl: QUERY_URL.summary,
|
|
339
|
+
routeKind: "summary",
|
|
232
340
|
searchParams,
|
|
233
341
|
fallback: getWeatherSummaryDefault(),
|
|
234
342
|
});
|
|
@@ -251,6 +359,7 @@ export const routeWeatherAlert = async ({
|
|
|
251
359
|
domain,
|
|
252
360
|
routeUrl,
|
|
253
361
|
queryUrl: QUERY_URL.alert,
|
|
362
|
+
routeKind: "alert",
|
|
254
363
|
searchParams,
|
|
255
364
|
fallback: getWeatherAlertDefault(),
|
|
256
365
|
});
|
|
@@ -259,6 +368,6 @@ export const routeWeatherAlert = async ({
|
|
|
259
368
|
res,
|
|
260
369
|
domain,
|
|
261
370
|
queryUrl: QUERY_URL.alert,
|
|
262
|
-
searchParams: getBackendSearchParams(searchParams),
|
|
371
|
+
searchParams: getBackendSearchParams("alert", searchParams),
|
|
263
372
|
});
|
|
264
373
|
};
|
package/src/weather/types/api.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
WeatherApiLocaleOptions,
|
|
3
3
|
WeatherCoordinate,
|
|
4
4
|
WeatherGeoCoordinate,
|
|
5
|
+
WeatherGridCoordinate,
|
|
5
6
|
} from "./base";
|
|
6
7
|
|
|
7
8
|
/**
|
|
@@ -9,11 +10,22 @@ import type {
|
|
|
9
10
|
* @property {number|string|null} [farm_idx] 농장 식별자
|
|
10
11
|
* @property {number|null} [lat] 위도
|
|
11
12
|
* @property {number|null} [lng] 경도
|
|
13
|
+
* @property {number|null} [nx] backend 격자 X
|
|
14
|
+
* @property {number|null} [ny] backend 격자 Y
|
|
15
|
+
* @property {string} [base_date] 현재/예보 단일 route 재현용 날짜
|
|
16
|
+
* @property {string} [base_time] 현재/예보 단일 route 재현용 시각
|
|
17
|
+
* @property {string} [now_base_date] summary 현재 날씨 재현용 날짜
|
|
18
|
+
* @property {string} [now_base_time] summary 현재 날씨 재현용 시각
|
|
19
|
+
* @property {string} [forecast_base_date] summary 예보 날씨 재현용 날짜
|
|
20
|
+
* @property {string} [forecast_base_time] summary 예보 날씨 재현용 시각
|
|
12
21
|
* @property {string} [locale] 요청 언어 코드
|
|
13
22
|
* @property {boolean} [include_raw] 원본 응답 포함 여부
|
|
14
23
|
*/
|
|
15
24
|
export interface API_Req_Weather
|
|
16
|
-
extends
|
|
25
|
+
extends
|
|
26
|
+
WeatherApiLocaleOptions,
|
|
27
|
+
Partial<WeatherGeoCoordinate>,
|
|
28
|
+
Partial<WeatherGridCoordinate> {
|
|
17
29
|
/**
|
|
18
30
|
* 농장 식별자
|
|
19
31
|
*/
|
|
@@ -26,6 +38,38 @@ export interface API_Req_Weather
|
|
|
26
38
|
* 경도
|
|
27
39
|
*/
|
|
28
40
|
lng?: number | null;
|
|
41
|
+
/**
|
|
42
|
+
* backend 격자 X
|
|
43
|
+
*/
|
|
44
|
+
nx?: number | null;
|
|
45
|
+
/**
|
|
46
|
+
* backend 격자 Y
|
|
47
|
+
*/
|
|
48
|
+
ny?: number | null;
|
|
49
|
+
/**
|
|
50
|
+
* 현재/예보 단일 route 재현용 날짜
|
|
51
|
+
*/
|
|
52
|
+
base_date?: string;
|
|
53
|
+
/**
|
|
54
|
+
* 현재/예보 단일 route 재현용 시각
|
|
55
|
+
*/
|
|
56
|
+
base_time?: string;
|
|
57
|
+
/**
|
|
58
|
+
* summary 현재 날씨 재현용 날짜
|
|
59
|
+
*/
|
|
60
|
+
now_base_date?: string;
|
|
61
|
+
/**
|
|
62
|
+
* summary 현재 날씨 재현용 시각
|
|
63
|
+
*/
|
|
64
|
+
now_base_time?: string;
|
|
65
|
+
/**
|
|
66
|
+
* summary 예보 날씨 재현용 날짜
|
|
67
|
+
*/
|
|
68
|
+
forecast_base_date?: string;
|
|
69
|
+
/**
|
|
70
|
+
* summary 예보 날씨 재현용 시각
|
|
71
|
+
*/
|
|
72
|
+
forecast_base_time?: string;
|
|
29
73
|
/**
|
|
30
74
|
* 요청 언어 코드
|
|
31
75
|
*/
|
|
@@ -128,6 +172,7 @@ export interface WeatherServerRouteParams {
|
|
|
128
172
|
* @property {string} routeUrl service app local route URL
|
|
129
173
|
* @property {URLSearchParams} searchParams service app local route query
|
|
130
174
|
* @property {string} queryUrl backend weather route URL
|
|
175
|
+
* @property {"now"|"forecast"|"summary"|"alert"} routeKind backend weather route 종류
|
|
131
176
|
* @property {Data} fallback 실패 시 반환할 fallback 응답
|
|
132
177
|
*/
|
|
133
178
|
export type WeatherBackendFetchParams<Data> = WeatherServerRouteParams & {
|
|
@@ -135,6 +180,10 @@ export type WeatherBackendFetchParams<Data> = WeatherServerRouteParams & {
|
|
|
135
180
|
* backend weather route URL
|
|
136
181
|
*/
|
|
137
182
|
queryUrl: string;
|
|
183
|
+
/**
|
|
184
|
+
* backend weather route 종류
|
|
185
|
+
*/
|
|
186
|
+
routeKind: "now" | "forecast" | "summary" | "alert";
|
|
138
187
|
/**
|
|
139
188
|
* 실패 시 반환할 fallback 응답
|
|
140
189
|
*/
|
|
@@ -30,6 +30,22 @@ export type WeatherGeoCoordinate = {
|
|
|
30
30
|
lng: number | null;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 날씨 backend에서 사용하는 격자 좌표.
|
|
35
|
+
* @property {number | null} nx 격자 X
|
|
36
|
+
* @property {number | null} ny 격자 Y
|
|
37
|
+
*/
|
|
38
|
+
export type WeatherGridCoordinate = {
|
|
39
|
+
/**
|
|
40
|
+
* 격자 X
|
|
41
|
+
*/
|
|
42
|
+
nx: number | null;
|
|
43
|
+
/**
|
|
44
|
+
* 격자 Y
|
|
45
|
+
*/
|
|
46
|
+
ny: number | null;
|
|
47
|
+
};
|
|
48
|
+
|
|
33
49
|
/**
|
|
34
50
|
* 날씨 API locale 요청 옵션.
|
|
35
51
|
* @property {string} [locale] 요청 언어 코드. 기본 지원값은 ko, en, ja이며 타입은 확장 가능한 string으로 유지한다.
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
GeoCoordinate,
|
|
3
|
+
WeatherGeoCoordinate,
|
|
4
|
+
WeatherGridCoordinate,
|
|
5
|
+
} from "../types";
|
|
2
6
|
|
|
3
7
|
/**
|
|
4
8
|
* 날씨 도구; 접속위치 추출.
|
|
@@ -27,3 +31,79 @@ export const userLocation = async (): Promise<GeoCoordinate> => {
|
|
|
27
31
|
return { latitude: null, longitude: null };
|
|
28
32
|
}
|
|
29
33
|
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 날씨 도구; 위경도 좌표를 backend weather 격자 좌표로 변환한다.
|
|
37
|
+
* @util
|
|
38
|
+
* @param {GeoCoordinate} coordinate 위경도 좌표
|
|
39
|
+
* @param {number|null} coordinate.latitude 위도
|
|
40
|
+
* @param {number|null} coordinate.longitude 경도
|
|
41
|
+
* @return {WeatherGridCoordinate} backend weather 격자 좌표
|
|
42
|
+
*/
|
|
43
|
+
export const convertCoordinateToGrid = ({
|
|
44
|
+
latitude,
|
|
45
|
+
longitude,
|
|
46
|
+
}: GeoCoordinate): WeatherGridCoordinate => {
|
|
47
|
+
const fallback: WeatherGridCoordinate = { nx: null, ny: null };
|
|
48
|
+
|
|
49
|
+
if (latitude === null || longitude === null) return fallback;
|
|
50
|
+
|
|
51
|
+
const RE = 6371.00877;
|
|
52
|
+
const GRID = 5.0;
|
|
53
|
+
const SLAT1 = 30.0;
|
|
54
|
+
const SLAT2 = 60.0;
|
|
55
|
+
const ORIGIN_LONGITUDE = 126.0;
|
|
56
|
+
const ORIGIN_LATITUDE = 38.0;
|
|
57
|
+
const XO = 43;
|
|
58
|
+
const YO = 136;
|
|
59
|
+
|
|
60
|
+
const RADIAN = Math.PI / 180.0;
|
|
61
|
+
const re = RE / GRID;
|
|
62
|
+
const slat1 = SLAT1 * RADIAN;
|
|
63
|
+
const slat2 = SLAT2 * RADIAN;
|
|
64
|
+
const originLongitude = ORIGIN_LONGITUDE * RADIAN;
|
|
65
|
+
const originLatitude = ORIGIN_LATITUDE * RADIAN;
|
|
66
|
+
|
|
67
|
+
const sn =
|
|
68
|
+
Math.log(Math.cos(slat1) / Math.cos(slat2)) /
|
|
69
|
+
Math.log(
|
|
70
|
+
Math.tan(Math.PI * 0.25 + slat2 * 0.5) /
|
|
71
|
+
Math.tan(Math.PI * 0.25 + slat1 * 0.5),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const sf = Math.tan(Math.PI * 0.25 + slat1 * 0.5);
|
|
75
|
+
const sfPow = (Math.pow(sf, sn) * Math.cos(slat1)) / sn;
|
|
76
|
+
const ro =
|
|
77
|
+
(re * sfPow) /
|
|
78
|
+
Math.pow(Math.tan(Math.PI * 0.25 + originLatitude * 0.5), sn);
|
|
79
|
+
const ra =
|
|
80
|
+
(re * sfPow) /
|
|
81
|
+
Math.pow(Math.tan(Math.PI * 0.25 + latitude * RADIAN * 0.5), sn);
|
|
82
|
+
let theta = longitude * RADIAN - originLongitude;
|
|
83
|
+
|
|
84
|
+
if (theta > Math.PI) theta -= 2.0 * Math.PI;
|
|
85
|
+
if (theta < -Math.PI) theta += 2.0 * Math.PI;
|
|
86
|
+
theta *= sn;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
nx: Math.floor(ra * Math.sin(theta) + XO + 0.5),
|
|
90
|
+
ny: Math.floor(ro - ra * Math.cos(theta) + YO + 0.5),
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 날씨 도구; weather 위경도 좌표를 backend weather 격자 좌표로 변환한다.
|
|
96
|
+
* @util
|
|
97
|
+
* @param {Partial<WeatherGeoCoordinate>} coordinate weather 위경도 좌표
|
|
98
|
+
* @param {number|null} [coordinate.lat] 위도
|
|
99
|
+
* @param {number|null} [coordinate.lng] 경도
|
|
100
|
+
* @return {WeatherGridCoordinate} backend weather 격자 좌표
|
|
101
|
+
*/
|
|
102
|
+
export const convertWeatherCoordinateToGrid = ({
|
|
103
|
+
lat,
|
|
104
|
+
lng,
|
|
105
|
+
}: Partial<WeatherGeoCoordinate>): WeatherGridCoordinate =>
|
|
106
|
+
convertCoordinateToGrid({
|
|
107
|
+
latitude: lat ?? null,
|
|
108
|
+
longitude: lng ?? null,
|
|
109
|
+
});
|