diff --git a/.env.development b/.env.development index f480fa97..4f1cc1eb 100644 --- a/.env.development +++ b/.env.development @@ -6,8 +6,9 @@ # 배포 경로 VITE_BASE_URL=/ -# API 서버 (SNP-Batch API) -VITE_API_URL=http://211.208.115.83:8041/snp-api +# API 서버 — 로컬 개발은 Vite 프록시 사용 (/snp-api → 211.208.115.83:8041) +# 빈 값으로 설정하여 .env의 절대 URL을 override → aisTargetApi 기본값 /snp-api 사용 +VITE_API_URL= # 선박 데이터 쓰로틀링 (ms, 0=무제한) VITE_SHIP_THROTTLE=0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 681c3ced..6ab80d54 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -1,6 +1,6 @@ #!/bin/bash #============================================================================== -# pre-commit hook (React JavaScript) +# pre-commit hook (React TypeScript) # ESLint 검증 — 실패 시 커밋 차단 #============================================================================== @@ -19,7 +19,7 @@ fi # ESLint 검증 (설정 파일이 있는 경우만) if [ -f ".eslintrc.js" ] || [ -f ".eslintrc.json" ] || [ -f ".eslintrc.cjs" ] || [ -f "eslint.config.js" ] || [ -f "eslint.config.mjs" ]; then echo "pre-commit: ESLint 검증 중..." - npx eslint src/ --ext .js,.jsx --quiet 2>&1 + npx eslint src/ --quiet 2>&1 LINT_RESULT=$? if [ $LINT_RESULT -ne 0 ]; then diff --git a/.gitignore b/.gitignore index ba1152d6..60710fe8 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,5 @@ Desktop.ini .claude/settings.local.json .claude/scripts/ -# TypeScript files (메인 프로젝트 참조용, 빌드/커밋 제외) -**/*.ts -**/*.tsx -# tracking VesselListManager (참조용) -src/tracking/components/VesselListManager/ +# TypeScript config (vite.config.ts 등은 추적) +# tsconfig*.json은 추적 diff --git a/.node-version b/.node-version index a45fd52c..209e3ef4 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -24 +20 diff --git a/.nvmrc b/.nvmrc deleted file mode 100644 index 42a1c98a..00000000 --- a/.nvmrc +++ /dev/null @@ -1 +0,0 @@ -v22.22.0 diff --git a/.yarn-offline-cache/@deck.gl-mapbox-9.2.7.tgz b/.yarn-offline-cache/@deck.gl-mapbox-9.2.7.tgz new file mode 100644 index 00000000..15543b1c Binary files /dev/null and b/.yarn-offline-cache/@deck.gl-mapbox-9.2.7.tgz differ diff --git a/.yarn-offline-cache/@esbuild-aix-ppc64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-aix-ppc64-0.21.5.tgz deleted file mode 100644 index 1d84abbe..00000000 Binary files a/.yarn-offline-cache/@esbuild-aix-ppc64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-aix-ppc64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-aix-ppc64-0.27.3.tgz new file mode 100644 index 00000000..8dcadd8f Binary files /dev/null and b/.yarn-offline-cache/@esbuild-aix-ppc64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-android-arm-0.21.5.tgz b/.yarn-offline-cache/@esbuild-android-arm-0.21.5.tgz deleted file mode 100644 index 5b527d23..00000000 Binary files a/.yarn-offline-cache/@esbuild-android-arm-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-android-arm-0.27.3.tgz b/.yarn-offline-cache/@esbuild-android-arm-0.27.3.tgz new file mode 100644 index 00000000..852f32a5 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-android-arm-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-android-arm64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-android-arm64-0.21.5.tgz deleted file mode 100644 index 8eea3773..00000000 Binary files a/.yarn-offline-cache/@esbuild-android-arm64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-android-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-android-arm64-0.27.3.tgz new file mode 100644 index 00000000..7767bb58 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-android-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-android-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-android-x64-0.21.5.tgz deleted file mode 100644 index 4747fdb5..00000000 Binary files a/.yarn-offline-cache/@esbuild-android-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-android-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-android-x64-0.27.3.tgz new file mode 100644 index 00000000..0d656c8b Binary files /dev/null and b/.yarn-offline-cache/@esbuild-android-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-darwin-arm64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-darwin-arm64-0.21.5.tgz deleted file mode 100644 index 392c4ed9..00000000 Binary files a/.yarn-offline-cache/@esbuild-darwin-arm64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-darwin-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-darwin-arm64-0.27.3.tgz new file mode 100644 index 00000000..4f24dba0 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-darwin-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-darwin-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-darwin-x64-0.21.5.tgz deleted file mode 100644 index cbbd309e..00000000 Binary files a/.yarn-offline-cache/@esbuild-darwin-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-darwin-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-darwin-x64-0.27.3.tgz new file mode 100644 index 00000000..baa66211 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-darwin-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-freebsd-arm64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-freebsd-arm64-0.21.5.tgz deleted file mode 100644 index 368c7c01..00000000 Binary files a/.yarn-offline-cache/@esbuild-freebsd-arm64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-freebsd-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-freebsd-arm64-0.27.3.tgz new file mode 100644 index 00000000..1ca19a49 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-freebsd-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-freebsd-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-freebsd-x64-0.21.5.tgz deleted file mode 100644 index ac5c9bf1..00000000 Binary files a/.yarn-offline-cache/@esbuild-freebsd-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-freebsd-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-freebsd-x64-0.27.3.tgz new file mode 100644 index 00000000..e0c521a1 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-freebsd-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-arm-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-arm-0.21.5.tgz deleted file mode 100644 index c70b080f..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-arm-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-arm-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-arm-0.27.3.tgz new file mode 100644 index 00000000..f18fca2c Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-arm-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-arm64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-arm64-0.21.5.tgz deleted file mode 100644 index e66e2295..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-arm64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-arm64-0.27.3.tgz new file mode 100644 index 00000000..7340580b Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-ia32-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-ia32-0.21.5.tgz deleted file mode 100644 index 8c30fce6..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-ia32-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-ia32-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-ia32-0.27.3.tgz new file mode 100644 index 00000000..f82f3b53 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-ia32-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-loong64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-loong64-0.21.5.tgz deleted file mode 100644 index 263fe79d..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-loong64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-loong64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-loong64-0.27.3.tgz new file mode 100644 index 00000000..5bb5984e Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-loong64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-mips64el-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-mips64el-0.21.5.tgz deleted file mode 100644 index 406992ff..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-mips64el-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-mips64el-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-mips64el-0.27.3.tgz new file mode 100644 index 00000000..ccefe81d Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-mips64el-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-ppc64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-ppc64-0.21.5.tgz deleted file mode 100644 index 93a991b5..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-ppc64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-ppc64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-ppc64-0.27.3.tgz new file mode 100644 index 00000000..9571193a Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-ppc64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-riscv64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-riscv64-0.21.5.tgz deleted file mode 100644 index 55b7ddb3..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-riscv64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-riscv64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-riscv64-0.27.3.tgz new file mode 100644 index 00000000..12ee17b2 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-riscv64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-s390x-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-s390x-0.21.5.tgz deleted file mode 100644 index bd55a804..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-s390x-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-s390x-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-s390x-0.27.3.tgz new file mode 100644 index 00000000..eca17977 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-s390x-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-linux-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-linux-x64-0.21.5.tgz deleted file mode 100644 index 7b2f3d19..00000000 Binary files a/.yarn-offline-cache/@esbuild-linux-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-linux-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-linux-x64-0.27.3.tgz new file mode 100644 index 00000000..a5617fc3 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-linux-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-netbsd-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-netbsd-arm64-0.27.3.tgz new file mode 100644 index 00000000..5ae9d286 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-netbsd-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-netbsd-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-netbsd-x64-0.21.5.tgz deleted file mode 100644 index b9a3dfcb..00000000 Binary files a/.yarn-offline-cache/@esbuild-netbsd-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-netbsd-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-netbsd-x64-0.27.3.tgz new file mode 100644 index 00000000..75939fcf Binary files /dev/null and b/.yarn-offline-cache/@esbuild-netbsd-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-openbsd-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-openbsd-arm64-0.27.3.tgz new file mode 100644 index 00000000..7d550404 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-openbsd-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-openbsd-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-openbsd-x64-0.21.5.tgz deleted file mode 100644 index 34cd39fc..00000000 Binary files a/.yarn-offline-cache/@esbuild-openbsd-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-openbsd-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-openbsd-x64-0.27.3.tgz new file mode 100644 index 00000000..2d1bac03 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-openbsd-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-openharmony-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-openharmony-arm64-0.27.3.tgz new file mode 100644 index 00000000..46872059 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-openharmony-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-sunos-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-sunos-x64-0.21.5.tgz deleted file mode 100644 index b5f11f39..00000000 Binary files a/.yarn-offline-cache/@esbuild-sunos-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-sunos-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-sunos-x64-0.27.3.tgz new file mode 100644 index 00000000..12357bc4 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-sunos-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-win32-arm64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-win32-arm64-0.21.5.tgz deleted file mode 100644 index f8167b1a..00000000 Binary files a/.yarn-offline-cache/@esbuild-win32-arm64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-win32-arm64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-win32-arm64-0.27.3.tgz new file mode 100644 index 00000000..996ad66b Binary files /dev/null and b/.yarn-offline-cache/@esbuild-win32-arm64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-win32-ia32-0.21.5.tgz b/.yarn-offline-cache/@esbuild-win32-ia32-0.21.5.tgz deleted file mode 100644 index ae59cafb..00000000 Binary files a/.yarn-offline-cache/@esbuild-win32-ia32-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-win32-ia32-0.27.3.tgz b/.yarn-offline-cache/@esbuild-win32-ia32-0.27.3.tgz new file mode 100644 index 00000000..41812924 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-win32-ia32-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@esbuild-win32-x64-0.21.5.tgz b/.yarn-offline-cache/@esbuild-win32-x64-0.21.5.tgz deleted file mode 100644 index 295b3d2d..00000000 Binary files a/.yarn-offline-cache/@esbuild-win32-x64-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@esbuild-win32-x64-0.27.3.tgz b/.yarn-offline-cache/@esbuild-win32-x64-0.27.3.tgz new file mode 100644 index 00000000..38c6ae45 Binary files /dev/null and b/.yarn-offline-cache/@esbuild-win32-x64-0.27.3.tgz differ diff --git a/.yarn-offline-cache/@eslint-community-eslint-utils-4.9.1.tgz b/.yarn-offline-cache/@eslint-community-eslint-utils-4.9.1.tgz deleted file mode 100644 index e2c9ef05..00000000 Binary files a/.yarn-offline-cache/@eslint-community-eslint-utils-4.9.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@eslint-community-regexpp-4.12.2.tgz b/.yarn-offline-cache/@eslint-community-regexpp-4.12.2.tgz deleted file mode 100644 index e1f22ae5..00000000 Binary files a/.yarn-offline-cache/@eslint-community-regexpp-4.12.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@eslint-config-array-0.21.1.tgz b/.yarn-offline-cache/@eslint-config-array-0.21.1.tgz new file mode 100644 index 00000000..930a799f Binary files /dev/null and b/.yarn-offline-cache/@eslint-config-array-0.21.1.tgz differ diff --git a/.yarn-offline-cache/@eslint-config-helpers-0.4.2.tgz b/.yarn-offline-cache/@eslint-config-helpers-0.4.2.tgz new file mode 100644 index 00000000..e0a5d715 Binary files /dev/null and b/.yarn-offline-cache/@eslint-config-helpers-0.4.2.tgz differ diff --git a/.yarn-offline-cache/@eslint-core-0.17.0.tgz b/.yarn-offline-cache/@eslint-core-0.17.0.tgz new file mode 100644 index 00000000..b2c09c0a Binary files /dev/null and b/.yarn-offline-cache/@eslint-core-0.17.0.tgz differ diff --git a/.yarn-offline-cache/@eslint-eslintrc-2.1.4.tgz b/.yarn-offline-cache/@eslint-eslintrc-2.1.4.tgz deleted file mode 100644 index cedbe9d8..00000000 Binary files a/.yarn-offline-cache/@eslint-eslintrc-2.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@eslint-eslintrc-3.3.3.tgz b/.yarn-offline-cache/@eslint-eslintrc-3.3.3.tgz new file mode 100644 index 00000000..fd9203e8 Binary files /dev/null and b/.yarn-offline-cache/@eslint-eslintrc-3.3.3.tgz differ diff --git a/.yarn-offline-cache/@eslint-js-8.57.1.tgz b/.yarn-offline-cache/@eslint-js-8.57.1.tgz deleted file mode 100644 index b62d617b..00000000 Binary files a/.yarn-offline-cache/@eslint-js-8.57.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@eslint-js-9.39.2.tgz b/.yarn-offline-cache/@eslint-js-9.39.2.tgz new file mode 100644 index 00000000..8c0070b9 Binary files /dev/null and b/.yarn-offline-cache/@eslint-js-9.39.2.tgz differ diff --git a/.yarn-offline-cache/@eslint-object-schema-2.1.7.tgz b/.yarn-offline-cache/@eslint-object-schema-2.1.7.tgz new file mode 100644 index 00000000..6b4047da Binary files /dev/null and b/.yarn-offline-cache/@eslint-object-schema-2.1.7.tgz differ diff --git a/.yarn-offline-cache/@eslint-plugin-kit-0.4.1.tgz b/.yarn-offline-cache/@eslint-plugin-kit-0.4.1.tgz new file mode 100644 index 00000000..5178bf8b Binary files /dev/null and b/.yarn-offline-cache/@eslint-plugin-kit-0.4.1.tgz differ diff --git a/.yarn-offline-cache/@humanfs-core-0.19.1.tgz b/.yarn-offline-cache/@humanfs-core-0.19.1.tgz new file mode 100644 index 00000000..fe57f6ec Binary files /dev/null and b/.yarn-offline-cache/@humanfs-core-0.19.1.tgz differ diff --git a/.yarn-offline-cache/@humanfs-node-0.16.7.tgz b/.yarn-offline-cache/@humanfs-node-0.16.7.tgz new file mode 100644 index 00000000..f8aa5a50 Binary files /dev/null and b/.yarn-offline-cache/@humanfs-node-0.16.7.tgz differ diff --git a/.yarn-offline-cache/@humanwhocodes-config-array-0.13.0.tgz b/.yarn-offline-cache/@humanwhocodes-config-array-0.13.0.tgz deleted file mode 100644 index d41aa6e8..00000000 Binary files a/.yarn-offline-cache/@humanwhocodes-config-array-0.13.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@humanwhocodes-module-importer-1.0.1.tgz b/.yarn-offline-cache/@humanwhocodes-module-importer-1.0.1.tgz deleted file mode 100644 index b6253899..00000000 Binary files a/.yarn-offline-cache/@humanwhocodes-module-importer-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@humanwhocodes-object-schema-2.0.3.tgz b/.yarn-offline-cache/@humanwhocodes-object-schema-2.0.3.tgz deleted file mode 100644 index 3f05d120..00000000 Binary files a/.yarn-offline-cache/@humanwhocodes-object-schema-2.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@humanwhocodes-retry-0.4.3.tgz b/.yarn-offline-cache/@humanwhocodes-retry-0.4.3.tgz new file mode 100644 index 00000000..edff35dc Binary files /dev/null and b/.yarn-offline-cache/@humanwhocodes-retry-0.4.3.tgz differ diff --git a/.yarn-offline-cache/@mapbox-geojson-rewind-0.5.2.tgz b/.yarn-offline-cache/@mapbox-geojson-rewind-0.5.2.tgz new file mode 100644 index 00000000..1ebaa712 Binary files /dev/null and b/.yarn-offline-cache/@mapbox-geojson-rewind-0.5.2.tgz differ diff --git a/.yarn-offline-cache/@mapbox-jsonlint-lines-primitives-2.0.2.tgz b/.yarn-offline-cache/@mapbox-jsonlint-lines-primitives-2.0.2.tgz new file mode 100644 index 00000000..ac6c0ccd Binary files /dev/null and b/.yarn-offline-cache/@mapbox-jsonlint-lines-primitives-2.0.2.tgz differ diff --git a/.yarn-offline-cache/@mapbox-point-geometry-1.1.0.tgz b/.yarn-offline-cache/@mapbox-point-geometry-1.1.0.tgz new file mode 100644 index 00000000..d8138194 Binary files /dev/null and b/.yarn-offline-cache/@mapbox-point-geometry-1.1.0.tgz differ diff --git a/.yarn-offline-cache/@mapbox-unitbezier-0.0.1.tgz b/.yarn-offline-cache/@mapbox-unitbezier-0.0.1.tgz new file mode 100644 index 00000000..c0bb66ad Binary files /dev/null and b/.yarn-offline-cache/@mapbox-unitbezier-0.0.1.tgz differ diff --git a/.yarn-offline-cache/@mapbox-vector-tile-2.0.4.tgz b/.yarn-offline-cache/@mapbox-vector-tile-2.0.4.tgz new file mode 100644 index 00000000..cf8f349e Binary files /dev/null and b/.yarn-offline-cache/@mapbox-vector-tile-2.0.4.tgz differ diff --git a/.yarn-offline-cache/@mapbox-whoots-js-3.1.0.tgz b/.yarn-offline-cache/@mapbox-whoots-js-3.1.0.tgz new file mode 100644 index 00000000..da91d004 Binary files /dev/null and b/.yarn-offline-cache/@mapbox-whoots-js-3.1.0.tgz differ diff --git a/.yarn-offline-cache/@maplibre-geojson-vt-5.0.4.tgz b/.yarn-offline-cache/@maplibre-geojson-vt-5.0.4.tgz new file mode 100644 index 00000000..89eee8ca Binary files /dev/null and b/.yarn-offline-cache/@maplibre-geojson-vt-5.0.4.tgz differ diff --git a/.yarn-offline-cache/@maplibre-maplibre-gl-style-spec-24.4.1.tgz b/.yarn-offline-cache/@maplibre-maplibre-gl-style-spec-24.4.1.tgz new file mode 100644 index 00000000..114bd3ee Binary files /dev/null and b/.yarn-offline-cache/@maplibre-maplibre-gl-style-spec-24.4.1.tgz differ diff --git a/.yarn-offline-cache/@maplibre-mlt-1.1.6.tgz b/.yarn-offline-cache/@maplibre-mlt-1.1.6.tgz new file mode 100644 index 00000000..4ad84959 Binary files /dev/null and b/.yarn-offline-cache/@maplibre-mlt-1.1.6.tgz differ diff --git a/.yarn-offline-cache/@maplibre-vt-pbf-4.2.1.tgz b/.yarn-offline-cache/@maplibre-vt-pbf-4.2.1.tgz new file mode 100644 index 00000000..dee44804 Binary files /dev/null and b/.yarn-offline-cache/@maplibre-vt-pbf-4.2.1.tgz differ diff --git a/.yarn-offline-cache/@nodelib-fs.scandir-2.1.5.tgz b/.yarn-offline-cache/@nodelib-fs.scandir-2.1.5.tgz deleted file mode 100644 index eebed629..00000000 Binary files a/.yarn-offline-cache/@nodelib-fs.scandir-2.1.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@nodelib-fs.stat-2.0.5.tgz b/.yarn-offline-cache/@nodelib-fs.stat-2.0.5.tgz deleted file mode 100644 index 01d85fd2..00000000 Binary files a/.yarn-offline-cache/@nodelib-fs.stat-2.0.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@nodelib-fs.walk-1.2.8.tgz b/.yarn-offline-cache/@nodelib-fs.walk-1.2.8.tgz deleted file mode 100644 index c08a8dc0..00000000 Binary files a/.yarn-offline-cache/@nodelib-fs.walk-1.2.8.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz b/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz deleted file mode 100644 index ddfbcbaa..00000000 Binary files a/.yarn-offline-cache/@petamoriken-float16-3.9.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@repeaterjs-repeater-3.0.6.tgz b/.yarn-offline-cache/@repeaterjs-repeater-3.0.6.tgz deleted file mode 100644 index 59ffd273..00000000 Binary files a/.yarn-offline-cache/@repeaterjs-repeater-3.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-beta.27.tgz b/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-beta.27.tgz deleted file mode 100644 index 48c3491d..00000000 Binary files a/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-beta.27.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-rc.3.tgz b/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-rc.3.tgz new file mode 100644 index 00000000..9ba233bc Binary files /dev/null and b/.yarn-offline-cache/@rolldown-pluginutils-1.0.0-rc.3.tgz differ diff --git a/.yarn-offline-cache/@turf-along-7.3.4.tgz b/.yarn-offline-cache/@turf-along-7.3.4.tgz new file mode 100644 index 00000000..3584ca93 Binary files /dev/null and b/.yarn-offline-cache/@turf-along-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-angle-7.3.4.tgz b/.yarn-offline-cache/@turf-angle-7.3.4.tgz new file mode 100644 index 00000000..98540c86 Binary files /dev/null and b/.yarn-offline-cache/@turf-angle-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-area-7.3.4.tgz b/.yarn-offline-cache/@turf-area-7.3.4.tgz new file mode 100644 index 00000000..e4bda4c9 Binary files /dev/null and b/.yarn-offline-cache/@turf-area-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-bbox-7.3.4.tgz b/.yarn-offline-cache/@turf-bbox-7.3.4.tgz new file mode 100644 index 00000000..b7c8262a Binary files /dev/null and b/.yarn-offline-cache/@turf-bbox-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-bbox-clip-7.3.4.tgz b/.yarn-offline-cache/@turf-bbox-clip-7.3.4.tgz new file mode 100644 index 00000000..be6cd98f Binary files /dev/null and b/.yarn-offline-cache/@turf-bbox-clip-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-bbox-polygon-7.3.4.tgz b/.yarn-offline-cache/@turf-bbox-polygon-7.3.4.tgz new file mode 100644 index 00000000..02723e76 Binary files /dev/null and b/.yarn-offline-cache/@turf-bbox-polygon-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-bearing-7.3.4.tgz b/.yarn-offline-cache/@turf-bearing-7.3.4.tgz new file mode 100644 index 00000000..368e7f2b Binary files /dev/null and b/.yarn-offline-cache/@turf-bearing-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-bezier-spline-7.3.4.tgz b/.yarn-offline-cache/@turf-bezier-spline-7.3.4.tgz new file mode 100644 index 00000000..44c36315 Binary files /dev/null and b/.yarn-offline-cache/@turf-bezier-spline-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-clockwise-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-clockwise-7.3.4.tgz new file mode 100644 index 00000000..1cb3fa62 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-clockwise-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-concave-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-concave-7.3.4.tgz new file mode 100644 index 00000000..6f61f495 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-concave-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-contains-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-contains-7.3.4.tgz new file mode 100644 index 00000000..08430c27 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-contains-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-crosses-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-crosses-7.3.4.tgz new file mode 100644 index 00000000..6dbdd7ca Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-crosses-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-disjoint-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-disjoint-7.3.4.tgz new file mode 100644 index 00000000..9e9bc7f4 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-disjoint-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-equal-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-equal-7.3.4.tgz new file mode 100644 index 00000000..4f460468 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-equal-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-intersects-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-intersects-7.3.4.tgz new file mode 100644 index 00000000..dcfce0f9 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-intersects-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-overlap-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-overlap-7.3.4.tgz new file mode 100644 index 00000000..00cb96be Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-overlap-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-parallel-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-parallel-7.3.4.tgz new file mode 100644 index 00000000..285e9fb5 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-parallel-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-point-in-polygon-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-point-in-polygon-7.3.4.tgz new file mode 100644 index 00000000..f117462a Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-point-in-polygon-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-point-on-line-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-point-on-line-7.3.4.tgz new file mode 100644 index 00000000..e13006eb Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-point-on-line-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-touches-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-touches-7.3.4.tgz new file mode 100644 index 00000000..4ff4de6a Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-touches-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-valid-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-valid-7.3.4.tgz new file mode 100644 index 00000000..8e142599 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-valid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-boolean-within-7.3.4.tgz b/.yarn-offline-cache/@turf-boolean-within-7.3.4.tgz new file mode 100644 index 00000000..7378ad54 Binary files /dev/null and b/.yarn-offline-cache/@turf-boolean-within-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-buffer-7.3.4.tgz b/.yarn-offline-cache/@turf-buffer-7.3.4.tgz new file mode 100644 index 00000000..410645c0 Binary files /dev/null and b/.yarn-offline-cache/@turf-buffer-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-center-7.3.4.tgz b/.yarn-offline-cache/@turf-center-7.3.4.tgz new file mode 100644 index 00000000..fcdf451e Binary files /dev/null and b/.yarn-offline-cache/@turf-center-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-center-mean-7.3.4.tgz b/.yarn-offline-cache/@turf-center-mean-7.3.4.tgz new file mode 100644 index 00000000..fd74eac1 Binary files /dev/null and b/.yarn-offline-cache/@turf-center-mean-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-center-median-7.3.4.tgz b/.yarn-offline-cache/@turf-center-median-7.3.4.tgz new file mode 100644 index 00000000..aaa4ee19 Binary files /dev/null and b/.yarn-offline-cache/@turf-center-median-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-center-of-mass-7.3.4.tgz b/.yarn-offline-cache/@turf-center-of-mass-7.3.4.tgz new file mode 100644 index 00000000..d6591d7f Binary files /dev/null and b/.yarn-offline-cache/@turf-center-of-mass-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-centroid-7.3.4.tgz b/.yarn-offline-cache/@turf-centroid-7.3.4.tgz new file mode 100644 index 00000000..12d65fd3 Binary files /dev/null and b/.yarn-offline-cache/@turf-centroid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-circle-7.3.4.tgz b/.yarn-offline-cache/@turf-circle-7.3.4.tgz new file mode 100644 index 00000000..db705d82 Binary files /dev/null and b/.yarn-offline-cache/@turf-circle-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-clean-coords-7.3.4.tgz b/.yarn-offline-cache/@turf-clean-coords-7.3.4.tgz new file mode 100644 index 00000000..ab1924b2 Binary files /dev/null and b/.yarn-offline-cache/@turf-clean-coords-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-clone-7.3.4.tgz b/.yarn-offline-cache/@turf-clone-7.3.4.tgz new file mode 100644 index 00000000..bf269069 Binary files /dev/null and b/.yarn-offline-cache/@turf-clone-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-clusters-7.3.4.tgz b/.yarn-offline-cache/@turf-clusters-7.3.4.tgz new file mode 100644 index 00000000..878e1553 Binary files /dev/null and b/.yarn-offline-cache/@turf-clusters-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-clusters-dbscan-7.3.4.tgz b/.yarn-offline-cache/@turf-clusters-dbscan-7.3.4.tgz new file mode 100644 index 00000000..e32a2abb Binary files /dev/null and b/.yarn-offline-cache/@turf-clusters-dbscan-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-clusters-kmeans-7.3.4.tgz b/.yarn-offline-cache/@turf-clusters-kmeans-7.3.4.tgz new file mode 100644 index 00000000..63429199 Binary files /dev/null and b/.yarn-offline-cache/@turf-clusters-kmeans-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-collect-7.3.4.tgz b/.yarn-offline-cache/@turf-collect-7.3.4.tgz new file mode 100644 index 00000000..6744353e Binary files /dev/null and b/.yarn-offline-cache/@turf-collect-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-combine-7.3.4.tgz b/.yarn-offline-cache/@turf-combine-7.3.4.tgz new file mode 100644 index 00000000..a7057ff8 Binary files /dev/null and b/.yarn-offline-cache/@turf-combine-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-concave-7.3.4.tgz b/.yarn-offline-cache/@turf-concave-7.3.4.tgz new file mode 100644 index 00000000..c17118d2 Binary files /dev/null and b/.yarn-offline-cache/@turf-concave-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-convex-7.3.4.tgz b/.yarn-offline-cache/@turf-convex-7.3.4.tgz new file mode 100644 index 00000000..dc553593 Binary files /dev/null and b/.yarn-offline-cache/@turf-convex-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-destination-7.3.4.tgz b/.yarn-offline-cache/@turf-destination-7.3.4.tgz new file mode 100644 index 00000000..60e15d24 Binary files /dev/null and b/.yarn-offline-cache/@turf-destination-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-difference-7.3.4.tgz b/.yarn-offline-cache/@turf-difference-7.3.4.tgz new file mode 100644 index 00000000..f70ed640 Binary files /dev/null and b/.yarn-offline-cache/@turf-difference-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-dissolve-7.3.4.tgz b/.yarn-offline-cache/@turf-dissolve-7.3.4.tgz new file mode 100644 index 00000000..1f3a98e0 Binary files /dev/null and b/.yarn-offline-cache/@turf-dissolve-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-distance-7.3.4.tgz b/.yarn-offline-cache/@turf-distance-7.3.4.tgz new file mode 100644 index 00000000..5912e076 Binary files /dev/null and b/.yarn-offline-cache/@turf-distance-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-distance-weight-7.3.4.tgz b/.yarn-offline-cache/@turf-distance-weight-7.3.4.tgz new file mode 100644 index 00000000..08fd90cb Binary files /dev/null and b/.yarn-offline-cache/@turf-distance-weight-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-ellipse-7.3.4.tgz b/.yarn-offline-cache/@turf-ellipse-7.3.4.tgz new file mode 100644 index 00000000..3163cd97 Binary files /dev/null and b/.yarn-offline-cache/@turf-ellipse-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-envelope-7.3.4.tgz b/.yarn-offline-cache/@turf-envelope-7.3.4.tgz new file mode 100644 index 00000000..822118aa Binary files /dev/null and b/.yarn-offline-cache/@turf-envelope-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-explode-7.3.4.tgz b/.yarn-offline-cache/@turf-explode-7.3.4.tgz new file mode 100644 index 00000000..1e03cd57 Binary files /dev/null and b/.yarn-offline-cache/@turf-explode-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-flatten-7.3.4.tgz b/.yarn-offline-cache/@turf-flatten-7.3.4.tgz new file mode 100644 index 00000000..82496512 Binary files /dev/null and b/.yarn-offline-cache/@turf-flatten-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-flip-7.3.4.tgz b/.yarn-offline-cache/@turf-flip-7.3.4.tgz new file mode 100644 index 00000000..6a2b865b Binary files /dev/null and b/.yarn-offline-cache/@turf-flip-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-geojson-rbush-7.3.4.tgz b/.yarn-offline-cache/@turf-geojson-rbush-7.3.4.tgz new file mode 100644 index 00000000..e2ee28f4 Binary files /dev/null and b/.yarn-offline-cache/@turf-geojson-rbush-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-great-circle-7.3.4.tgz b/.yarn-offline-cache/@turf-great-circle-7.3.4.tgz new file mode 100644 index 00000000..a11cc738 Binary files /dev/null and b/.yarn-offline-cache/@turf-great-circle-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-helpers-7.3.4.tgz b/.yarn-offline-cache/@turf-helpers-7.3.4.tgz new file mode 100644 index 00000000..ee65c7ca Binary files /dev/null and b/.yarn-offline-cache/@turf-helpers-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-hex-grid-7.3.4.tgz b/.yarn-offline-cache/@turf-hex-grid-7.3.4.tgz new file mode 100644 index 00000000..d0024c7f Binary files /dev/null and b/.yarn-offline-cache/@turf-hex-grid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-interpolate-7.3.4.tgz b/.yarn-offline-cache/@turf-interpolate-7.3.4.tgz new file mode 100644 index 00000000..7bd2b895 Binary files /dev/null and b/.yarn-offline-cache/@turf-interpolate-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-intersect-7.3.4.tgz b/.yarn-offline-cache/@turf-intersect-7.3.4.tgz new file mode 100644 index 00000000..43093a57 Binary files /dev/null and b/.yarn-offline-cache/@turf-intersect-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-invariant-7.3.4.tgz b/.yarn-offline-cache/@turf-invariant-7.3.4.tgz new file mode 100644 index 00000000..355413ca Binary files /dev/null and b/.yarn-offline-cache/@turf-invariant-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-isobands-7.3.4.tgz b/.yarn-offline-cache/@turf-isobands-7.3.4.tgz new file mode 100644 index 00000000..b9031e50 Binary files /dev/null and b/.yarn-offline-cache/@turf-isobands-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-isolines-7.3.4.tgz b/.yarn-offline-cache/@turf-isolines-7.3.4.tgz new file mode 100644 index 00000000..8320db35 Binary files /dev/null and b/.yarn-offline-cache/@turf-isolines-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-jsts-2.7.2.tgz b/.yarn-offline-cache/@turf-jsts-2.7.2.tgz new file mode 100644 index 00000000..e5a4bb37 Binary files /dev/null and b/.yarn-offline-cache/@turf-jsts-2.7.2.tgz differ diff --git a/.yarn-offline-cache/@turf-kinks-7.3.4.tgz b/.yarn-offline-cache/@turf-kinks-7.3.4.tgz new file mode 100644 index 00000000..fd7fd7d0 Binary files /dev/null and b/.yarn-offline-cache/@turf-kinks-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-length-7.3.4.tgz b/.yarn-offline-cache/@turf-length-7.3.4.tgz new file mode 100644 index 00000000..181f3e92 Binary files /dev/null and b/.yarn-offline-cache/@turf-length-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-arc-7.3.4.tgz b/.yarn-offline-cache/@turf-line-arc-7.3.4.tgz new file mode 100644 index 00000000..94543f3c Binary files /dev/null and b/.yarn-offline-cache/@turf-line-arc-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-chunk-7.3.4.tgz b/.yarn-offline-cache/@turf-line-chunk-7.3.4.tgz new file mode 100644 index 00000000..b1c5f3e0 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-chunk-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-intersect-7.3.4.tgz b/.yarn-offline-cache/@turf-line-intersect-7.3.4.tgz new file mode 100644 index 00000000..b2e8f638 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-intersect-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-offset-7.3.4.tgz b/.yarn-offline-cache/@turf-line-offset-7.3.4.tgz new file mode 100644 index 00000000..a40071d5 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-offset-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-overlap-7.3.4.tgz b/.yarn-offline-cache/@turf-line-overlap-7.3.4.tgz new file mode 100644 index 00000000..acc10815 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-overlap-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-segment-7.3.4.tgz b/.yarn-offline-cache/@turf-line-segment-7.3.4.tgz new file mode 100644 index 00000000..3f94e3d4 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-segment-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-slice-7.3.4.tgz b/.yarn-offline-cache/@turf-line-slice-7.3.4.tgz new file mode 100644 index 00000000..6eea8d1c Binary files /dev/null and b/.yarn-offline-cache/@turf-line-slice-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-slice-along-7.3.4.tgz b/.yarn-offline-cache/@turf-line-slice-along-7.3.4.tgz new file mode 100644 index 00000000..b46191c4 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-slice-along-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-split-7.3.4.tgz b/.yarn-offline-cache/@turf-line-split-7.3.4.tgz new file mode 100644 index 00000000..7e6c4b79 Binary files /dev/null and b/.yarn-offline-cache/@turf-line-split-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-line-to-polygon-7.3.4.tgz b/.yarn-offline-cache/@turf-line-to-polygon-7.3.4.tgz new file mode 100644 index 00000000..7ad688dd Binary files /dev/null and b/.yarn-offline-cache/@turf-line-to-polygon-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-mask-7.3.4.tgz b/.yarn-offline-cache/@turf-mask-7.3.4.tgz new file mode 100644 index 00000000..e2614832 Binary files /dev/null and b/.yarn-offline-cache/@turf-mask-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-meta-7.3.4.tgz b/.yarn-offline-cache/@turf-meta-7.3.4.tgz new file mode 100644 index 00000000..42898ed6 Binary files /dev/null and b/.yarn-offline-cache/@turf-meta-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-midpoint-7.3.4.tgz b/.yarn-offline-cache/@turf-midpoint-7.3.4.tgz new file mode 100644 index 00000000..6c5e16ba Binary files /dev/null and b/.yarn-offline-cache/@turf-midpoint-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-moran-index-7.3.4.tgz b/.yarn-offline-cache/@turf-moran-index-7.3.4.tgz new file mode 100644 index 00000000..a5c65660 Binary files /dev/null and b/.yarn-offline-cache/@turf-moran-index-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-nearest-neighbor-analysis-7.3.4.tgz b/.yarn-offline-cache/@turf-nearest-neighbor-analysis-7.3.4.tgz new file mode 100644 index 00000000..9e1bf749 Binary files /dev/null and b/.yarn-offline-cache/@turf-nearest-neighbor-analysis-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-nearest-point-7.3.4.tgz b/.yarn-offline-cache/@turf-nearest-point-7.3.4.tgz new file mode 100644 index 00000000..16a1695f Binary files /dev/null and b/.yarn-offline-cache/@turf-nearest-point-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-nearest-point-on-line-7.3.4.tgz b/.yarn-offline-cache/@turf-nearest-point-on-line-7.3.4.tgz new file mode 100644 index 00000000..dc639312 Binary files /dev/null and b/.yarn-offline-cache/@turf-nearest-point-on-line-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-nearest-point-to-line-7.3.4.tgz b/.yarn-offline-cache/@turf-nearest-point-to-line-7.3.4.tgz new file mode 100644 index 00000000..5838aa06 Binary files /dev/null and b/.yarn-offline-cache/@turf-nearest-point-to-line-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-planepoint-7.3.4.tgz b/.yarn-offline-cache/@turf-planepoint-7.3.4.tgz new file mode 100644 index 00000000..54a766f0 Binary files /dev/null and b/.yarn-offline-cache/@turf-planepoint-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-point-grid-7.3.4.tgz b/.yarn-offline-cache/@turf-point-grid-7.3.4.tgz new file mode 100644 index 00000000..4e81f2f8 Binary files /dev/null and b/.yarn-offline-cache/@turf-point-grid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-point-on-feature-7.3.4.tgz b/.yarn-offline-cache/@turf-point-on-feature-7.3.4.tgz new file mode 100644 index 00000000..073e57e5 Binary files /dev/null and b/.yarn-offline-cache/@turf-point-on-feature-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-point-to-line-distance-7.3.4.tgz b/.yarn-offline-cache/@turf-point-to-line-distance-7.3.4.tgz new file mode 100644 index 00000000..98b28709 Binary files /dev/null and b/.yarn-offline-cache/@turf-point-to-line-distance-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-point-to-polygon-distance-7.3.4.tgz b/.yarn-offline-cache/@turf-point-to-polygon-distance-7.3.4.tgz new file mode 100644 index 00000000..d95d11d3 Binary files /dev/null and b/.yarn-offline-cache/@turf-point-to-polygon-distance-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-points-within-polygon-7.3.4.tgz b/.yarn-offline-cache/@turf-points-within-polygon-7.3.4.tgz new file mode 100644 index 00000000..4d459ea2 Binary files /dev/null and b/.yarn-offline-cache/@turf-points-within-polygon-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-polygon-smooth-7.3.4.tgz b/.yarn-offline-cache/@turf-polygon-smooth-7.3.4.tgz new file mode 100644 index 00000000..c7ddd0f5 Binary files /dev/null and b/.yarn-offline-cache/@turf-polygon-smooth-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-polygon-tangents-7.3.4.tgz b/.yarn-offline-cache/@turf-polygon-tangents-7.3.4.tgz new file mode 100644 index 00000000..a56cb19e Binary files /dev/null and b/.yarn-offline-cache/@turf-polygon-tangents-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-polygon-to-line-7.3.4.tgz b/.yarn-offline-cache/@turf-polygon-to-line-7.3.4.tgz new file mode 100644 index 00000000..615ebac9 Binary files /dev/null and b/.yarn-offline-cache/@turf-polygon-to-line-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-polygonize-7.3.4.tgz b/.yarn-offline-cache/@turf-polygonize-7.3.4.tgz new file mode 100644 index 00000000..cf2356a9 Binary files /dev/null and b/.yarn-offline-cache/@turf-polygonize-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-projection-7.3.4.tgz b/.yarn-offline-cache/@turf-projection-7.3.4.tgz new file mode 100644 index 00000000..1df82f5e Binary files /dev/null and b/.yarn-offline-cache/@turf-projection-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-quadrat-analysis-7.3.4.tgz b/.yarn-offline-cache/@turf-quadrat-analysis-7.3.4.tgz new file mode 100644 index 00000000..88813426 Binary files /dev/null and b/.yarn-offline-cache/@turf-quadrat-analysis-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-random-7.3.4.tgz b/.yarn-offline-cache/@turf-random-7.3.4.tgz new file mode 100644 index 00000000..d444c866 Binary files /dev/null and b/.yarn-offline-cache/@turf-random-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-rectangle-grid-7.3.4.tgz b/.yarn-offline-cache/@turf-rectangle-grid-7.3.4.tgz new file mode 100644 index 00000000..4331b2fb Binary files /dev/null and b/.yarn-offline-cache/@turf-rectangle-grid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-rewind-7.3.4.tgz b/.yarn-offline-cache/@turf-rewind-7.3.4.tgz new file mode 100644 index 00000000..050efd45 Binary files /dev/null and b/.yarn-offline-cache/@turf-rewind-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-rhumb-bearing-7.3.4.tgz b/.yarn-offline-cache/@turf-rhumb-bearing-7.3.4.tgz new file mode 100644 index 00000000..a9f65357 Binary files /dev/null and b/.yarn-offline-cache/@turf-rhumb-bearing-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-rhumb-destination-7.3.4.tgz b/.yarn-offline-cache/@turf-rhumb-destination-7.3.4.tgz new file mode 100644 index 00000000..50cce2e4 Binary files /dev/null and b/.yarn-offline-cache/@turf-rhumb-destination-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-rhumb-distance-7.3.4.tgz b/.yarn-offline-cache/@turf-rhumb-distance-7.3.4.tgz new file mode 100644 index 00000000..3b253161 Binary files /dev/null and b/.yarn-offline-cache/@turf-rhumb-distance-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-sample-7.3.4.tgz b/.yarn-offline-cache/@turf-sample-7.3.4.tgz new file mode 100644 index 00000000..138baa63 Binary files /dev/null and b/.yarn-offline-cache/@turf-sample-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-sector-7.3.4.tgz b/.yarn-offline-cache/@turf-sector-7.3.4.tgz new file mode 100644 index 00000000..626e10b1 Binary files /dev/null and b/.yarn-offline-cache/@turf-sector-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-shortest-path-7.3.4.tgz b/.yarn-offline-cache/@turf-shortest-path-7.3.4.tgz new file mode 100644 index 00000000..eb98296c Binary files /dev/null and b/.yarn-offline-cache/@turf-shortest-path-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-simplify-7.3.4.tgz b/.yarn-offline-cache/@turf-simplify-7.3.4.tgz new file mode 100644 index 00000000..4b3c34d7 Binary files /dev/null and b/.yarn-offline-cache/@turf-simplify-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-square-7.3.4.tgz b/.yarn-offline-cache/@turf-square-7.3.4.tgz new file mode 100644 index 00000000..17307dae Binary files /dev/null and b/.yarn-offline-cache/@turf-square-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-square-grid-7.3.4.tgz b/.yarn-offline-cache/@turf-square-grid-7.3.4.tgz new file mode 100644 index 00000000..891a1500 Binary files /dev/null and b/.yarn-offline-cache/@turf-square-grid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-standard-deviational-ellipse-7.3.4.tgz b/.yarn-offline-cache/@turf-standard-deviational-ellipse-7.3.4.tgz new file mode 100644 index 00000000..07ab5ea1 Binary files /dev/null and b/.yarn-offline-cache/@turf-standard-deviational-ellipse-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-tag-7.3.4.tgz b/.yarn-offline-cache/@turf-tag-7.3.4.tgz new file mode 100644 index 00000000..fa19d716 Binary files /dev/null and b/.yarn-offline-cache/@turf-tag-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-tesselate-7.3.4.tgz b/.yarn-offline-cache/@turf-tesselate-7.3.4.tgz new file mode 100644 index 00000000..58a37043 Binary files /dev/null and b/.yarn-offline-cache/@turf-tesselate-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-tin-7.3.4.tgz b/.yarn-offline-cache/@turf-tin-7.3.4.tgz new file mode 100644 index 00000000..7f1d5388 Binary files /dev/null and b/.yarn-offline-cache/@turf-tin-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-transform-rotate-7.3.4.tgz b/.yarn-offline-cache/@turf-transform-rotate-7.3.4.tgz new file mode 100644 index 00000000..5d4cc681 Binary files /dev/null and b/.yarn-offline-cache/@turf-transform-rotate-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-transform-scale-7.3.4.tgz b/.yarn-offline-cache/@turf-transform-scale-7.3.4.tgz new file mode 100644 index 00000000..08f9ee57 Binary files /dev/null and b/.yarn-offline-cache/@turf-transform-scale-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-transform-translate-7.3.4.tgz b/.yarn-offline-cache/@turf-transform-translate-7.3.4.tgz new file mode 100644 index 00000000..686e54d1 Binary files /dev/null and b/.yarn-offline-cache/@turf-transform-translate-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-triangle-grid-7.3.4.tgz b/.yarn-offline-cache/@turf-triangle-grid-7.3.4.tgz new file mode 100644 index 00000000..72e03bc4 Binary files /dev/null and b/.yarn-offline-cache/@turf-triangle-grid-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-truncate-7.3.4.tgz b/.yarn-offline-cache/@turf-truncate-7.3.4.tgz new file mode 100644 index 00000000..8631045a Binary files /dev/null and b/.yarn-offline-cache/@turf-truncate-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-turf-7.3.4.tgz b/.yarn-offline-cache/@turf-turf-7.3.4.tgz new file mode 100644 index 00000000..dc689747 Binary files /dev/null and b/.yarn-offline-cache/@turf-turf-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-union-7.3.4.tgz b/.yarn-offline-cache/@turf-union-7.3.4.tgz new file mode 100644 index 00000000..14ab63dc Binary files /dev/null and b/.yarn-offline-cache/@turf-union-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-unkink-polygon-7.3.4.tgz b/.yarn-offline-cache/@turf-unkink-polygon-7.3.4.tgz new file mode 100644 index 00000000..2bf6cb43 Binary files /dev/null and b/.yarn-offline-cache/@turf-unkink-polygon-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@turf-voronoi-7.3.4.tgz b/.yarn-offline-cache/@turf-voronoi-7.3.4.tgz new file mode 100644 index 00000000..34cca3fd Binary files /dev/null and b/.yarn-offline-cache/@turf-voronoi-7.3.4.tgz differ diff --git a/.yarn-offline-cache/@types-d3-voronoi-1.1.12.tgz b/.yarn-offline-cache/@types-d3-voronoi-1.1.12.tgz new file mode 100644 index 00000000..5cd2da03 Binary files /dev/null and b/.yarn-offline-cache/@types-d3-voronoi-1.1.12.tgz differ diff --git a/.yarn-offline-cache/@types-geokdbush-1.1.5.tgz b/.yarn-offline-cache/@types-geokdbush-1.1.5.tgz new file mode 100644 index 00000000..d6134e44 Binary files /dev/null and b/.yarn-offline-cache/@types-geokdbush-1.1.5.tgz differ diff --git a/.yarn-offline-cache/@types-json-schema-7.0.15.tgz b/.yarn-offline-cache/@types-json-schema-7.0.15.tgz new file mode 100644 index 00000000..7c621c81 Binary files /dev/null and b/.yarn-offline-cache/@types-json-schema-7.0.15.tgz differ diff --git a/.yarn-offline-cache/@types-kdbush-1.0.7.tgz b/.yarn-offline-cache/@types-kdbush-1.0.7.tgz new file mode 100644 index 00000000..26656e6a Binary files /dev/null and b/.yarn-offline-cache/@types-kdbush-1.0.7.tgz differ diff --git a/.yarn-offline-cache/@types-kdbush-3.0.5.tgz b/.yarn-offline-cache/@types-kdbush-3.0.5.tgz new file mode 100644 index 00000000..27bb5887 Binary files /dev/null and b/.yarn-offline-cache/@types-kdbush-3.0.5.tgz differ diff --git a/.yarn-offline-cache/@types-node-22.19.11.tgz b/.yarn-offline-cache/@types-node-22.19.11.tgz new file mode 100644 index 00000000..2f7259ac Binary files /dev/null and b/.yarn-offline-cache/@types-node-22.19.11.tgz differ diff --git a/.yarn-offline-cache/@types-rbush-4.0.0.tgz b/.yarn-offline-cache/@types-rbush-4.0.0.tgz deleted file mode 100644 index 29865e80..00000000 Binary files a/.yarn-offline-cache/@types-rbush-4.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@types-react-19.2.14.tgz b/.yarn-offline-cache/@types-react-19.2.14.tgz new file mode 100644 index 00000000..665486e2 Binary files /dev/null and b/.yarn-offline-cache/@types-react-19.2.14.tgz differ diff --git a/.yarn-offline-cache/@types-react-dom-19.2.3.tgz b/.yarn-offline-cache/@types-react-dom-19.2.3.tgz new file mode 100644 index 00000000..1b16970a Binary files /dev/null and b/.yarn-offline-cache/@types-react-dom-19.2.3.tgz differ diff --git a/.yarn-offline-cache/@types-supercluster-7.1.3.tgz b/.yarn-offline-cache/@types-supercluster-7.1.3.tgz new file mode 100644 index 00000000..7f946b5d Binary files /dev/null and b/.yarn-offline-cache/@types-supercluster-7.1.3.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-eslint-plugin-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-eslint-plugin-8.55.0.tgz new file mode 100644 index 00000000..b73b6a54 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-eslint-plugin-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-parser-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-parser-8.55.0.tgz new file mode 100644 index 00000000..38716f81 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-parser-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-project-service-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-project-service-8.55.0.tgz new file mode 100644 index 00000000..7f911cd8 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-project-service-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-scope-manager-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-scope-manager-8.55.0.tgz new file mode 100644 index 00000000..d50bae3f Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-scope-manager-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-tsconfig-utils-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-tsconfig-utils-8.55.0.tgz new file mode 100644 index 00000000..a42b40ca Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-tsconfig-utils-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-type-utils-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-type-utils-8.55.0.tgz new file mode 100644 index 00000000..2d6e8f2a Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-type-utils-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-types-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-types-8.55.0.tgz new file mode 100644 index 00000000..3730011f Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-types-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-typescript-estree-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-typescript-estree-8.55.0.tgz new file mode 100644 index 00000000..24b25529 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-typescript-estree-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-utils-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-utils-8.55.0.tgz new file mode 100644 index 00000000..1c12cd03 Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-utils-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@typescript-eslint-visitor-keys-8.55.0.tgz b/.yarn-offline-cache/@typescript-eslint-visitor-keys-8.55.0.tgz new file mode 100644 index 00000000..1961d35d Binary files /dev/null and b/.yarn-offline-cache/@typescript-eslint-visitor-keys-8.55.0.tgz differ diff --git a/.yarn-offline-cache/@ungap-structured-clone-1.3.0.tgz b/.yarn-offline-cache/@ungap-structured-clone-1.3.0.tgz deleted file mode 100644 index 889823fb..00000000 Binary files a/.yarn-offline-cache/@ungap-structured-clone-1.3.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@vitejs-plugin-react-4.7.0.tgz b/.yarn-offline-cache/@vitejs-plugin-react-4.7.0.tgz deleted file mode 100644 index 7cad2db3..00000000 Binary files a/.yarn-offline-cache/@vitejs-plugin-react-4.7.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/@vitejs-plugin-react-5.1.4.tgz b/.yarn-offline-cache/@vitejs-plugin-react-5.1.4.tgz new file mode 100644 index 00000000..6f556a5b Binary files /dev/null and b/.yarn-offline-cache/@vitejs-plugin-react-5.1.4.tgz differ diff --git a/.yarn-offline-cache/@zarrita-storage-0.1.4.tgz b/.yarn-offline-cache/@zarrita-storage-0.1.4.tgz deleted file mode 100644 index 73114695..00000000 Binary files a/.yarn-offline-cache/@zarrita-storage-0.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/acorn-8.15.0.tgz b/.yarn-offline-cache/acorn-8.15.0.tgz deleted file mode 100644 index 3a6f1daa..00000000 Binary files a/.yarn-offline-cache/acorn-8.15.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/acorn-jsx-5.3.2.tgz b/.yarn-offline-cache/acorn-jsx-5.3.2.tgz deleted file mode 100644 index b9de5a03..00000000 Binary files a/.yarn-offline-cache/acorn-jsx-5.3.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ajv-6.12.6.tgz b/.yarn-offline-cache/ajv-6.12.6.tgz deleted file mode 100644 index 8a6c96d0..00000000 Binary files a/.yarn-offline-cache/ajv-6.12.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ansi-regex-5.0.1.tgz b/.yarn-offline-cache/ansi-regex-5.0.1.tgz deleted file mode 100644 index e1095e4d..00000000 Binary files a/.yarn-offline-cache/ansi-regex-5.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ansi-styles-4.3.0.tgz b/.yarn-offline-cache/ansi-styles-4.3.0.tgz deleted file mode 100644 index 39e6a784..00000000 Binary files a/.yarn-offline-cache/ansi-styles-4.3.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/arc-0.2.0.tgz b/.yarn-offline-cache/arc-0.2.0.tgz new file mode 100644 index 00000000..d2f20c2f Binary files /dev/null and b/.yarn-offline-cache/arc-0.2.0.tgz differ diff --git a/.yarn-offline-cache/argparse-2.0.1.tgz b/.yarn-offline-cache/argparse-2.0.1.tgz deleted file mode 100644 index 30c97f16..00000000 Binary files a/.yarn-offline-cache/argparse-2.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array-buffer-byte-length-1.0.2.tgz b/.yarn-offline-cache/array-buffer-byte-length-1.0.2.tgz deleted file mode 100644 index bdb60c52..00000000 Binary files a/.yarn-offline-cache/array-buffer-byte-length-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array-includes-3.1.9.tgz b/.yarn-offline-cache/array-includes-3.1.9.tgz deleted file mode 100644 index 9b35ecdd..00000000 Binary files a/.yarn-offline-cache/array-includes-3.1.9.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array.prototype.findlast-1.2.5.tgz b/.yarn-offline-cache/array.prototype.findlast-1.2.5.tgz deleted file mode 100644 index b767e0f5..00000000 Binary files a/.yarn-offline-cache/array.prototype.findlast-1.2.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array.prototype.flat-1.3.3.tgz b/.yarn-offline-cache/array.prototype.flat-1.3.3.tgz deleted file mode 100644 index 9024218e..00000000 Binary files a/.yarn-offline-cache/array.prototype.flat-1.3.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array.prototype.flatmap-1.3.3.tgz b/.yarn-offline-cache/array.prototype.flatmap-1.3.3.tgz deleted file mode 100644 index de4e3422..00000000 Binary files a/.yarn-offline-cache/array.prototype.flatmap-1.3.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/array.prototype.tosorted-1.1.4.tgz b/.yarn-offline-cache/array.prototype.tosorted-1.1.4.tgz deleted file mode 100644 index 4e483425..00000000 Binary files a/.yarn-offline-cache/array.prototype.tosorted-1.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/arraybuffer.prototype.slice-1.0.4.tgz b/.yarn-offline-cache/arraybuffer.prototype.slice-1.0.4.tgz deleted file mode 100644 index 6af68ed1..00000000 Binary files a/.yarn-offline-cache/arraybuffer.prototype.slice-1.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/async-function-1.0.0.tgz b/.yarn-offline-cache/async-function-1.0.0.tgz deleted file mode 100644 index b4c4ffbe..00000000 Binary files a/.yarn-offline-cache/async-function-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/available-typed-arrays-1.0.7.tgz b/.yarn-offline-cache/available-typed-arrays-1.0.7.tgz deleted file mode 100644 index f999512e..00000000 Binary files a/.yarn-offline-cache/available-typed-arrays-1.0.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/balanced-match-1.0.2.tgz b/.yarn-offline-cache/balanced-match-1.0.2.tgz deleted file mode 100644 index 6629c915..00000000 Binary files a/.yarn-offline-cache/balanced-match-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/bignumber.js-9.3.1.tgz b/.yarn-offline-cache/bignumber.js-9.3.1.tgz new file mode 100644 index 00000000..c5f6c7ed Binary files /dev/null and b/.yarn-offline-cache/bignumber.js-9.3.1.tgz differ diff --git a/.yarn-offline-cache/brace-expansion-1.1.12.tgz b/.yarn-offline-cache/brace-expansion-1.1.12.tgz deleted file mode 100644 index 81f63b81..00000000 Binary files a/.yarn-offline-cache/brace-expansion-1.1.12.tgz and /dev/null differ diff --git a/.yarn-offline-cache/brace-expansion-2.0.2.tgz b/.yarn-offline-cache/brace-expansion-2.0.2.tgz new file mode 100644 index 00000000..b76e79f0 Binary files /dev/null and b/.yarn-offline-cache/brace-expansion-2.0.2.tgz differ diff --git a/.yarn-offline-cache/call-bind-1.0.8.tgz b/.yarn-offline-cache/call-bind-1.0.8.tgz deleted file mode 100644 index b303bdf1..00000000 Binary files a/.yarn-offline-cache/call-bind-1.0.8.tgz and /dev/null differ diff --git a/.yarn-offline-cache/call-bound-1.0.4.tgz b/.yarn-offline-cache/call-bound-1.0.4.tgz deleted file mode 100644 index 45c3ae0e..00000000 Binary files a/.yarn-offline-cache/call-bound-1.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/callsites-3.1.0.tgz b/.yarn-offline-cache/callsites-3.1.0.tgz deleted file mode 100644 index d490707e..00000000 Binary files a/.yarn-offline-cache/callsites-3.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/chalk-4.1.2.tgz b/.yarn-offline-cache/chalk-4.1.2.tgz deleted file mode 100644 index 8d34872c..00000000 Binary files a/.yarn-offline-cache/chalk-4.1.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-convert-2.0.1.tgz b/.yarn-offline-cache/color-convert-2.0.1.tgz deleted file mode 100644 index f656c5d7..00000000 Binary files a/.yarn-offline-cache/color-convert-2.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-name-1.1.4.tgz b/.yarn-offline-cache/color-name-1.1.4.tgz deleted file mode 100644 index 98fca076..00000000 Binary files a/.yarn-offline-cache/color-name-1.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-name-2.1.0.tgz b/.yarn-offline-cache/color-name-2.1.0.tgz deleted file mode 100644 index c4eff8d9..00000000 Binary files a/.yarn-offline-cache/color-name-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-parse-2.0.2.tgz b/.yarn-offline-cache/color-parse-2.0.2.tgz deleted file mode 100644 index 78b906bc..00000000 Binary files a/.yarn-offline-cache/color-parse-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-rgba-3.0.0.tgz b/.yarn-offline-cache/color-rgba-3.0.0.tgz deleted file mode 100644 index 1df66897..00000000 Binary files a/.yarn-offline-cache/color-rgba-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/color-space-2.3.2.tgz b/.yarn-offline-cache/color-space-2.3.2.tgz deleted file mode 100644 index 93dcb94b..00000000 Binary files a/.yarn-offline-cache/color-space-2.3.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/commander-2.20.3.tgz b/.yarn-offline-cache/commander-2.20.3.tgz new file mode 100644 index 00000000..6158fbbf Binary files /dev/null and b/.yarn-offline-cache/commander-2.20.3.tgz differ diff --git a/.yarn-offline-cache/concat-map-0.0.1.tgz b/.yarn-offline-cache/concat-map-0.0.1.tgz deleted file mode 100644 index 8d19b25a..00000000 Binary files a/.yarn-offline-cache/concat-map-0.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/concaveman-1.2.1.tgz b/.yarn-offline-cache/concaveman-1.2.1.tgz new file mode 100644 index 00000000..5cc24af4 Binary files /dev/null and b/.yarn-offline-cache/concaveman-1.2.1.tgz differ diff --git a/.yarn-offline-cache/cross-spawn-7.0.6.tgz b/.yarn-offline-cache/cross-spawn-7.0.6.tgz deleted file mode 100644 index adbd2cc6..00000000 Binary files a/.yarn-offline-cache/cross-spawn-7.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/csstype-3.2.3.tgz b/.yarn-offline-cache/csstype-3.2.3.tgz new file mode 100644 index 00000000..3c1935ba Binary files /dev/null and b/.yarn-offline-cache/csstype-3.2.3.tgz differ diff --git a/.yarn-offline-cache/d3-array-1.2.4.tgz b/.yarn-offline-cache/d3-array-1.2.4.tgz new file mode 100644 index 00000000..aa654459 Binary files /dev/null and b/.yarn-offline-cache/d3-array-1.2.4.tgz differ diff --git a/.yarn-offline-cache/d3-geo-1.7.1.tgz b/.yarn-offline-cache/d3-geo-1.7.1.tgz new file mode 100644 index 00000000..ee6c09b9 Binary files /dev/null and b/.yarn-offline-cache/d3-geo-1.7.1.tgz differ diff --git a/.yarn-offline-cache/d3-voronoi-1.1.2.tgz b/.yarn-offline-cache/d3-voronoi-1.1.2.tgz new file mode 100644 index 00000000..f721287d Binary files /dev/null and b/.yarn-offline-cache/d3-voronoi-1.1.2.tgz differ diff --git a/.yarn-offline-cache/data-view-buffer-1.0.2.tgz b/.yarn-offline-cache/data-view-buffer-1.0.2.tgz deleted file mode 100644 index 7fa27c35..00000000 Binary files a/.yarn-offline-cache/data-view-buffer-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/data-view-byte-length-1.0.2.tgz b/.yarn-offline-cache/data-view-byte-length-1.0.2.tgz deleted file mode 100644 index b603087a..00000000 Binary files a/.yarn-offline-cache/data-view-byte-length-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/data-view-byte-offset-1.0.1.tgz b/.yarn-offline-cache/data-view-byte-offset-1.0.1.tgz deleted file mode 100644 index e7d9f178..00000000 Binary files a/.yarn-offline-cache/data-view-byte-offset-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/debug-3.2.7.tgz b/.yarn-offline-cache/debug-3.2.7.tgz deleted file mode 100644 index f0097400..00000000 Binary files a/.yarn-offline-cache/debug-3.2.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/deep-is-0.1.4.tgz b/.yarn-offline-cache/deep-is-0.1.4.tgz deleted file mode 100644 index 1473fd33..00000000 Binary files a/.yarn-offline-cache/deep-is-0.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/define-data-property-1.1.4.tgz b/.yarn-offline-cache/define-data-property-1.1.4.tgz deleted file mode 100644 index a1551c13..00000000 Binary files a/.yarn-offline-cache/define-data-property-1.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/define-properties-1.2.1.tgz b/.yarn-offline-cache/define-properties-1.2.1.tgz deleted file mode 100644 index ea00f4c8..00000000 Binary files a/.yarn-offline-cache/define-properties-1.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/doctrine-2.1.0.tgz b/.yarn-offline-cache/doctrine-2.1.0.tgz deleted file mode 100644 index f3cc56f5..00000000 Binary files a/.yarn-offline-cache/doctrine-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/doctrine-3.0.0.tgz b/.yarn-offline-cache/doctrine-3.0.0.tgz deleted file mode 100644 index a390a279..00000000 Binary files a/.yarn-offline-cache/doctrine-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/earcut-3.0.2.tgz b/.yarn-offline-cache/earcut-3.0.2.tgz deleted file mode 100644 index f2a2a492..00000000 Binary files a/.yarn-offline-cache/earcut-3.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/es-abstract-1.24.1.tgz b/.yarn-offline-cache/es-abstract-1.24.1.tgz deleted file mode 100644 index 80f1e808..00000000 Binary files a/.yarn-offline-cache/es-abstract-1.24.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/es-iterator-helpers-1.2.2.tgz b/.yarn-offline-cache/es-iterator-helpers-1.2.2.tgz deleted file mode 100644 index dfabeda5..00000000 Binary files a/.yarn-offline-cache/es-iterator-helpers-1.2.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/es-shim-unscopables-1.1.0.tgz b/.yarn-offline-cache/es-shim-unscopables-1.1.0.tgz deleted file mode 100644 index ff2c3b9d..00000000 Binary files a/.yarn-offline-cache/es-shim-unscopables-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/es-to-primitive-1.3.0.tgz b/.yarn-offline-cache/es-to-primitive-1.3.0.tgz deleted file mode 100644 index 91cf75c0..00000000 Binary files a/.yarn-offline-cache/es-to-primitive-1.3.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/esbuild-0.21.5.tgz b/.yarn-offline-cache/esbuild-0.21.5.tgz deleted file mode 100644 index 068193d7..00000000 Binary files a/.yarn-offline-cache/esbuild-0.21.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/esbuild-0.27.3.tgz b/.yarn-offline-cache/esbuild-0.27.3.tgz new file mode 100644 index 00000000..176296ad Binary files /dev/null and b/.yarn-offline-cache/esbuild-0.27.3.tgz differ diff --git a/.yarn-offline-cache/escape-string-regexp-4.0.0.tgz b/.yarn-offline-cache/escape-string-regexp-4.0.0.tgz deleted file mode 100644 index 2b37a022..00000000 Binary files a/.yarn-offline-cache/escape-string-regexp-4.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-8.57.1.tgz b/.yarn-offline-cache/eslint-8.57.1.tgz deleted file mode 100644 index c8c05139..00000000 Binary files a/.yarn-offline-cache/eslint-8.57.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-9.39.2.tgz b/.yarn-offline-cache/eslint-9.39.2.tgz new file mode 100644 index 00000000..3ec12736 Binary files /dev/null and b/.yarn-offline-cache/eslint-9.39.2.tgz differ diff --git a/.yarn-offline-cache/eslint-plugin-react-7.37.5.tgz b/.yarn-offline-cache/eslint-plugin-react-7.37.5.tgz deleted file mode 100644 index 981f2233..00000000 Binary files a/.yarn-offline-cache/eslint-plugin-react-7.37.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-plugin-react-hooks-4.6.2.tgz b/.yarn-offline-cache/eslint-plugin-react-hooks-4.6.2.tgz deleted file mode 100644 index 9c40e6a3..00000000 Binary files a/.yarn-offline-cache/eslint-plugin-react-hooks-4.6.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-plugin-react-hooks-7.0.1.tgz b/.yarn-offline-cache/eslint-plugin-react-hooks-7.0.1.tgz new file mode 100644 index 00000000..f7db92de Binary files /dev/null and b/.yarn-offline-cache/eslint-plugin-react-hooks-7.0.1.tgz differ diff --git a/.yarn-offline-cache/eslint-plugin-react-refresh-0.4.26.tgz b/.yarn-offline-cache/eslint-plugin-react-refresh-0.4.26.tgz deleted file mode 100644 index e24c2f2a..00000000 Binary files a/.yarn-offline-cache/eslint-plugin-react-refresh-0.4.26.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-scope-7.2.2.tgz b/.yarn-offline-cache/eslint-scope-7.2.2.tgz deleted file mode 100644 index 58461c93..00000000 Binary files a/.yarn-offline-cache/eslint-scope-7.2.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-scope-8.4.0.tgz b/.yarn-offline-cache/eslint-scope-8.4.0.tgz new file mode 100644 index 00000000..db4fb497 Binary files /dev/null and b/.yarn-offline-cache/eslint-scope-8.4.0.tgz differ diff --git a/.yarn-offline-cache/eslint-visitor-keys-3.4.3.tgz b/.yarn-offline-cache/eslint-visitor-keys-3.4.3.tgz deleted file mode 100644 index 43511c1f..00000000 Binary files a/.yarn-offline-cache/eslint-visitor-keys-3.4.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eslint-visitor-keys-4.2.1.tgz b/.yarn-offline-cache/eslint-visitor-keys-4.2.1.tgz new file mode 100644 index 00000000..1e5a2762 Binary files /dev/null and b/.yarn-offline-cache/eslint-visitor-keys-4.2.1.tgz differ diff --git a/.yarn-offline-cache/espree-10.4.0.tgz b/.yarn-offline-cache/espree-10.4.0.tgz new file mode 100644 index 00000000..86ccd52f Binary files /dev/null and b/.yarn-offline-cache/espree-10.4.0.tgz differ diff --git a/.yarn-offline-cache/espree-9.6.1.tgz b/.yarn-offline-cache/espree-9.6.1.tgz deleted file mode 100644 index 68c37cd2..00000000 Binary files a/.yarn-offline-cache/espree-9.6.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/esquery-1.7.0.tgz b/.yarn-offline-cache/esquery-1.7.0.tgz deleted file mode 100644 index c6736336..00000000 Binary files a/.yarn-offline-cache/esquery-1.7.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/esrecurse-4.3.0.tgz b/.yarn-offline-cache/esrecurse-4.3.0.tgz deleted file mode 100644 index 8d8e5bf5..00000000 Binary files a/.yarn-offline-cache/esrecurse-4.3.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/estraverse-5.3.0.tgz b/.yarn-offline-cache/estraverse-5.3.0.tgz deleted file mode 100644 index eca953f6..00000000 Binary files a/.yarn-offline-cache/estraverse-5.3.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/esutils-2.0.3.tgz b/.yarn-offline-cache/esutils-2.0.3.tgz deleted file mode 100644 index 1bdb0e81..00000000 Binary files a/.yarn-offline-cache/esutils-2.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/eventsource-2.0.2.tgz b/.yarn-offline-cache/eventsource-2.0.2.tgz deleted file mode 100644 index e4437189..00000000 Binary files a/.yarn-offline-cache/eventsource-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fast-deep-equal-3.1.3.tgz b/.yarn-offline-cache/fast-deep-equal-3.1.3.tgz deleted file mode 100644 index dbc9d8dc..00000000 Binary files a/.yarn-offline-cache/fast-deep-equal-3.1.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fast-json-stable-stringify-2.1.0.tgz b/.yarn-offline-cache/fast-json-stable-stringify-2.1.0.tgz deleted file mode 100644 index c78f0090..00000000 Binary files a/.yarn-offline-cache/fast-json-stable-stringify-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fast-levenshtein-2.0.6.tgz b/.yarn-offline-cache/fast-levenshtein-2.0.6.tgz deleted file mode 100644 index fa3d42e2..00000000 Binary files a/.yarn-offline-cache/fast-levenshtein-2.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fastq-1.20.1.tgz b/.yarn-offline-cache/fastq-1.20.1.tgz deleted file mode 100644 index e04fc73f..00000000 Binary files a/.yarn-offline-cache/fastq-1.20.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/faye-websocket-0.11.4.tgz b/.yarn-offline-cache/faye-websocket-0.11.4.tgz deleted file mode 100644 index e94d5e42..00000000 Binary files a/.yarn-offline-cache/faye-websocket-0.11.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fdir-6.5.0.tgz b/.yarn-offline-cache/fdir-6.5.0.tgz new file mode 100644 index 00000000..6f2d7398 Binary files /dev/null and b/.yarn-offline-cache/fdir-6.5.0.tgz differ diff --git a/.yarn-offline-cache/fflate-0.8.2.tgz b/.yarn-offline-cache/fflate-0.8.2.tgz deleted file mode 100644 index 50e3b909..00000000 Binary files a/.yarn-offline-cache/fflate-0.8.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/file-entry-cache-6.0.1.tgz b/.yarn-offline-cache/file-entry-cache-6.0.1.tgz deleted file mode 100644 index 4274ce7b..00000000 Binary files a/.yarn-offline-cache/file-entry-cache-6.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/file-entry-cache-8.0.0.tgz b/.yarn-offline-cache/file-entry-cache-8.0.0.tgz new file mode 100644 index 00000000..38e6f694 Binary files /dev/null and b/.yarn-offline-cache/file-entry-cache-8.0.0.tgz differ diff --git a/.yarn-offline-cache/find-up-5.0.0.tgz b/.yarn-offline-cache/find-up-5.0.0.tgz deleted file mode 100644 index 66adea7b..00000000 Binary files a/.yarn-offline-cache/find-up-5.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/flat-cache-3.2.0.tgz b/.yarn-offline-cache/flat-cache-3.2.0.tgz deleted file mode 100644 index 9a6f33a6..00000000 Binary files a/.yarn-offline-cache/flat-cache-3.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/flat-cache-4.0.1.tgz b/.yarn-offline-cache/flat-cache-4.0.1.tgz new file mode 100644 index 00000000..54e2e098 Binary files /dev/null and b/.yarn-offline-cache/flat-cache-4.0.1.tgz differ diff --git a/.yarn-offline-cache/flatbuffers-25.9.23.tgz b/.yarn-offline-cache/flatbuffers-25.9.23.tgz deleted file mode 100644 index b0f27a1c..00000000 Binary files a/.yarn-offline-cache/flatbuffers-25.9.23.tgz and /dev/null differ diff --git a/.yarn-offline-cache/flatgeobuf-4.4.0.tgz b/.yarn-offline-cache/flatgeobuf-4.4.0.tgz deleted file mode 100644 index f3cd48c4..00000000 Binary files a/.yarn-offline-cache/flatgeobuf-4.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/flatted-3.3.3.tgz b/.yarn-offline-cache/flatted-3.3.3.tgz deleted file mode 100644 index 3ccbe361..00000000 Binary files a/.yarn-offline-cache/flatted-3.3.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/for-each-0.3.5.tgz b/.yarn-offline-cache/for-each-0.3.5.tgz deleted file mode 100644 index 68b14ccc..00000000 Binary files a/.yarn-offline-cache/for-each-0.3.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/fs.realpath-1.0.0.tgz b/.yarn-offline-cache/fs.realpath-1.0.0.tgz deleted file mode 100644 index c9cf8315..00000000 Binary files a/.yarn-offline-cache/fs.realpath-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/function.prototype.name-1.1.8.tgz b/.yarn-offline-cache/function.prototype.name-1.1.8.tgz deleted file mode 100644 index 3d4c8dec..00000000 Binary files a/.yarn-offline-cache/function.prototype.name-1.1.8.tgz and /dev/null differ diff --git a/.yarn-offline-cache/functions-have-names-1.2.3.tgz b/.yarn-offline-cache/functions-have-names-1.2.3.tgz deleted file mode 100644 index 3b4b9fa6..00000000 Binary files a/.yarn-offline-cache/functions-have-names-1.2.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/generator-function-2.0.1.tgz b/.yarn-offline-cache/generator-function-2.0.1.tgz deleted file mode 100644 index 2fb01e66..00000000 Binary files a/.yarn-offline-cache/generator-function-2.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/geojson-equality-ts-1.0.2.tgz b/.yarn-offline-cache/geojson-equality-ts-1.0.2.tgz new file mode 100644 index 00000000..63195225 Binary files /dev/null and b/.yarn-offline-cache/geojson-equality-ts-1.0.2.tgz differ diff --git a/.yarn-offline-cache/geojson-polygon-self-intersections-1.2.2.tgz b/.yarn-offline-cache/geojson-polygon-self-intersections-1.2.2.tgz new file mode 100644 index 00000000..3ef99ff6 Binary files /dev/null and b/.yarn-offline-cache/geojson-polygon-self-intersections-1.2.2.tgz differ diff --git a/.yarn-offline-cache/geokdbush-2.0.1.tgz b/.yarn-offline-cache/geokdbush-2.0.1.tgz new file mode 100644 index 00000000..d45f3d90 Binary files /dev/null and b/.yarn-offline-cache/geokdbush-2.0.1.tgz differ diff --git a/.yarn-offline-cache/geotiff-2.1.3.tgz b/.yarn-offline-cache/geotiff-2.1.3.tgz deleted file mode 100644 index 698c2dd2..00000000 Binary files a/.yarn-offline-cache/geotiff-2.1.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/geotiff-3.0.2.tgz b/.yarn-offline-cache/geotiff-3.0.2.tgz deleted file mode 100644 index 1e53dc13..00000000 Binary files a/.yarn-offline-cache/geotiff-3.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/get-stream-6.0.1.tgz b/.yarn-offline-cache/get-stream-6.0.1.tgz new file mode 100644 index 00000000..7508de4a Binary files /dev/null and b/.yarn-offline-cache/get-stream-6.0.1.tgz differ diff --git a/.yarn-offline-cache/get-symbol-description-1.1.0.tgz b/.yarn-offline-cache/get-symbol-description-1.1.0.tgz deleted file mode 100644 index 7217e02c..00000000 Binary files a/.yarn-offline-cache/get-symbol-description-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/glob-7.2.3.tgz b/.yarn-offline-cache/glob-7.2.3.tgz deleted file mode 100644 index 006b59b8..00000000 Binary files a/.yarn-offline-cache/glob-7.2.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/glob-parent-6.0.2.tgz b/.yarn-offline-cache/glob-parent-6.0.2.tgz deleted file mode 100644 index ebc20239..00000000 Binary files a/.yarn-offline-cache/glob-parent-6.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/globals-13.24.0.tgz b/.yarn-offline-cache/globals-13.24.0.tgz deleted file mode 100644 index cd03801a..00000000 Binary files a/.yarn-offline-cache/globals-13.24.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/globals-14.0.0.tgz b/.yarn-offline-cache/globals-14.0.0.tgz new file mode 100644 index 00000000..e2d39919 Binary files /dev/null and b/.yarn-offline-cache/globals-14.0.0.tgz differ diff --git a/.yarn-offline-cache/globals-16.5.0.tgz b/.yarn-offline-cache/globals-16.5.0.tgz new file mode 100644 index 00000000..2c717014 Binary files /dev/null and b/.yarn-offline-cache/globals-16.5.0.tgz differ diff --git a/.yarn-offline-cache/globalthis-1.0.4.tgz b/.yarn-offline-cache/globalthis-1.0.4.tgz deleted file mode 100644 index 5d051eea..00000000 Binary files a/.yarn-offline-cache/globalthis-1.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/graphemer-1.4.0.tgz b/.yarn-offline-cache/graphemer-1.4.0.tgz deleted file mode 100644 index 90f1ed43..00000000 Binary files a/.yarn-offline-cache/graphemer-1.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/has-bigints-1.1.0.tgz b/.yarn-offline-cache/has-bigints-1.1.0.tgz deleted file mode 100644 index 4b264ee7..00000000 Binary files a/.yarn-offline-cache/has-bigints-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/has-flag-4.0.0.tgz b/.yarn-offline-cache/has-flag-4.0.0.tgz deleted file mode 100644 index 509ae0c0..00000000 Binary files a/.yarn-offline-cache/has-flag-4.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/has-property-descriptors-1.0.2.tgz b/.yarn-offline-cache/has-property-descriptors-1.0.2.tgz deleted file mode 100644 index 21c53f42..00000000 Binary files a/.yarn-offline-cache/has-property-descriptors-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/has-proto-1.2.0.tgz b/.yarn-offline-cache/has-proto-1.2.0.tgz deleted file mode 100644 index 9a140273..00000000 Binary files a/.yarn-offline-cache/has-proto-1.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/hermes-estree-0.25.1.tgz b/.yarn-offline-cache/hermes-estree-0.25.1.tgz new file mode 100644 index 00000000..e581b462 Binary files /dev/null and b/.yarn-offline-cache/hermes-estree-0.25.1.tgz differ diff --git a/.yarn-offline-cache/hermes-parser-0.25.1.tgz b/.yarn-offline-cache/hermes-parser-0.25.1.tgz new file mode 100644 index 00000000..44133989 Binary files /dev/null and b/.yarn-offline-cache/hermes-parser-0.25.1.tgz differ diff --git a/.yarn-offline-cache/http-parser-js-0.5.10.tgz b/.yarn-offline-cache/http-parser-js-0.5.10.tgz deleted file mode 100644 index 5b7709d3..00000000 Binary files a/.yarn-offline-cache/http-parser-js-0.5.10.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ignore-5.3.2.tgz b/.yarn-offline-cache/ignore-5.3.2.tgz deleted file mode 100644 index a3aae867..00000000 Binary files a/.yarn-offline-cache/ignore-5.3.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ignore-7.0.5.tgz b/.yarn-offline-cache/ignore-7.0.5.tgz new file mode 100644 index 00000000..c194e63f Binary files /dev/null and b/.yarn-offline-cache/ignore-7.0.5.tgz differ diff --git a/.yarn-offline-cache/import-fresh-3.3.1.tgz b/.yarn-offline-cache/import-fresh-3.3.1.tgz deleted file mode 100644 index fc462620..00000000 Binary files a/.yarn-offline-cache/import-fresh-3.3.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/imurmurhash-0.1.4.tgz b/.yarn-offline-cache/imurmurhash-0.1.4.tgz deleted file mode 100644 index 36917b63..00000000 Binary files a/.yarn-offline-cache/imurmurhash-0.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/inflight-1.0.6.tgz b/.yarn-offline-cache/inflight-1.0.6.tgz deleted file mode 100644 index 6b5a0721..00000000 Binary files a/.yarn-offline-cache/inflight-1.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/internal-slot-1.1.0.tgz b/.yarn-offline-cache/internal-slot-1.1.0.tgz deleted file mode 100644 index 9f731c5f..00000000 Binary files a/.yarn-offline-cache/internal-slot-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-array-buffer-3.0.5.tgz b/.yarn-offline-cache/is-array-buffer-3.0.5.tgz deleted file mode 100644 index 009f78ec..00000000 Binary files a/.yarn-offline-cache/is-array-buffer-3.0.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-async-function-2.1.1.tgz b/.yarn-offline-cache/is-async-function-2.1.1.tgz deleted file mode 100644 index 01177f2d..00000000 Binary files a/.yarn-offline-cache/is-async-function-2.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-bigint-1.1.0.tgz b/.yarn-offline-cache/is-bigint-1.1.0.tgz deleted file mode 100644 index a53bdddf..00000000 Binary files a/.yarn-offline-cache/is-bigint-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-boolean-object-1.2.2.tgz b/.yarn-offline-cache/is-boolean-object-1.2.2.tgz deleted file mode 100644 index ccddd9ca..00000000 Binary files a/.yarn-offline-cache/is-boolean-object-1.2.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-callable-1.2.7.tgz b/.yarn-offline-cache/is-callable-1.2.7.tgz deleted file mode 100644 index c73fcdd6..00000000 Binary files a/.yarn-offline-cache/is-callable-1.2.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-core-module-2.16.1.tgz b/.yarn-offline-cache/is-core-module-2.16.1.tgz deleted file mode 100644 index 16d67cce..00000000 Binary files a/.yarn-offline-cache/is-core-module-2.16.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-data-view-1.0.2.tgz b/.yarn-offline-cache/is-data-view-1.0.2.tgz deleted file mode 100644 index 968c28df..00000000 Binary files a/.yarn-offline-cache/is-data-view-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-date-object-1.1.0.tgz b/.yarn-offline-cache/is-date-object-1.1.0.tgz deleted file mode 100644 index ba794d64..00000000 Binary files a/.yarn-offline-cache/is-date-object-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-finalizationregistry-1.1.1.tgz b/.yarn-offline-cache/is-finalizationregistry-1.1.1.tgz deleted file mode 100644 index ccdebcce..00000000 Binary files a/.yarn-offline-cache/is-finalizationregistry-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-generator-function-1.1.2.tgz b/.yarn-offline-cache/is-generator-function-1.1.2.tgz deleted file mode 100644 index 5a803e10..00000000 Binary files a/.yarn-offline-cache/is-generator-function-1.1.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-map-2.0.3.tgz b/.yarn-offline-cache/is-map-2.0.3.tgz deleted file mode 100644 index d157186f..00000000 Binary files a/.yarn-offline-cache/is-map-2.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-negative-zero-2.0.3.tgz b/.yarn-offline-cache/is-negative-zero-2.0.3.tgz deleted file mode 100644 index 8d986cef..00000000 Binary files a/.yarn-offline-cache/is-negative-zero-2.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-number-object-1.1.1.tgz b/.yarn-offline-cache/is-number-object-1.1.1.tgz deleted file mode 100644 index 00b5a6e1..00000000 Binary files a/.yarn-offline-cache/is-number-object-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-path-inside-3.0.3.tgz b/.yarn-offline-cache/is-path-inside-3.0.3.tgz deleted file mode 100644 index 61f1f93d..00000000 Binary files a/.yarn-offline-cache/is-path-inside-3.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-regex-1.2.1.tgz b/.yarn-offline-cache/is-regex-1.2.1.tgz deleted file mode 100644 index 9b9b97ea..00000000 Binary files a/.yarn-offline-cache/is-regex-1.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-set-2.0.3.tgz b/.yarn-offline-cache/is-set-2.0.3.tgz deleted file mode 100644 index ee494aac..00000000 Binary files a/.yarn-offline-cache/is-set-2.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-shared-array-buffer-1.0.4.tgz b/.yarn-offline-cache/is-shared-array-buffer-1.0.4.tgz deleted file mode 100644 index 0d4bc0c6..00000000 Binary files a/.yarn-offline-cache/is-shared-array-buffer-1.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-string-1.1.1.tgz b/.yarn-offline-cache/is-string-1.1.1.tgz deleted file mode 100644 index f813b89a..00000000 Binary files a/.yarn-offline-cache/is-string-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-symbol-1.1.1.tgz b/.yarn-offline-cache/is-symbol-1.1.1.tgz deleted file mode 100644 index 4ca1ae0d..00000000 Binary files a/.yarn-offline-cache/is-symbol-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-typed-array-1.1.15.tgz b/.yarn-offline-cache/is-typed-array-1.1.15.tgz deleted file mode 100644 index ca52a2a2..00000000 Binary files a/.yarn-offline-cache/is-typed-array-1.1.15.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-weakmap-2.0.2.tgz b/.yarn-offline-cache/is-weakmap-2.0.2.tgz deleted file mode 100644 index efe32418..00000000 Binary files a/.yarn-offline-cache/is-weakmap-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-weakref-1.1.1.tgz b/.yarn-offline-cache/is-weakref-1.1.1.tgz deleted file mode 100644 index 22aa18c4..00000000 Binary files a/.yarn-offline-cache/is-weakref-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/is-weakset-2.0.4.tgz b/.yarn-offline-cache/is-weakset-2.0.4.tgz deleted file mode 100644 index f555b13d..00000000 Binary files a/.yarn-offline-cache/is-weakset-2.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/isarray-2.0.5.tgz b/.yarn-offline-cache/isarray-2.0.5.tgz deleted file mode 100644 index 1aa47b55..00000000 Binary files a/.yarn-offline-cache/isarray-2.0.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/isexe-2.0.0.tgz b/.yarn-offline-cache/isexe-2.0.0.tgz deleted file mode 100644 index ffba9a94..00000000 Binary files a/.yarn-offline-cache/isexe-2.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/iterator.prototype-1.1.5.tgz b/.yarn-offline-cache/iterator.prototype-1.1.5.tgz deleted file mode 100644 index 6bf79e47..00000000 Binary files a/.yarn-offline-cache/iterator.prototype-1.1.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/js-yaml-4.1.1.tgz b/.yarn-offline-cache/js-yaml-4.1.1.tgz deleted file mode 100644 index 59be320f..00000000 Binary files a/.yarn-offline-cache/js-yaml-4.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/json-buffer-3.0.1.tgz b/.yarn-offline-cache/json-buffer-3.0.1.tgz deleted file mode 100644 index 12454787..00000000 Binary files a/.yarn-offline-cache/json-buffer-3.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/json-schema-traverse-0.4.1.tgz b/.yarn-offline-cache/json-schema-traverse-0.4.1.tgz deleted file mode 100644 index 02de0aaf..00000000 Binary files a/.yarn-offline-cache/json-schema-traverse-0.4.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/json-stable-stringify-without-jsonify-1.0.1.tgz b/.yarn-offline-cache/json-stable-stringify-without-jsonify-1.0.1.tgz deleted file mode 100644 index 25ccaf28..00000000 Binary files a/.yarn-offline-cache/json-stable-stringify-without-jsonify-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/json-stringify-pretty-compact-4.0.0.tgz b/.yarn-offline-cache/json-stringify-pretty-compact-4.0.0.tgz new file mode 100644 index 00000000..27c7464b Binary files /dev/null and b/.yarn-offline-cache/json-stringify-pretty-compact-4.0.0.tgz differ diff --git a/.yarn-offline-cache/jsts-2.7.1.tgz b/.yarn-offline-cache/jsts-2.7.1.tgz new file mode 100644 index 00000000..99cb9ce2 Binary files /dev/null and b/.yarn-offline-cache/jsts-2.7.1.tgz differ diff --git a/.yarn-offline-cache/jsx-ast-utils-3.3.5.tgz b/.yarn-offline-cache/jsx-ast-utils-3.3.5.tgz deleted file mode 100644 index fcd80faf..00000000 Binary files a/.yarn-offline-cache/jsx-ast-utils-3.3.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/kdbush-4.0.2.tgz b/.yarn-offline-cache/kdbush-4.0.2.tgz new file mode 100644 index 00000000..0a594a16 Binary files /dev/null and b/.yarn-offline-cache/kdbush-4.0.2.tgz differ diff --git a/.yarn-offline-cache/keyv-4.5.4.tgz b/.yarn-offline-cache/keyv-4.5.4.tgz deleted file mode 100644 index 40e22fd0..00000000 Binary files a/.yarn-offline-cache/keyv-4.5.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/lerc-3.0.0.tgz b/.yarn-offline-cache/lerc-3.0.0.tgz deleted file mode 100644 index 54938e93..00000000 Binary files a/.yarn-offline-cache/lerc-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/levn-0.4.1.tgz b/.yarn-offline-cache/levn-0.4.1.tgz deleted file mode 100644 index 670d5845..00000000 Binary files a/.yarn-offline-cache/levn-0.4.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/locate-path-6.0.0.tgz b/.yarn-offline-cache/locate-path-6.0.0.tgz deleted file mode 100644 index de8576ca..00000000 Binary files a/.yarn-offline-cache/locate-path-6.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/lodash.merge-4.6.2.tgz b/.yarn-offline-cache/lodash.merge-4.6.2.tgz deleted file mode 100644 index 7ddf829d..00000000 Binary files a/.yarn-offline-cache/lodash.merge-4.6.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/loose-envify-1.4.0.tgz b/.yarn-offline-cache/loose-envify-1.4.0.tgz deleted file mode 100644 index cc1d1e41..00000000 Binary files a/.yarn-offline-cache/loose-envify-1.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/maplibre-gl-5.18.0.tgz b/.yarn-offline-cache/maplibre-gl-5.18.0.tgz new file mode 100644 index 00000000..2ca197a7 Binary files /dev/null and b/.yarn-offline-cache/maplibre-gl-5.18.0.tgz differ diff --git a/.yarn-offline-cache/minimatch-3.1.2.tgz b/.yarn-offline-cache/minimatch-3.1.2.tgz deleted file mode 100644 index 629d8283..00000000 Binary files a/.yarn-offline-cache/minimatch-3.1.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/minimatch-9.0.5.tgz b/.yarn-offline-cache/minimatch-9.0.5.tgz new file mode 100644 index 00000000..7df70b45 Binary files /dev/null and b/.yarn-offline-cache/minimatch-9.0.5.tgz differ diff --git a/.yarn-offline-cache/minimist-1.2.8.tgz b/.yarn-offline-cache/minimist-1.2.8.tgz new file mode 100644 index 00000000..2653b25e Binary files /dev/null and b/.yarn-offline-cache/minimist-1.2.8.tgz differ diff --git a/.yarn-offline-cache/murmurhash-js-1.0.0.tgz b/.yarn-offline-cache/murmurhash-js-1.0.0.tgz new file mode 100644 index 00000000..3731522a Binary files /dev/null and b/.yarn-offline-cache/murmurhash-js-1.0.0.tgz differ diff --git a/.yarn-offline-cache/natural-compare-1.4.0.tgz b/.yarn-offline-cache/natural-compare-1.4.0.tgz deleted file mode 100644 index 15633e0a..00000000 Binary files a/.yarn-offline-cache/natural-compare-1.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/numcodecs-0.3.2.tgz b/.yarn-offline-cache/numcodecs-0.3.2.tgz deleted file mode 100644 index 54d120d3..00000000 Binary files a/.yarn-offline-cache/numcodecs-0.3.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object-assign-4.1.1.tgz b/.yarn-offline-cache/object-assign-4.1.1.tgz deleted file mode 100644 index 939f2a8d..00000000 Binary files a/.yarn-offline-cache/object-assign-4.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object-inspect-1.13.4.tgz b/.yarn-offline-cache/object-inspect-1.13.4.tgz deleted file mode 100644 index f5cc6c65..00000000 Binary files a/.yarn-offline-cache/object-inspect-1.13.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object-keys-1.1.1.tgz b/.yarn-offline-cache/object-keys-1.1.1.tgz deleted file mode 100644 index 3e52f10d..00000000 Binary files a/.yarn-offline-cache/object-keys-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object.assign-4.1.7.tgz b/.yarn-offline-cache/object.assign-4.1.7.tgz deleted file mode 100644 index 9bcad99c..00000000 Binary files a/.yarn-offline-cache/object.assign-4.1.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object.entries-1.1.9.tgz b/.yarn-offline-cache/object.entries-1.1.9.tgz deleted file mode 100644 index dfdcf006..00000000 Binary files a/.yarn-offline-cache/object.entries-1.1.9.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object.fromentries-2.0.8.tgz b/.yarn-offline-cache/object.fromentries-2.0.8.tgz deleted file mode 100644 index 8671933f..00000000 Binary files a/.yarn-offline-cache/object.fromentries-2.0.8.tgz and /dev/null differ diff --git a/.yarn-offline-cache/object.values-1.2.1.tgz b/.yarn-offline-cache/object.values-1.2.1.tgz deleted file mode 100644 index 5e78cee3..00000000 Binary files a/.yarn-offline-cache/object.values-1.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ol-10.8.0.tgz b/.yarn-offline-cache/ol-10.8.0.tgz deleted file mode 100644 index 511286e0..00000000 Binary files a/.yarn-offline-cache/ol-10.8.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ol-9.2.4.tgz b/.yarn-offline-cache/ol-9.2.4.tgz deleted file mode 100644 index a6e0de60..00000000 Binary files a/.yarn-offline-cache/ol-9.2.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/ol-ext-4.0.37.tgz b/.yarn-offline-cache/ol-ext-4.0.37.tgz deleted file mode 100644 index b6322ac7..00000000 Binary files a/.yarn-offline-cache/ol-ext-4.0.37.tgz and /dev/null differ diff --git a/.yarn-offline-cache/once-1.4.0.tgz b/.yarn-offline-cache/once-1.4.0.tgz deleted file mode 100644 index 23490a8c..00000000 Binary files a/.yarn-offline-cache/once-1.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/optionator-0.9.4.tgz b/.yarn-offline-cache/optionator-0.9.4.tgz deleted file mode 100644 index c4b34cf5..00000000 Binary files a/.yarn-offline-cache/optionator-0.9.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/own-keys-1.0.1.tgz b/.yarn-offline-cache/own-keys-1.0.1.tgz deleted file mode 100644 index 0ef72249..00000000 Binary files a/.yarn-offline-cache/own-keys-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/p-limit-3.1.0.tgz b/.yarn-offline-cache/p-limit-3.1.0.tgz deleted file mode 100644 index 461765ad..00000000 Binary files a/.yarn-offline-cache/p-limit-3.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/p-locate-5.0.0.tgz b/.yarn-offline-cache/p-locate-5.0.0.tgz deleted file mode 100644 index 221fcc26..00000000 Binary files a/.yarn-offline-cache/p-locate-5.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/pako-2.1.0.tgz b/.yarn-offline-cache/pako-2.1.0.tgz deleted file mode 100644 index 85e08da3..00000000 Binary files a/.yarn-offline-cache/pako-2.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/parent-module-1.0.1.tgz b/.yarn-offline-cache/parent-module-1.0.1.tgz deleted file mode 100644 index 036e2d7d..00000000 Binary files a/.yarn-offline-cache/parent-module-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/parse-headers-2.0.6.tgz b/.yarn-offline-cache/parse-headers-2.0.6.tgz deleted file mode 100644 index 97bbbcc7..00000000 Binary files a/.yarn-offline-cache/parse-headers-2.0.6.tgz and /dev/null differ diff --git a/.yarn-offline-cache/path-exists-4.0.0.tgz b/.yarn-offline-cache/path-exists-4.0.0.tgz deleted file mode 100644 index eb702860..00000000 Binary files a/.yarn-offline-cache/path-exists-4.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/path-is-absolute-1.0.1.tgz b/.yarn-offline-cache/path-is-absolute-1.0.1.tgz deleted file mode 100644 index 223459b5..00000000 Binary files a/.yarn-offline-cache/path-is-absolute-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/path-key-3.1.1.tgz b/.yarn-offline-cache/path-key-3.1.1.tgz deleted file mode 100644 index e60ab02d..00000000 Binary files a/.yarn-offline-cache/path-key-3.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/path-parse-1.0.7.tgz b/.yarn-offline-cache/path-parse-1.0.7.tgz deleted file mode 100644 index 35a650ea..00000000 Binary files a/.yarn-offline-cache/path-parse-1.0.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/pbf-3.2.1.tgz b/.yarn-offline-cache/pbf-3.2.1.tgz deleted file mode 100644 index 241220ea..00000000 Binary files a/.yarn-offline-cache/pbf-3.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/pbf-4.0.1.tgz b/.yarn-offline-cache/pbf-4.0.1.tgz deleted file mode 100644 index 11feb07a..00000000 Binary files a/.yarn-offline-cache/pbf-4.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/point-in-polygon-1.1.0.tgz b/.yarn-offline-cache/point-in-polygon-1.1.0.tgz new file mode 100644 index 00000000..945b85aa Binary files /dev/null and b/.yarn-offline-cache/point-in-polygon-1.1.0.tgz differ diff --git a/.yarn-offline-cache/point-in-polygon-hao-1.2.4.tgz b/.yarn-offline-cache/point-in-polygon-hao-1.2.4.tgz new file mode 100644 index 00000000..fb37022c Binary files /dev/null and b/.yarn-offline-cache/point-in-polygon-hao-1.2.4.tgz differ diff --git a/.yarn-offline-cache/polyclip-ts-0.16.8.tgz b/.yarn-offline-cache/polyclip-ts-0.16.8.tgz new file mode 100644 index 00000000..45263660 Binary files /dev/null and b/.yarn-offline-cache/polyclip-ts-0.16.8.tgz differ diff --git a/.yarn-offline-cache/possible-typed-array-names-1.1.0.tgz b/.yarn-offline-cache/possible-typed-array-names-1.1.0.tgz deleted file mode 100644 index 892afb5b..00000000 Binary files a/.yarn-offline-cache/possible-typed-array-names-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/potpack-2.1.0.tgz b/.yarn-offline-cache/potpack-2.1.0.tgz new file mode 100644 index 00000000..d57f78a9 Binary files /dev/null and b/.yarn-offline-cache/potpack-2.1.0.tgz differ diff --git a/.yarn-offline-cache/prelude-ls-1.2.1.tgz b/.yarn-offline-cache/prelude-ls-1.2.1.tgz deleted file mode 100644 index a1bf26f2..00000000 Binary files a/.yarn-offline-cache/prelude-ls-1.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/prop-types-15.8.1.tgz b/.yarn-offline-cache/prop-types-15.8.1.tgz deleted file mode 100644 index 0d0a5803..00000000 Binary files a/.yarn-offline-cache/prop-types-15.8.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/punycode-2.3.1.tgz b/.yarn-offline-cache/punycode-2.3.1.tgz deleted file mode 100644 index 1e9aa566..00000000 Binary files a/.yarn-offline-cache/punycode-2.3.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/querystringify-2.2.0.tgz b/.yarn-offline-cache/querystringify-2.2.0.tgz deleted file mode 100644 index a59cd74e..00000000 Binary files a/.yarn-offline-cache/querystringify-2.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/queue-microtask-1.2.3.tgz b/.yarn-offline-cache/queue-microtask-1.2.3.tgz deleted file mode 100644 index bcdd9db1..00000000 Binary files a/.yarn-offline-cache/queue-microtask-1.2.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/quick-lru-6.1.2.tgz b/.yarn-offline-cache/quick-lru-6.1.2.tgz deleted file mode 100644 index d99b26d4..00000000 Binary files a/.yarn-offline-cache/quick-lru-6.1.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/quickselect-1.1.1.tgz b/.yarn-offline-cache/quickselect-1.1.1.tgz new file mode 100644 index 00000000..39e1208d Binary files /dev/null and b/.yarn-offline-cache/quickselect-1.1.1.tgz differ diff --git a/.yarn-offline-cache/quickselect-3.0.0.tgz b/.yarn-offline-cache/quickselect-3.0.0.tgz deleted file mode 100644 index 933adc7b..00000000 Binary files a/.yarn-offline-cache/quickselect-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/rbush-2.0.2.tgz b/.yarn-offline-cache/rbush-2.0.2.tgz new file mode 100644 index 00000000..7aaea4c8 Binary files /dev/null and b/.yarn-offline-cache/rbush-2.0.2.tgz differ diff --git a/.yarn-offline-cache/rbush-4.0.1.tgz b/.yarn-offline-cache/rbush-4.0.1.tgz deleted file mode 100644 index eaea395f..00000000 Binary files a/.yarn-offline-cache/rbush-4.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/react-18.3.1.tgz b/.yarn-offline-cache/react-18.3.1.tgz deleted file mode 100644 index bbb682a5..00000000 Binary files a/.yarn-offline-cache/react-18.3.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/react-19.2.4.tgz b/.yarn-offline-cache/react-19.2.4.tgz new file mode 100644 index 00000000..ff3e5485 Binary files /dev/null and b/.yarn-offline-cache/react-19.2.4.tgz differ diff --git a/.yarn-offline-cache/react-dom-18.3.1.tgz b/.yarn-offline-cache/react-dom-18.3.1.tgz deleted file mode 100644 index bf997adb..00000000 Binary files a/.yarn-offline-cache/react-dom-18.3.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/react-dom-19.2.4.tgz b/.yarn-offline-cache/react-dom-19.2.4.tgz new file mode 100644 index 00000000..0fe21235 Binary files /dev/null and b/.yarn-offline-cache/react-dom-19.2.4.tgz differ diff --git a/.yarn-offline-cache/react-is-16.13.1.tgz b/.yarn-offline-cache/react-is-16.13.1.tgz deleted file mode 100644 index c4b139e2..00000000 Binary files a/.yarn-offline-cache/react-is-16.13.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/react-refresh-0.17.0.tgz b/.yarn-offline-cache/react-refresh-0.17.0.tgz deleted file mode 100644 index 1c99c076..00000000 Binary files a/.yarn-offline-cache/react-refresh-0.17.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/react-refresh-0.18.0.tgz b/.yarn-offline-cache/react-refresh-0.18.0.tgz new file mode 100644 index 00000000..80b2d86f Binary files /dev/null and b/.yarn-offline-cache/react-refresh-0.18.0.tgz differ diff --git a/.yarn-offline-cache/reference-spec-reader-0.2.0.tgz b/.yarn-offline-cache/reference-spec-reader-0.2.0.tgz deleted file mode 100644 index 8340541d..00000000 Binary files a/.yarn-offline-cache/reference-spec-reader-0.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/reflect.getprototypeof-1.0.10.tgz b/.yarn-offline-cache/reflect.getprototypeof-1.0.10.tgz deleted file mode 100644 index 28541229..00000000 Binary files a/.yarn-offline-cache/reflect.getprototypeof-1.0.10.tgz and /dev/null differ diff --git a/.yarn-offline-cache/regexp.prototype.flags-1.5.4.tgz b/.yarn-offline-cache/regexp.prototype.flags-1.5.4.tgz deleted file mode 100644 index 7096aed7..00000000 Binary files a/.yarn-offline-cache/regexp.prototype.flags-1.5.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/requires-port-1.0.0.tgz b/.yarn-offline-cache/requires-port-1.0.0.tgz deleted file mode 100644 index 4817b28e..00000000 Binary files a/.yarn-offline-cache/requires-port-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/resolve-2.0.0-next.5.tgz b/.yarn-offline-cache/resolve-2.0.0-next.5.tgz deleted file mode 100644 index c49ad89e..00000000 Binary files a/.yarn-offline-cache/resolve-2.0.0-next.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/resolve-from-4.0.0.tgz b/.yarn-offline-cache/resolve-from-4.0.0.tgz deleted file mode 100644 index d5f63f79..00000000 Binary files a/.yarn-offline-cache/resolve-from-4.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/reusify-1.1.0.tgz b/.yarn-offline-cache/reusify-1.1.0.tgz deleted file mode 100644 index c9599f92..00000000 Binary files a/.yarn-offline-cache/reusify-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/rimraf-3.0.2.tgz b/.yarn-offline-cache/rimraf-3.0.2.tgz deleted file mode 100644 index b385c801..00000000 Binary files a/.yarn-offline-cache/rimraf-3.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/robust-predicates-2.0.4.tgz b/.yarn-offline-cache/robust-predicates-2.0.4.tgz new file mode 100644 index 00000000..3b795a52 Binary files /dev/null and b/.yarn-offline-cache/robust-predicates-2.0.4.tgz differ diff --git a/.yarn-offline-cache/robust-predicates-3.0.2.tgz b/.yarn-offline-cache/robust-predicates-3.0.2.tgz new file mode 100644 index 00000000..c9a3f965 Binary files /dev/null and b/.yarn-offline-cache/robust-predicates-3.0.2.tgz differ diff --git a/.yarn-offline-cache/run-parallel-1.2.0.tgz b/.yarn-offline-cache/run-parallel-1.2.0.tgz deleted file mode 100644 index 2aad4c46..00000000 Binary files a/.yarn-offline-cache/run-parallel-1.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/rw-1.3.3.tgz b/.yarn-offline-cache/rw-1.3.3.tgz new file mode 100644 index 00000000..435c646d Binary files /dev/null and b/.yarn-offline-cache/rw-1.3.3.tgz differ diff --git a/.yarn-offline-cache/safe-array-concat-1.1.3.tgz b/.yarn-offline-cache/safe-array-concat-1.1.3.tgz deleted file mode 100644 index 3e702fa6..00000000 Binary files a/.yarn-offline-cache/safe-array-concat-1.1.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/safe-buffer-5.2.1.tgz b/.yarn-offline-cache/safe-buffer-5.2.1.tgz deleted file mode 100644 index 2d47db6c..00000000 Binary files a/.yarn-offline-cache/safe-buffer-5.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/safe-push-apply-1.0.0.tgz b/.yarn-offline-cache/safe-push-apply-1.0.0.tgz deleted file mode 100644 index b30481bb..00000000 Binary files a/.yarn-offline-cache/safe-push-apply-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/safe-regex-test-1.1.0.tgz b/.yarn-offline-cache/safe-regex-test-1.1.0.tgz deleted file mode 100644 index 300d5add..00000000 Binary files a/.yarn-offline-cache/safe-regex-test-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/scheduler-0.23.2.tgz b/.yarn-offline-cache/scheduler-0.23.2.tgz deleted file mode 100644 index 67c32cb3..00000000 Binary files a/.yarn-offline-cache/scheduler-0.23.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/scheduler-0.27.0.tgz b/.yarn-offline-cache/scheduler-0.27.0.tgz new file mode 100644 index 00000000..64fe0b16 Binary files /dev/null and b/.yarn-offline-cache/scheduler-0.27.0.tgz differ diff --git a/.yarn-offline-cache/semver-7.7.4.tgz b/.yarn-offline-cache/semver-7.7.4.tgz new file mode 100644 index 00000000..559ec6b2 Binary files /dev/null and b/.yarn-offline-cache/semver-7.7.4.tgz differ diff --git a/.yarn-offline-cache/set-function-length-1.2.2.tgz b/.yarn-offline-cache/set-function-length-1.2.2.tgz deleted file mode 100644 index c18a1a53..00000000 Binary files a/.yarn-offline-cache/set-function-length-1.2.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/set-function-name-2.0.2.tgz b/.yarn-offline-cache/set-function-name-2.0.2.tgz deleted file mode 100644 index f2341a38..00000000 Binary files a/.yarn-offline-cache/set-function-name-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/set-proto-1.0.0.tgz b/.yarn-offline-cache/set-proto-1.0.0.tgz deleted file mode 100644 index 62fdf22a..00000000 Binary files a/.yarn-offline-cache/set-proto-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/shebang-command-2.0.0.tgz b/.yarn-offline-cache/shebang-command-2.0.0.tgz deleted file mode 100644 index 2081b800..00000000 Binary files a/.yarn-offline-cache/shebang-command-2.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/shebang-regex-3.0.0.tgz b/.yarn-offline-cache/shebang-regex-3.0.0.tgz deleted file mode 100644 index fb3aa399..00000000 Binary files a/.yarn-offline-cache/shebang-regex-3.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/side-channel-1.1.0.tgz b/.yarn-offline-cache/side-channel-1.1.0.tgz deleted file mode 100644 index cbeae4a1..00000000 Binary files a/.yarn-offline-cache/side-channel-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/side-channel-list-1.0.0.tgz b/.yarn-offline-cache/side-channel-list-1.0.0.tgz deleted file mode 100644 index 8ec6fe05..00000000 Binary files a/.yarn-offline-cache/side-channel-list-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/side-channel-map-1.0.1.tgz b/.yarn-offline-cache/side-channel-map-1.0.1.tgz deleted file mode 100644 index e6eb4548..00000000 Binary files a/.yarn-offline-cache/side-channel-map-1.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/side-channel-weakmap-1.0.2.tgz b/.yarn-offline-cache/side-channel-weakmap-1.0.2.tgz deleted file mode 100644 index a66a86b9..00000000 Binary files a/.yarn-offline-cache/side-channel-weakmap-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/skmeans-0.9.7.tgz b/.yarn-offline-cache/skmeans-0.9.7.tgz new file mode 100644 index 00000000..64abc1a9 Binary files /dev/null and b/.yarn-offline-cache/skmeans-0.9.7.tgz differ diff --git a/.yarn-offline-cache/slice-source-0.4.1.tgz b/.yarn-offline-cache/slice-source-0.4.1.tgz deleted file mode 100644 index 33f3c589..00000000 Binary files a/.yarn-offline-cache/slice-source-0.4.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/sockjs-client-1.6.1.tgz b/.yarn-offline-cache/sockjs-client-1.6.1.tgz deleted file mode 100644 index 6f700548..00000000 Binary files a/.yarn-offline-cache/sockjs-client-1.6.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/splaytree-ts-1.0.2.tgz b/.yarn-offline-cache/splaytree-ts-1.0.2.tgz new file mode 100644 index 00000000..6923e72b Binary files /dev/null and b/.yarn-offline-cache/splaytree-ts-1.0.2.tgz differ diff --git a/.yarn-offline-cache/stop-iteration-iterator-1.1.0.tgz b/.yarn-offline-cache/stop-iteration-iterator-1.1.0.tgz deleted file mode 100644 index 56533c1a..00000000 Binary files a/.yarn-offline-cache/stop-iteration-iterator-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/string.prototype.matchall-4.0.12.tgz b/.yarn-offline-cache/string.prototype.matchall-4.0.12.tgz deleted file mode 100644 index 9aab21f4..00000000 Binary files a/.yarn-offline-cache/string.prototype.matchall-4.0.12.tgz and /dev/null differ diff --git a/.yarn-offline-cache/string.prototype.repeat-1.0.0.tgz b/.yarn-offline-cache/string.prototype.repeat-1.0.0.tgz deleted file mode 100644 index 21f65e32..00000000 Binary files a/.yarn-offline-cache/string.prototype.repeat-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/string.prototype.trim-1.2.10.tgz b/.yarn-offline-cache/string.prototype.trim-1.2.10.tgz deleted file mode 100644 index 0a46331f..00000000 Binary files a/.yarn-offline-cache/string.prototype.trim-1.2.10.tgz and /dev/null differ diff --git a/.yarn-offline-cache/string.prototype.trimend-1.0.9.tgz b/.yarn-offline-cache/string.prototype.trimend-1.0.9.tgz deleted file mode 100644 index a61f9b79..00000000 Binary files a/.yarn-offline-cache/string.prototype.trimend-1.0.9.tgz and /dev/null differ diff --git a/.yarn-offline-cache/string.prototype.trimstart-1.0.8.tgz b/.yarn-offline-cache/string.prototype.trimstart-1.0.8.tgz deleted file mode 100644 index bc2544ca..00000000 Binary files a/.yarn-offline-cache/string.prototype.trimstart-1.0.8.tgz and /dev/null differ diff --git a/.yarn-offline-cache/strip-ansi-6.0.1.tgz b/.yarn-offline-cache/strip-ansi-6.0.1.tgz deleted file mode 100644 index 14041ae0..00000000 Binary files a/.yarn-offline-cache/strip-ansi-6.0.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/strip-json-comments-3.1.1.tgz b/.yarn-offline-cache/strip-json-comments-3.1.1.tgz deleted file mode 100644 index 8f2216ec..00000000 Binary files a/.yarn-offline-cache/strip-json-comments-3.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/supercluster-8.0.1.tgz b/.yarn-offline-cache/supercluster-8.0.1.tgz new file mode 100644 index 00000000..71cf2b47 Binary files /dev/null and b/.yarn-offline-cache/supercluster-8.0.1.tgz differ diff --git a/.yarn-offline-cache/supports-color-7.2.0.tgz b/.yarn-offline-cache/supports-color-7.2.0.tgz deleted file mode 100644 index 07183d29..00000000 Binary files a/.yarn-offline-cache/supports-color-7.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/supports-preserve-symlinks-flag-1.0.0.tgz b/.yarn-offline-cache/supports-preserve-symlinks-flag-1.0.0.tgz deleted file mode 100644 index 00fca76d..00000000 Binary files a/.yarn-offline-cache/supports-preserve-symlinks-flag-1.0.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/sweepline-intersections-1.5.0.tgz b/.yarn-offline-cache/sweepline-intersections-1.5.0.tgz new file mode 100644 index 00000000..5b88baf4 Binary files /dev/null and b/.yarn-offline-cache/sweepline-intersections-1.5.0.tgz differ diff --git a/.yarn-offline-cache/text-table-0.2.0.tgz b/.yarn-offline-cache/text-table-0.2.0.tgz deleted file mode 100644 index 0d93cd59..00000000 Binary files a/.yarn-offline-cache/text-table-0.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/tinyglobby-0.2.15.tgz b/.yarn-offline-cache/tinyglobby-0.2.15.tgz new file mode 100644 index 00000000..35b4cc05 Binary files /dev/null and b/.yarn-offline-cache/tinyglobby-0.2.15.tgz differ diff --git a/.yarn-offline-cache/tinyqueue-2.0.3.tgz b/.yarn-offline-cache/tinyqueue-2.0.3.tgz new file mode 100644 index 00000000..5d28d117 Binary files /dev/null and b/.yarn-offline-cache/tinyqueue-2.0.3.tgz differ diff --git a/.yarn-offline-cache/tinyqueue-3.0.0.tgz b/.yarn-offline-cache/tinyqueue-3.0.0.tgz new file mode 100644 index 00000000..c6647eb9 Binary files /dev/null and b/.yarn-offline-cache/tinyqueue-3.0.0.tgz differ diff --git a/.yarn-offline-cache/topojson-client-3.1.0.tgz b/.yarn-offline-cache/topojson-client-3.1.0.tgz new file mode 100644 index 00000000..716087fe Binary files /dev/null and b/.yarn-offline-cache/topojson-client-3.1.0.tgz differ diff --git a/.yarn-offline-cache/topojson-server-3.0.1.tgz b/.yarn-offline-cache/topojson-server-3.0.1.tgz new file mode 100644 index 00000000..9a5abfdf Binary files /dev/null and b/.yarn-offline-cache/topojson-server-3.0.1.tgz differ diff --git a/.yarn-offline-cache/ts-api-utils-2.4.0.tgz b/.yarn-offline-cache/ts-api-utils-2.4.0.tgz new file mode 100644 index 00000000..c5880897 Binary files /dev/null and b/.yarn-offline-cache/ts-api-utils-2.4.0.tgz differ diff --git a/.yarn-offline-cache/tslib-2.8.1.tgz b/.yarn-offline-cache/tslib-2.8.1.tgz new file mode 100644 index 00000000..a1e8b078 Binary files /dev/null and b/.yarn-offline-cache/tslib-2.8.1.tgz differ diff --git a/.yarn-offline-cache/type-check-0.4.0.tgz b/.yarn-offline-cache/type-check-0.4.0.tgz deleted file mode 100644 index 4a3df6f2..00000000 Binary files a/.yarn-offline-cache/type-check-0.4.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/type-fest-0.20.2.tgz b/.yarn-offline-cache/type-fest-0.20.2.tgz deleted file mode 100644 index 0ac67554..00000000 Binary files a/.yarn-offline-cache/type-fest-0.20.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/typed-array-buffer-1.0.3.tgz b/.yarn-offline-cache/typed-array-buffer-1.0.3.tgz deleted file mode 100644 index 6d44c8a3..00000000 Binary files a/.yarn-offline-cache/typed-array-buffer-1.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/typed-array-byte-length-1.0.3.tgz b/.yarn-offline-cache/typed-array-byte-length-1.0.3.tgz deleted file mode 100644 index bf565777..00000000 Binary files a/.yarn-offline-cache/typed-array-byte-length-1.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/typed-array-byte-offset-1.0.4.tgz b/.yarn-offline-cache/typed-array-byte-offset-1.0.4.tgz deleted file mode 100644 index f1281466..00000000 Binary files a/.yarn-offline-cache/typed-array-byte-offset-1.0.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/typed-array-length-1.0.7.tgz b/.yarn-offline-cache/typed-array-length-1.0.7.tgz deleted file mode 100644 index 3a775d39..00000000 Binary files a/.yarn-offline-cache/typed-array-length-1.0.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/typescript-5.7.3.tgz b/.yarn-offline-cache/typescript-5.7.3.tgz new file mode 100644 index 00000000..86b1ff14 Binary files /dev/null and b/.yarn-offline-cache/typescript-5.7.3.tgz differ diff --git a/.yarn-offline-cache/typescript-eslint-8.55.0.tgz b/.yarn-offline-cache/typescript-eslint-8.55.0.tgz new file mode 100644 index 00000000..279a522e Binary files /dev/null and b/.yarn-offline-cache/typescript-eslint-8.55.0.tgz differ diff --git a/.yarn-offline-cache/unbox-primitive-1.1.0.tgz b/.yarn-offline-cache/unbox-primitive-1.1.0.tgz deleted file mode 100644 index 4d2dec26..00000000 Binary files a/.yarn-offline-cache/unbox-primitive-1.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/undici-types-6.21.0.tgz b/.yarn-offline-cache/undici-types-6.21.0.tgz new file mode 100644 index 00000000..2e6a7395 Binary files /dev/null and b/.yarn-offline-cache/undici-types-6.21.0.tgz differ diff --git a/.yarn-offline-cache/unzipit-1.4.3.tgz b/.yarn-offline-cache/unzipit-1.4.3.tgz deleted file mode 100644 index eacbcf6d..00000000 Binary files a/.yarn-offline-cache/unzipit-1.4.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/uri-js-4.4.1.tgz b/.yarn-offline-cache/uri-js-4.4.1.tgz deleted file mode 100644 index 9886fbe4..00000000 Binary files a/.yarn-offline-cache/uri-js-4.4.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/url-parse-1.5.10.tgz b/.yarn-offline-cache/url-parse-1.5.10.tgz deleted file mode 100644 index 94d336da..00000000 Binary files a/.yarn-offline-cache/url-parse-1.5.10.tgz and /dev/null differ diff --git a/.yarn-offline-cache/use-sync-external-store-1.6.0.tgz b/.yarn-offline-cache/use-sync-external-store-1.6.0.tgz deleted file mode 100644 index 2f8949f1..00000000 Binary files a/.yarn-offline-cache/use-sync-external-store-1.6.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/uzip-module-1.0.3.tgz b/.yarn-offline-cache/uzip-module-1.0.3.tgz deleted file mode 100644 index d69d8d3d..00000000 Binary files a/.yarn-offline-cache/uzip-module-1.0.3.tgz and /dev/null differ diff --git a/.yarn-offline-cache/vite-5.4.21.tgz b/.yarn-offline-cache/vite-5.4.21.tgz deleted file mode 100644 index 234f7752..00000000 Binary files a/.yarn-offline-cache/vite-5.4.21.tgz and /dev/null differ diff --git a/.yarn-offline-cache/vite-7.3.1.tgz b/.yarn-offline-cache/vite-7.3.1.tgz new file mode 100644 index 00000000..3b140ac2 Binary files /dev/null and b/.yarn-offline-cache/vite-7.3.1.tgz differ diff --git a/.yarn-offline-cache/web-worker-1.5.0.tgz b/.yarn-offline-cache/web-worker-1.5.0.tgz deleted file mode 100644 index f5c87727..00000000 Binary files a/.yarn-offline-cache/web-worker-1.5.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/websocket-driver-0.7.4.tgz b/.yarn-offline-cache/websocket-driver-0.7.4.tgz deleted file mode 100644 index 0ecfe541..00000000 Binary files a/.yarn-offline-cache/websocket-driver-0.7.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/websocket-extensions-0.1.4.tgz b/.yarn-offline-cache/websocket-extensions-0.1.4.tgz deleted file mode 100644 index 6f296520..00000000 Binary files a/.yarn-offline-cache/websocket-extensions-0.1.4.tgz and /dev/null differ diff --git a/.yarn-offline-cache/which-2.0.2.tgz b/.yarn-offline-cache/which-2.0.2.tgz deleted file mode 100644 index 865d6b66..00000000 Binary files a/.yarn-offline-cache/which-2.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/which-boxed-primitive-1.1.1.tgz b/.yarn-offline-cache/which-boxed-primitive-1.1.1.tgz deleted file mode 100644 index c09eabff..00000000 Binary files a/.yarn-offline-cache/which-boxed-primitive-1.1.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/which-builtin-type-1.2.1.tgz b/.yarn-offline-cache/which-builtin-type-1.2.1.tgz deleted file mode 100644 index 614b7778..00000000 Binary files a/.yarn-offline-cache/which-builtin-type-1.2.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/which-collection-1.0.2.tgz b/.yarn-offline-cache/which-collection-1.0.2.tgz deleted file mode 100644 index 7c26b015..00000000 Binary files a/.yarn-offline-cache/which-collection-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/which-typed-array-1.1.20.tgz b/.yarn-offline-cache/which-typed-array-1.1.20.tgz deleted file mode 100644 index 29a84760..00000000 Binary files a/.yarn-offline-cache/which-typed-array-1.1.20.tgz and /dev/null differ diff --git a/.yarn-offline-cache/word-wrap-1.2.5.tgz b/.yarn-offline-cache/word-wrap-1.2.5.tgz deleted file mode 100644 index 2c52fd76..00000000 Binary files a/.yarn-offline-cache/word-wrap-1.2.5.tgz and /dev/null differ diff --git a/.yarn-offline-cache/wrappy-1.0.2.tgz b/.yarn-offline-cache/wrappy-1.0.2.tgz deleted file mode 100644 index d0fb26b9..00000000 Binary files a/.yarn-offline-cache/wrappy-1.0.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/xml-utils-1.10.2.tgz b/.yarn-offline-cache/xml-utils-1.10.2.tgz deleted file mode 100644 index d8bc19ac..00000000 Binary files a/.yarn-offline-cache/xml-utils-1.10.2.tgz and /dev/null differ diff --git a/.yarn-offline-cache/yocto-queue-0.1.0.tgz b/.yarn-offline-cache/yocto-queue-0.1.0.tgz deleted file mode 100644 index 8ede4a59..00000000 Binary files a/.yarn-offline-cache/yocto-queue-0.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zarrita-0.6.1.tgz b/.yarn-offline-cache/zarrita-0.6.1.tgz deleted file mode 100644 index 9ce53f3a..00000000 Binary files a/.yarn-offline-cache/zarrita-0.6.1.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zod-4.3.6.tgz b/.yarn-offline-cache/zod-4.3.6.tgz new file mode 100644 index 00000000..246ccb13 Binary files /dev/null and b/.yarn-offline-cache/zod-4.3.6.tgz differ diff --git a/.yarn-offline-cache/zod-validation-error-4.0.2.tgz b/.yarn-offline-cache/zod-validation-error-4.0.2.tgz new file mode 100644 index 00000000..717068fe Binary files /dev/null and b/.yarn-offline-cache/zod-validation-error-4.0.2.tgz differ diff --git a/.yarn-offline-cache/zstddec-0.1.0.tgz b/.yarn-offline-cache/zstddec-0.1.0.tgz deleted file mode 100644 index 93eb7ef6..00000000 Binary files a/.yarn-offline-cache/zstddec-0.1.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zstddec-0.2.0.tgz b/.yarn-offline-cache/zstddec-0.2.0.tgz deleted file mode 100644 index 69000063..00000000 Binary files a/.yarn-offline-cache/zstddec-0.2.0.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zustand-4.5.7.tgz b/.yarn-offline-cache/zustand-4.5.7.tgz deleted file mode 100644 index 26ebdeb5..00000000 Binary files a/.yarn-offline-cache/zustand-4.5.7.tgz and /dev/null differ diff --git a/.yarn-offline-cache/zustand-5.0.11.tgz b/.yarn-offline-cache/zustand-5.0.11.tgz new file mode 100644 index 00000000..8e989838 Binary files /dev/null and b/.yarn-offline-cache/zustand-5.0.11.tgz differ diff --git a/CLAUDE.md b/CLAUDE.md index 3cd8426e..b82b2c52 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,12 +5,14 @@ 민간용 데모 버전으로, OSM + OpenSeaMap 기반 지도와 AIS API 폴링 방식의 선박 데이터를 사용. ## 기술 스택 -- **프레임워크**: React 18 + Vite 5 +- **언어**: TypeScript (strict 모드) +- **프레임워크**: React 19 + Vite 7 - **지도 엔진**: OpenLayers 9 + Deck.gl 9 (MapLibre 전환 예정) -- **상태관리**: Zustand 4 +- **상태관리**: Zustand 5 - **HTTP**: Axios - **스타일**: SASS -- **라우팅**: React Router DOM 6 +- **라우팅**: React Router DOM 6 (v7 future flag 적용) +- **Lint**: ESLint 9 flat config + typescript-eslint 8 ## 빌드 / 실행 ```bash @@ -47,16 +49,38 @@ src/ ├── scss/ # 글로벌 SCSS ├── stores/ # Zustand 스토어 (ship, map, auth, tracking 등) ├── tracking/ # 항적조회 모듈 -├── types/ # 상수 정의 +├── types/ # 타입 정의 (ship.ts, constants.ts, global.d.ts 등) ├── utils/ # 유틸리티 └── workers/ # Web Worker (signalWorker) ``` +## TypeScript / Lint 설정 +- `tsconfig.json`: composite 프로젝트 (tsconfig.app.json + tsconfig.node.json) +- `eslint.config.js`: ESLint 9 flat config (typescript-eslint 8, react-hooks 7) +- pre-commit hook: `npx eslint src/ --quiet` +- 타입 체크: `npx tsc -b --noEmit` + ## Git 저장소 - **Remote**: https://gitea.gc-si.dev/gc/ship-gis.git -- **브랜치**: main (보호), develop (작업 브랜치) +- **브랜치**: main (보호), develop (작업 브랜치), chore/react19-vite7 (Step 1) ## 팀 워크플로우 - 버전: v1.2.0 - 커밋 형식: Conventional Commits (한/영 혼용) - 브랜치 전략: main ← develop ← feature/* + +## Phase 3 계획 (React 19 + Vite 7 + MapLibre 전환) + +상세 계획: `/Users/lht/.claude/plans/glittery-zooming-feigenbaum.md` + +### Step 1: React 19 + Vite 7 + ESLint 9 업그레이드 (완료) +- 브랜치: `chore/react19-vite7` (develop 병합 대기) +- React 19.2.4, Vite 7.3.1, ESLint 9.39.2, Zustand 5.0.11 +- sockjs-client, flatgeobuf 제거 (@stomp/stompjs는 리플레이 모듈 사용으로 유지) + +### Step 2: OpenLayers → MapLibre GL JS 전환 +- 브랜치: `refactor/maplibre-migration` (Step 1 병합 후) +- `@deck.gl/mapbox` MapboxOverlay로 Deck.gl 자동 통합 +- OL EPSG:3857 변환 제거 → MapLibre LngLat(4326) 직접 사용 +- `@turf/turf`로 측정/좌표 계산 대체 +- 22개 기존 파일 수정 + projection.ts 신규 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 00000000..4d820488 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,34 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'warn', + '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], + // react-hooks v7 신규 규칙 — 기존 코드 점진적 수정을 위해 warn + 'react-hooks/set-state-in-effect': 'warn', + 'react-hooks/refs': 'warn', + 'react-hooks/immutability': 'warn', + }, + }, +); diff --git a/index.html b/index.html index 21e282dd..6d59e12d 100644 --- a/index.html +++ b/index.html @@ -11,6 +11,6 @@
- + diff --git a/package.json b/package.json index f07f7ddf..ad077b17 100644 --- a/package.json +++ b/package.json @@ -5,41 +5,47 @@ "type": "module", "scripts": { "dev": "vite --port 3000", - "build": "vite build", - "build:dev": "vite build --mode dev", - "build:qa": "vite build --mode qa", - "build:prod": "vite build", + "build": "tsc -b && vite build", + "build:dev": "tsc -b && vite build --mode dev", + "build:qa": "tsc -b && vite build --mode qa", + "build:prod": "tsc -b && vite build", "preview": "vite preview --port 3000", "preview:dev": "vite preview --mode dev --port 3000", "preview:qa": "vite preview --mode qa --port 3000", - "lint": "eslint src --ext js,jsx --report-unused-disable-directives --max-warnings 0" + "type-check": "tsc -b --noEmit", + "lint": "eslint src" }, "dependencies": { "@deck.gl/core": "^9.2.6", "@deck.gl/extensions": "^9.2.6", "@deck.gl/geo-layers": "^9.2.6", "@deck.gl/layers": "^9.2.6", + "@deck.gl/mapbox": "^9.2.7", "@deck.gl/mesh-layers": "^9.2.6", "@stomp/stompjs": "^7.2.1", + "@turf/turf": "^7.3.4", "axios": "^1.4.0", "dayjs": "^1.11.11", - "flatgeobuf": "^4.4.0", "html2canvas": "^1.4.1", - "ol": "^9.2.4", - "ol-ext": "^4.0.10", - "react": "^18.2.0", - "react-dom": "^18.2.0", + "maplibre-gl": "^5.18.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", "react-router-dom": "^6.30.3", - "sockjs-client": "^1.6.1", - "zustand": "^4.5.2" + "zustand": "^5" }, "devDependencies": { - "@vitejs/plugin-react": "^4.0.1", - "eslint": "^8.44.0", - "eslint-plugin-react": "^7.34.1", - "eslint-plugin-react-hooks": "^4.6.0", - "eslint-plugin-react-refresh": "^0.4.1", - "sass": "^1.77.8", - "vite": "^5.2.10" + "@eslint/js": "^9.39.2", + "@types/node": "^22.10.5", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.4", + "eslint": "^9.39.2", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "sass": "^1.97.3", + "typescript": "~5.7.2", + "typescript-eslint": "^8.55.0", + "vite": "^7.3.1" } } diff --git a/src/App.jsx b/src/App.tsx similarity index 100% rename from src/App.jsx rename to src/App.tsx diff --git a/src/api/aisTargetApi.js b/src/api/aisTargetApi.ts similarity index 66% rename from src/api/aisTargetApi.js rename to src/api/aisTargetApi.ts index 3c92d68f..a981e7cb 100644 --- a/src/api/aisTargetApi.js +++ b/src/api/aisTargetApi.ts @@ -3,15 +3,44 @@ * SNP-Batch 서버의 AIS 데이터를 HTTP 폴링으로 조회 */ import axios from 'axios'; +import type { ShipFeature } from '../types/ship'; -const BASE_URL = import.meta.env.VITE_API_URL || ''; +// dev: Vite 프록시 (/snp-api → 211.208.115.83:8041) +// prod: 환경변수로 직접 지정 +const BASE_URL: string = import.meta.env.VITE_API_URL || '/snp-api'; + +/** AIS Target API 응답 단건 인터페이스 */ +interface AisTargetResponse { + mmsi?: number | string; + imo?: number | string; + name?: string; + callsign?: string; + vesselType?: string; + lat?: number; + lon?: number; + heading?: number; + sog?: number; + cog?: number; + rot?: number; + length?: number; + width?: number; + draught?: number | string; + destination?: string; + eta?: string; + status?: string; + messageTimestamp?: string; + receivedDate?: string; + source?: string; + classType?: string; + signalKindCode?: string; +} /** * AIS 타겟 검색 (최근 N분 데이터) * @param {number} minutes - 조회 기간 (분) - * @returns {Promise} AIS 타겟 데이터 배열 + * @returns {Promise} AIS 타겟 데이터 배열 */ -export async function searchAisTargets(minutes = 60) { +export async function searchAisTargets(minutes: number = 60): Promise { const res = await axios.get(`${BASE_URL}/api/ais-target/search`, { params: { minutes }, timeout: 30000, @@ -28,12 +57,13 @@ export async function searchAisTargets(minutes = 60) { * destination, eta, status, messageTimestamp, receivedDate, * source, classType * - * @param {Object} aisTarget - API 응답 단건 - * @returns {Object} shipStore 호환 feature 객체 + * @param {AisTargetResponse} aisTarget - API 응답 단건 + * @returns {ShipFeature} shipStore 호환 feature 객체 */ -export function aisTargetToFeature(aisTarget) { +export function aisTargetToFeature(aisTarget: AisTargetResponse): ShipFeature { const mmsi = String(aisTarget.mmsi || ''); - const signalKindCode = mapVesselTypeToKindCode(aisTarget.vesselType); + // 백엔드에서 signalKindCode를 직접 제공, 없으면 vesselType 기반 fallback + const signalKindCode = aisTarget.signalKindCode || mapVesselTypeToKindCode(aisTarget.vesselType); return { // 고유 식별자 (AIS 신호원 코드 + MMSI) @@ -106,7 +136,7 @@ export function aisTargetToFeature(aisTarget) { /** * vesselType 문자열 → 선종 코드 매핑 */ -function mapVesselTypeToKindCode(vesselType) { +function mapVesselTypeToKindCode(vesselType: string | undefined): string { if (!vesselType) return '000027'; // 일반 const vt = vesselType.toLowerCase(); @@ -122,11 +152,11 @@ function mapVesselTypeToKindCode(vesselType) { /** * ISO 타임스탬프 → "YYYYMMDDHHmmss" 형식 변환 */ -function formatTimestamp(isoString) { +function formatTimestamp(isoString: string | undefined): string { if (!isoString) return ''; try { const d = new Date(isoString); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`; } catch { return ''; diff --git a/src/api/commonApi.js b/src/api/commonApi.ts similarity index 70% rename from src/api/commonApi.js rename to src/api/commonApi.ts index b3629b52..271d8699 100644 --- a/src/api/commonApi.js +++ b/src/api/commonApi.ts @@ -5,13 +5,20 @@ import { fetchWithAuth } from './fetchWithAuth'; const COMMON_CODE_LIST_ENDPOINT = '/api/cmn/code/list/search'; +/** 공통코드 아이템 인터페이스 */ +export interface CommonCodeItem { + commonCodeTypeName: string; + commonCodeTypeNumber: string; + commonCodeEtc: string; +} + /** * 공통코드 목록 조회 * * @param {string} commonCodeTypeNumber - 공통코드 유형 번호 - * @returns {Promise>} + * @returns {Promise} */ -export async function fetchCommonCodeList(commonCodeTypeNumber) { +export async function fetchCommonCodeList(commonCodeTypeNumber: string): Promise { try { const response = await fetchWithAuth(COMMON_CODE_LIST_ENDPOINT, { method: 'POST', diff --git a/src/api/favoriteApi.js b/src/api/favoriteApi.ts similarity index 57% rename from src/api/favoriteApi.js rename to src/api/favoriteApi.ts index b1ff9a59..8b26d9e6 100644 --- a/src/api/favoriteApi.js +++ b/src/api/favoriteApi.ts @@ -1,10 +1,22 @@ import { fetchWithAuth } from './fetchWithAuth'; +/** 관심선박 API 응답 아이템 */ +export interface FavoriteShipItem { + signalSourceCode?: string; + targetId?: string; + [key: string]: unknown; +} + +/** 관심구역 API 응답 아이템 */ +export interface RealmItem { + [key: string]: unknown; +} + /** * 관심선박 목록 조회 - * @returns {Promise} 관심선박 목록 + * @returns {Promise} 관심선박 목록 */ -export async function fetchFavoriteShips() { +export async function fetchFavoriteShips(): Promise { const response = await fetchWithAuth('/api/gis/my/dashboard/ship/attention/static/search'); if (!response.ok) throw new Error(`HTTP ${response.status}`); const result = await response.json(); @@ -13,9 +25,9 @@ export async function fetchFavoriteShips() { /** * 관심구역 목록 조회 - * @returns {Promise} 관심구역 목록 + * @returns {Promise} 관심구역 목록 */ -export async function fetchRealms() { +export async function fetchRealms(): Promise { const response = await fetchWithAuth('/api/gis/sea-relm/manage/show', { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/api/fetchWithAuth.js b/src/api/fetchWithAuth.ts similarity index 87% rename from src/api/fetchWithAuth.js rename to src/api/fetchWithAuth.ts index 59befcb7..39151b94 100644 --- a/src/api/fetchWithAuth.js +++ b/src/api/fetchWithAuth.ts @@ -7,7 +7,7 @@ import { SESSION_TIMEOUT_MS } from '../types/constants'; * - 사후 체크: 4011 응답 감지 (세션 만료) * - credentials: 'include' 자동 설정 */ -export async function fetchWithAuth(url, options = {}) { +export async function fetchWithAuth(url: string, options: RequestInit = {}): Promise { // 로컬 개발: 세션 타임아웃 체크 우회 if (import.meta.env.VITE_DEV_SKIP_AUTH !== 'true') { const loginDate = localStorage.getItem('loginDate'); @@ -32,7 +32,7 @@ export async function fetchWithAuth(url, options = {}) { throw new Error('Session expired (4011)'); } } catch (e) { - if (e.message.includes('Session expired')) throw e; + if (e instanceof Error && e.message.includes('Session expired')) throw e; } } } diff --git a/src/api/signalApi.js b/src/api/signalApi.ts similarity index 83% rename from src/api/signalApi.js rename to src/api/signalApi.ts index da1f874a..93810886 100644 --- a/src/api/signalApi.js +++ b/src/api/signalApi.ts @@ -4,14 +4,15 @@ */ import { parsePipeMessage, rowToShipObject } from '../common/stompClient'; +import type { ShipFeature } from '../types/ship'; /** * 12분 이내 전체 선박 신호 조회 * STOMP 구독 전에 호출하여 초기 선박 데이터 로드 * - * @returns {Promise} 선박 데이터 배열 + * @returns {Promise} 선박 데이터 배열 */ -export async function fetchAllSignals() { +export async function fetchAllSignals(): Promise { try { const response = await fetch('/signal-api/all/12'); @@ -30,10 +31,10 @@ export async function fetchAllSignals() { } // 각 행을 선박 객체로 변환 - const ships = rawData.map((row) => { + const ships: ShipFeature[] = rawData.map((row: string | string[]) => { // row가 문자열이면 파이프로 파싱, 배열이면 그대로 사용 const parsed = typeof row === 'string' ? parsePipeMessage(row) : row; - return rowToShipObject(parsed); + return rowToShipObject(parsed) as ShipFeature; }); // 좌표가 있는 선박만 필터링 @@ -54,7 +55,7 @@ export async function fetchAllSignals() { * * @returns {Promise} 파이프 구분 문자열 배열 */ -export async function fetchAllSignalsRaw() { +export async function fetchAllSignalsRaw(): Promise { try { const response = await fetch('/signal-api/all/12'); @@ -73,7 +74,7 @@ export async function fetchAllSignalsRaw() { } // 문자열 배열로 변환 (각 행이 이미 문자열이면 그대로, 배열이면 파이프로 조인) - const rawLines = rawData.map((row) => { + const rawLines: string[] = rawData.map((row: string | string[]) => { if (typeof row === 'string') { return row; } @@ -81,7 +82,7 @@ export async function fetchAllSignalsRaw() { return row.join('|'); } return ''; - }).filter(line => line.trim()); + }).filter((line: string) => line.trim()); console.log(`[fetchAllSignalsRaw] Loaded ${rawLines.length} raw lines for Worker`); diff --git a/src/api/trackApi.js b/src/api/trackApi.ts similarity index 66% rename from src/api/trackApi.js rename to src/api/trackApi.ts index a710d591..e3ad750a 100644 --- a/src/api/trackApi.js +++ b/src/api/trackApi.ts @@ -9,21 +9,73 @@ */ import useShipStore from '../stores/shipStore'; import { fetchWithAuth } from './fetchWithAuth'; +import type { ShipFeature } from '../types/ship'; /** API 엔드포인트 (메인 프로젝트와 동일) */ const API_ENDPOINT = '/api/v2/tracks/vessels'; +/** 선박 식별자 (항적 조회용) */ +export interface VesselIdentifier { + sigSrcCd: string; + targetId: string; +} + +/** 항적 통계 */ +interface TrackStats { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; +} + +/** 가공된 항적 데이터 */ +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId: string; + geometry: number[][]; + timestampsMs: number[]; + speeds: number[]; + stats: TrackStats; +} + +/** 항적 조회 요청 파라미터 */ +interface TrackQueryParams { + startTime: string; + endTime: string; + vessels: VesselIdentifier[]; + isIntegration?: boolean; +} + +/** API 원시 응답 항적 */ +interface RawTrack { + vesselId?: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + integrationTargetId?: string; + geometry?: number[][]; + timestamps?: (string | number)[]; + speeds?: number[]; + totalDistance?: number; + avgSpeed?: number; + maxSpeed?: number; + pointCount?: number; +} + /** * 항적 데이터 조회 * - * @param {Object} params - * @param {string} params.startTime - 조회 시작 시간 (ISO 8601, e.g. '2026-01-01T00:00:00') - * @param {string} params.endTime - 조회 종료 시간 (ISO 8601) - * @param {Array<{ sigSrcCd: string, targetId: string }>} params.vessels - 조회 대상 선박 - * @param {boolean} [params.isIntegration=false] - 통합 조회 여부 - * @returns {Promise} ProcessedTrack 배열 + * @param {TrackQueryParams} params + * @returns {Promise} ProcessedTrack 배열 */ -export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }) { +export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegration = false }: TrackQueryParams): Promise { try { const body = { startTime, @@ -45,7 +97,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati const result = await response.json(); // v2 API는 배열을 직접 반환 - const rawTracks = Array.isArray(result) ? result : (result?.data || []); + const rawTracks: RawTrack[] = Array.isArray(result) ? result : (result?.data || []); if (!Array.isArray(rawTracks)) { console.warn('[fetchTrackQuery] Invalid response format:', result); @@ -55,7 +107,7 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati // 가공: CompactVesselTrack → ProcessedTrack const processed = rawTracks .map((raw) => processTrack(raw)) - .filter((t) => t !== null); + .filter((t): t is ProcessedTrack => t !== null); console.log(`[fetchTrackQuery] Loaded ${processed.length} tracks`); return processed; @@ -69,10 +121,10 @@ export async function fetchTrackQuery({ startTime, endTime, vessels, isIntegrati * API 응답 데이터를 ProcessedTrack으로 변환 * 참조: mda-react-front/src/tracking/stores/trackQueryStore.ts - setTracks * - * @param {Object} raw - API 응답의 개별 항적 데이터 - * @returns {Object|null} ProcessedTrack + * @param {RawTrack} raw - API 응답의 개별 항적 데이터 + * @returns {ProcessedTrack|null} ProcessedTrack */ -function processTrack(raw) { +function processTrack(raw: RawTrack): ProcessedTrack | null { if (!raw || !raw.geometry || raw.geometry.length === 0) return null; const vesselId = raw.vesselId || `${raw.sigSrcCd}_${raw.targetId}`; @@ -115,11 +167,11 @@ function processTrack(raw) { /** * 실시간 선박 데이터에서 매칭되는 선박 찾기 - * @param {string} targetId - * @param {string} sigSrcCd - * @returns {Object|null} + * @param {string|undefined} targetId + * @param {string|undefined} sigSrcCd + * @returns {ShipFeature|null} */ -function findLiveShipData(targetId, sigSrcCd) { +function findLiveShipData(targetId: string | undefined, sigSrcCd: string | undefined): ShipFeature | null { if (!targetId) return null; const features = useShipStore.getState().features; @@ -132,7 +184,7 @@ function findLiveShipData(targetId, sigSrcCd) { } // featureId로 못 찾으면 originalTargetId로 검색 - let found = null; + let found: ShipFeature | null = null; features.forEach((ship) => { if (ship.originalTargetId === targetId) { found = ship; @@ -144,10 +196,10 @@ function findLiveShipData(targetId, sigSrcCd) { /** * 선박 객체에서 항적 조회용 파라미터 추출 - * @param {Object} ship - shipStore의 선박 데이터 - * @returns {{ sigSrcCd: string, targetId: string }} + * @param {ShipFeature} ship - shipStore의 선박 데이터 + * @returns {VesselIdentifier} */ -export function extractVesselIdentifier(ship) { +export function extractVesselIdentifier(ship: ShipFeature): VesselIdentifier { return { sigSrcCd: ship.signalSourceCode || '', targetId: ship.originalTargetId || ship.targetId || '', @@ -159,8 +211,8 @@ export function extractVesselIdentifier(ship) { * @param {Date} date * @returns {string} 'YYYY-MM-DDTHH:mm:ss' */ -export function toLocalISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +export function toLocalISOString(date: Date): string { + const pad = (n: number): string => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } @@ -170,14 +222,14 @@ export function toLocalISOString(date) { * 각 위치에 00000이면 해당 장비 없음 * * @param {string} targetId - 통합 TARGET_ID - * @returns {Array<{ sigSrcCd: string, targetId: string }>} + * @returns {VesselIdentifier[]} */ -export function parseIntegratedTargetId(targetId) { +export function parseIntegratedTargetId(targetId: string): VesselIdentifier[] { if (!targetId) return []; const parts = targetId.split('_'); // 위치별 장비 매핑: AIS, VPASS, ENAV, VTS_AIS, D_MF_HF - const equipmentMap = [ + const equipmentMap: { sigSrcCd: string; index: number }[] = [ { sigSrcCd: '000001', index: 0 }, // AIS { sigSrcCd: '000003', index: 1 }, // VPASS { sigSrcCd: '000002', index: 2 }, // ENAV @@ -185,7 +237,7 @@ export function parseIntegratedTargetId(targetId) { { sigSrcCd: '000016', index: 4 }, // D_MF_HF ]; - const vessels = []; + const vessels: VesselIdentifier[] = []; equipmentMap.forEach(({ sigSrcCd, index }) => { const id = parts[index]; if (id && id !== '00000' && id !== '0' && id !== '') { @@ -201,10 +253,10 @@ export function parseIntegratedTargetId(targetId) { * 통합선박: TARGET_ID 파싱 → 모든 장비 (레이더 제외) * 단일선박: 기본 identifier 반환 * - * @param {Object} ship - shipStore 선박 데이터 - * @returns {Array<{ sigSrcCd: string, targetId: string }>} + * @param {ShipFeature} ship - shipStore 선박 데이터 + * @returns {VesselIdentifier[]} */ -export function buildVesselListForQuery(ship) { +export function buildVesselListForQuery(ship: ShipFeature): VesselIdentifier[] { if (ship.integrate && ship.targetId && ship.targetId.includes('_')) { return parseIntegratedTargetId(ship.targetId); } diff --git a/src/api/userSettingApi.js b/src/api/userSettingApi.ts similarity index 61% rename from src/api/userSettingApi.js rename to src/api/userSettingApi.ts index b6118b5a..329e02b2 100644 --- a/src/api/userSettingApi.js +++ b/src/api/userSettingApi.ts @@ -4,11 +4,24 @@ import { USER_SETTING_FILTER } from '../types/constants'; const SEARCH_ENDPOINT = '/api/cmn/personal/settings/search'; const SAVE_ENDPOINT = '/api/cmn/personal/settings/save'; +/** 필터 설정 아이템 */ +export interface FilterSettingItem { + settingCode: string; + settingValue: string; + [key: string]: unknown; +} + +/** 필터 저장 요청 아이템 */ +export interface FilterSaveItem { + code: string; + value: string; +} + /** * 필터 설정 조회 - * @returns {Promise} 설정 배열 또는 null (저장된 설정 없음) + * @returns {Promise} 설정 배열 또는 null (저장된 설정 없음) */ -export async function fetchUserFilter() { +export async function fetchUserFilter(): Promise { const url = `${SEARCH_ENDPOINT}?type=${USER_SETTING_FILTER}`; const response = await fetchWithAuth(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); @@ -19,9 +32,9 @@ export async function fetchUserFilter() { /** * 필터 설정 저장 - * @param {Array<{code: string, value: string}>} settings + * @param {FilterSaveItem[]} settings */ -export async function saveUserFilter(settings) { +export async function saveUserFilter(settings: FilterSaveItem[]): Promise { const response = await fetchWithAuth(SAVE_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/src/areaSearch/components/AreaSearchPage.jsx b/src/areaSearch/components/AreaSearchPage.tsx similarity index 92% rename from src/areaSearch/components/AreaSearchPage.jsx rename to src/areaSearch/components/AreaSearchPage.tsx index 47e88013..b08bb7b6 100644 --- a/src/areaSearch/components/AreaSearchPage.jsx +++ b/src/areaSearch/components/AreaSearchPage.tsx @@ -6,6 +6,8 @@ import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore' import { fetchAreaSearch } from '../services/areaSearchApi'; import { fetchVesselContacts } from '../services/stsApi'; import { QUERY_MAX_DAYS, getQueryDateRange, ANALYSIS_TABS } from '../types/areaSearch.types'; +import type { AnalysisTab, Zone } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { showToast } from '../../components/common/Toast'; import { hideLiveShips, showLiveShips } from '../../utils/liveControl'; import { unregisterAreaSearchLayers } from '../utils/areaSearchLayerRegistry'; @@ -16,12 +18,17 @@ import StsAnalysisTab from './StsAnalysisTab'; const DAYS_TO_MS = 24 * 60 * 60 * 1000; -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -export default function AreaSearchPage({ isOpen, onToggle }) { +interface AreaSearchPageProps { + isOpen: boolean; + onToggle: () => void; +} + +export default function AreaSearchPage({ isOpen, onToggle }: AreaSearchPageProps) { const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState('00:00'); const [endDate, setEndDate] = useState(''); @@ -67,7 +74,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { // ========== 탭 전환 ========== - const handleTabChange = useCallback((newTab) => { + const handleTabChange = useCallback((newTab: AnalysisTab) => { if (newTab === activeTab) return; const areaState = useAreaSearchStore.getState(); @@ -101,12 +108,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) { // ========== 날짜 핸들러 ========== - const handleStartDateChange = useCallback((newStartDate) => { + const handleStartDateChange = useCallback((newStartDate: string) => { setStartDate(newStartDate); const start = new Date(`${newStartDate}T${startTime}:00`); const end = new Date(`${endDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); if (diffDays < 0) { const adjusted = new Date(start.getTime() + QUERY_MAX_DAYS * DAYS_TO_MS); @@ -121,12 +128,12 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } }, [startTime, endDate, endTime]); - const handleEndDateChange = useCallback((newEndDate) => { + const handleEndDateChange = useCallback((newEndDate: string) => { setEndDate(newEndDate); const start = new Date(`${startDate}T${startTime}:00`); const end = new Date(`${newEndDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); if (diffDays < 0) { const adjusted = new Date(end.getTime() - QUERY_MAX_DAYS * DAYS_TO_MS); @@ -152,7 +159,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { setErrorMessage(''); useAreaSearchStore.getState().setLoading(true); - const polygons = zones.map((z) => ({ + const polygons = zones.map((z: Zone) => ({ id: z.id, name: z.name, coordinates: z.coordinates, @@ -177,7 +184,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { let minTime = Infinity; let maxTime = -Infinity; - result.tracks.forEach((t) => { + result.tracks.forEach((t: ProcessedTrack) => { if (t.timestampsMs.length > 0) { minTime = Math.min(minTime, t.timestampsMs[0]); maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]); @@ -190,7 +197,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } catch (error) { console.error('[AreaSearch] 조회 실패:', error); useAreaSearchStore.getState().setLoading(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); @@ -228,7 +235,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { let minTime = Infinity; let maxTime = -Infinity; - result.tracks.forEach((t) => { + result.tracks.forEach((t: ProcessedTrack) => { if (t.timestampsMs.length > 0) { minTime = Math.min(minTime, t.timestampsMs[0]); maxTime = Math.max(maxTime, t.timestampsMs[t.timestampsMs.length - 1]); @@ -241,7 +248,7 @@ export default function AreaSearchPage({ isOpen, onToggle }) { } catch (error) { console.error('[STS] 조회 실패:', error); useStsStore.getState().setLoading(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, zones, setTimeRange]); diff --git a/src/areaSearch/components/AreaSearchTab.jsx b/src/areaSearch/components/AreaSearchTab.tsx similarity index 92% rename from src/areaSearch/components/AreaSearchTab.jsx rename to src/areaSearch/components/AreaSearchTab.tsx index 39a6d1e3..8a29a17d 100644 --- a/src/areaSearch/components/AreaSearchTab.jsx +++ b/src/areaSearch/components/AreaSearchTab.tsx @@ -12,13 +12,20 @@ import { useAreaSearchStore } from '../stores/areaSearchStore'; import { SEARCH_MODE_LABELS, } from '../types/areaSearch.types'; +import type { SearchMode } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import ZoneDrawPanel from './ZoneDrawPanel'; import VesselDetailModal from './VesselDetailModal'; import { exportSearchResultToCSV } from '../utils/csvExport'; -export default function AreaSearchTab({ isLoading, errorMessage }) { - const [detailVesselId, setDetailVesselId] = useState(null); +interface AreaSearchTabProps { + isLoading: boolean; + errorMessage: string; +} + +export default function AreaSearchTab({ isLoading, errorMessage }: AreaSearchTabProps) { + const [detailVesselId, setDetailVesselId] = useState(null); const zones = useAreaSearchStore((s) => s.zones); const searchMode = useAreaSearchStore((s) => s.searchMode); @@ -30,11 +37,11 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { const highlightedVesselId = useAreaSearchStore((s) => s.highlightedVesselId); const setSearchMode = useAreaSearchStore((s) => s.setSearchMode); - const handleToggleVessel = useCallback((vesselId) => { + const handleToggleVessel = useCallback((vesselId: string) => { useAreaSearchStore.getState().toggleVesselEnabled(vesselId); }, []); - const handleHighlightVessel = useCallback((vesselId) => { + const handleHighlightVessel = useCallback((vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, []); @@ -42,7 +49,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { exportSearchResultToCSV(tracks, hitDetails, zones); }, [tracks, hitDetails, zones]); - const listRef = useRef(null); + const listRef = useRef(null); useEffect(() => { if (!highlightedVesselId || !listRef.current) return; @@ -73,7 +80,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) { checked={searchMode === mode} onChange={() => { if (!useAreaSearchStore.getState().confirmAndClearResults()) return; - setSearchMode(mode); + setSearchMode(mode as SearchMode); }} disabled={isLoading} /> @@ -105,7 +112,7 @@ export default function AreaSearchTab({ isLoading, errorMessage }) {
    - {tracks.map((track) => { + {tracks.map((track: ProcessedTrack) => { const isDisabled = disabledVesselIds.has(track.vesselId); const isHighlighted = highlightedVesselId === track.vesselId; const color = getShipKindColor(track.shipKindCode); diff --git a/src/areaSearch/components/AreaSearchTimeline.jsx b/src/areaSearch/components/AreaSearchTimeline.tsx similarity index 91% rename from src/areaSearch/components/AreaSearchTimeline.jsx rename to src/areaSearch/components/AreaSearchTimeline.tsx index cc948085..c1366594 100644 --- a/src/areaSearch/components/AreaSearchTimeline.jsx +++ b/src/areaSearch/components/AreaSearchTimeline.tsx @@ -1,13 +1,13 @@ /** * 항적분석 타임라인 재생 컨트롤 - * 참조: src/replay/components/ReplayTimeline.jsx (간소화) + * 참조: src/replay/components/ReplayTimeline.tsx (간소화) * * - 재생/일시정지/정지 * - 배속 조절 (1x ~ 1000x) * - 프로그레스 바 (range slider) * - 드래그 가능한 헤더 */ -import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo, ChangeEvent } from 'react'; import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { useStsStore } from '../stores/stsStore'; @@ -21,10 +21,10 @@ import './AreaSearchTimeline.scss'; const PATH_LABEL = '항적'; const TRAIL_LABEL = '궤적'; -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms || ms <= 0) return '--:--:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } @@ -68,18 +68,18 @@ export default function AreaSearchTimeline() { }, [currentTime, startTime, endTime]); const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); + const speedMenuRef = useRef(null); // 드래그 const [isDragging, setIsDragging] = useState(false); const [hasDragged, setHasDragged] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const containerRef = useRef(null); + const containerRef = useRef(null); useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -87,7 +87,7 @@ export default function AreaSearchTimeline() { return () => document.removeEventListener('mousedown', handleClickOutside); }, [showSpeedMenu]); - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const parent = containerRef.current.parentElement; @@ -103,7 +103,7 @@ export default function AreaSearchTimeline() { }, [hasDragged]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!isDragging || !containerRef.current) return; const parent = containerRef.current.parentElement; if (!parent) return; @@ -135,12 +135,12 @@ export default function AreaSearchTimeline() { const handleStop = useCallback(() => { stop(); }, [stop]); - const handleSpeedChange = useCallback((speed) => { + const handleSpeedChange = useCallback((speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, [setPlaybackSpeed]); - const handleSliderChange = useCallback((e) => { + const handleSliderChange = useCallback((e: ChangeEvent) => { setCurrentTime(parseFloat(e.target.value)); }, [setCurrentTime]); @@ -232,7 +232,7 @@ export default function AreaSearchTimeline() { value={currentTime} onChange={handleSliderChange} disabled={!hasData} - style={{ '--progress': `${progress}%` }} + style={{ '--progress': `${progress}%` } as React.CSSProperties} /> diff --git a/src/areaSearch/components/AreaSearchTooltip.jsx b/src/areaSearch/components/AreaSearchTooltip.tsx similarity index 84% rename from src/areaSearch/components/AreaSearchTooltip.jsx rename to src/areaSearch/components/AreaSearchTooltip.tsx index 6b61d72b..13b870fc 100644 --- a/src/areaSearch/components/AreaSearchTooltip.jsx +++ b/src/areaSearch/components/AreaSearchTooltip.tsx @@ -6,26 +6,28 @@ import { useMemo } from 'react'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone, HitDetail } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import './AreaSearchTooltip.scss'; const OFFSET_X = 14; const OFFSET_Y = -20; -/** nationalCode → 국기 SVG URL */ -function getNationalFlagUrl(nationalCode) { +/** nationalCode -> 국기 SVG URL */ +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; return `/ship/image/small/${nationalCode}.svg`; } -export function formatTimestamp(ms) { +export function formatTimestamp(ms: number | null | undefined): string { if (!ms) return '-'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -export function formatPosition(pos) { +export function formatPosition(pos: number[] | null | undefined): string | null { if (!pos || pos.length < 2) return null; const lon = pos[0]; const lat = pos[1]; @@ -41,8 +43,8 @@ export default function AreaSearchTooltip() { const zones = useAreaSearchStore((s) => s.zones); const zoneMap = useMemo(() => { - const map = new Map(); - zones.forEach((z, idx) => { + const map = new Map(); + zones.forEach((z: Zone, idx: number) => { map.set(z.id, z); map.set(z.name, z); map.set(idx, z); @@ -54,16 +56,16 @@ export default function AreaSearchTooltip() { if (!tooltip) return null; const { vesselId, x, y } = tooltip; - const track = tracks.find((t) => t.vesselId === vesselId); + const track = tracks.find((t: ProcessedTrack) => t.vesselId === vesselId); if (!track) return null; - const hits = hitDetails[vesselId] || []; + const hits: HitDetail[] = hitDetails[vesselId] || []; const kindName = getShipKindName(track.shipKindCode); const sourceName = getSignalSourceName(track.sigSrcCd); const flagUrl = getNationalFlagUrl(track.nationalCode); // 시간순 정렬 (구역 무관) - const sortedHits = [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp); + const sortedHits = [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)); return (
    { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} diff --git a/src/areaSearch/components/StsAnalysisTab.jsx b/src/areaSearch/components/StsAnalysisTab.tsx similarity index 90% rename from src/areaSearch/components/StsAnalysisTab.jsx rename to src/areaSearch/components/StsAnalysisTab.tsx index 2483f004..5314496c 100644 --- a/src/areaSearch/components/StsAnalysisTab.jsx +++ b/src/areaSearch/components/StsAnalysisTab.tsx @@ -5,33 +5,37 @@ * - STS 파라미터 슬라이더 (최소 접촉 시간, 최대 접촉 거리) * - 결과: StsContactList */ -import { useCallback, useState } from 'react'; +import { useCallback, useState, ChangeEvent } from 'react'; import './StsAnalysisTab.scss'; import { useStsStore } from '../stores/stsStore'; -import { useAreaSearchStore } from '../stores/areaSearchStore'; import { STS_LIMITS } from '../types/sts.types'; import ZoneDrawPanel from './ZoneDrawPanel'; import StsContactList from './StsContactList'; import StsContactDetailModal from './StsContactDetailModal'; -export default function StsAnalysisTab({ isLoading, errorMessage }) { +interface StsAnalysisTabProps { + isLoading: boolean; + errorMessage: string; +} + +export default function StsAnalysisTab({ isLoading, errorMessage }: StsAnalysisTabProps) { const queryCompleted = useStsStore((s) => s.queryCompleted); const groupedContacts = useStsStore((s) => s.groupedContacts); const summary = useStsStore((s) => s.summary); const minContactDuration = useStsStore((s) => s.minContactDurationMinutes); const maxContactDistance = useStsStore((s) => s.maxContactDistanceMeters); - const handleDurationChange = useCallback((e) => { + const handleDurationChange = useCallback((e: ChangeEvent) => { useStsStore.getState().setMinContactDuration(Number(e.target.value)); }, []); - const handleDistanceChange = useCallback((e) => { + const handleDistanceChange = useCallback((e: ChangeEvent) => { useStsStore.getState().setMaxContactDistance(Number(e.target.value)); }, []); - const [detailGroupIndex, setDetailGroupIndex] = useState(null); + const [detailGroupIndex, setDetailGroupIndex] = useState(null); - const handleDetailClick = useCallback((idx) => { + const handleDetailClick = useCallback((idx: number) => { setDetailGroupIndex(idx); }, []); diff --git a/src/areaSearch/components/StsContactDetailModal.jsx b/src/areaSearch/components/StsContactDetailModal.tsx similarity index 59% rename from src/areaSearch/components/StsContactDetailModal.jsx rename to src/areaSearch/components/StsContactDetailModal.tsx index d5d90f65..467dee48 100644 --- a/src/areaSearch/components/StsContactDetailModal.jsx +++ b/src/areaSearch/components/StsContactDetailModal.tsx @@ -1,26 +1,19 @@ /** - * STS 접촉 쌍 상세 모달 — 임베디드 OL 지도 + 그리드 레이아웃 + 이미지 저장 + * STS 접촉 쌍 상세 모달 -- 임베디드 MapLibre 지도 + 그리드 레이아웃 + 이미지 저장 * 그룹 기반: 동일 선박 쌍의 여러 접촉을 리스트로 표시 */ import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; -import Map from 'ol/Map'; -import View from 'ol/View'; -import { XYZ } from 'ol/source'; -import TileLayer from 'ol/layer/Tile'; -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import { Feature } from 'ol'; -import { Point, LineString, Polygon } from 'ol/geom'; -import { fromLonLat } from 'ol/proj'; -import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; -import { defaults as defaultControls, ScaleLine } from 'ol/control'; -import { defaults as defaultInteractions } from 'ol/interaction'; +import maplibregl from 'maplibre-gl'; +import type { FeatureCollection, Feature, Polygon, LineString, Point } from 'geojson'; import html2canvas from 'html2canvas'; import { useStsStore } from '../stores/stsStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { StsVessel, StsContact } from '../types/sts.types'; import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; import { @@ -29,116 +22,128 @@ import { formatDuration, getContactRiskColor, } from '../types/sts.types'; -import { mapLayerConfig } from '../../map/layers/baseLayer'; +import { DARK_TILE_URL } from '../../map/layers/baseLayer'; import './StsContactDetailModal.scss'; -function getNationalFlagUrl(nationalCode) { +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; return `/ship/image/small/${nationalCode}.svg`; } -function createZoneFeatures(zones) { - const features = []; - zones.forEach((zone) => { - const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); - const polygon = new Polygon([coords3857]); - const feature = new Feature({ geometry: polygon }); - const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; - feature.setStyle([ - new Style({ - fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), - stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), - }), - new Style({ - geometry: () => { - const ext = polygon.getExtent(); - const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; - return new Point(center); +/** + * MapLibre GeoJSON 빌더: 관심구역 폴리곤 + */ +function buildZoneGeoJSON(zones: Zone[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: zones.map((zone) => { + const coords = zone.coordinates; + // 폴리곤 닫기 보장 + const closed = [...coords]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + closed.push([first[0], first[1]]); + } + + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [closed], }, - text: new Text({ - text: `${zone.name}구역`, - font: 'bold 12px sans-serif', - fill: new Fill({ color: color.label || '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), - }), - }), - ]); - features.push(feature); - }); - return features; -} - -function createTrackFeature(track) { - const coords3857 = track.geometry.map((c) => fromLonLat(c)); - const line = new LineString(coords3857); - const feature = new Feature({ geometry: line }); - const color = getShipKindColor(track.shipKindCode); - feature.setStyle(new Style({ - stroke: new Stroke({ - color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, - width: 2, + properties: { + fillColor: `rgba(${color.fill.join(',')})`, + outlineColor: `rgba(${color.stroke.join(',')})`, + labelText: `${zone.name}구역`, + labelColor: color.label || '#fff', + }, + }; }), - })); - return feature; + }; } -function createContactMarkers(contacts) { - const features = []; +/** + * MapLibre GeoJSON 빌더: 2개 항적 LineString + */ +function buildTrackGeoJSON(tracks: ProcessedTrack[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: tracks.map((track) => { + const coords = track.geometry; + const shipColor = getShipKindColor(track.shipKindCode); + const colorStr = `rgba(${shipColor[0]},${shipColor[1]},${shipColor[2]},0.8)`; + + return { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: coords, + }, + properties: { + color: colorStr, + }, + }; + }), + }; +} + +/** + * MapLibre GeoJSON 빌더: 접촉 중심 마커 + */ +function buildContactGeoJSON(contacts: StsContact[]): FeatureCollection { + const features: Feature[] = []; contacts.forEach((contact, idx) => { if (!contact.contactCenterPoint) return; - const pos3857 = fromLonLat(contact.contactCenterPoint); - const riskColor = getContactRiskColor(contact.indicators); + const riskColor = getContactRiskColor(contact.indicators ?? null); + const labelText = contacts.length > 1 ? `#${idx + 1}` : '접촉 중심'; + const startLabel = contact.contactStartTimestamp + ? `시작 ${formatTimestamp(contact.contactStartTimestamp)}` + : ''; + const endLabel = contact.contactEndTimestamp + ? `종료 ${formatTimestamp(contact.contactEndTimestamp)}` + : ''; + const timeLabel = startLabel && endLabel ? `${startLabel}\n${endLabel}` : ''; - const f = new Feature({ geometry: new Point(pos3857) }); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 10, - fill: new Fill({ color: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)` }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: contacts.length > 1 ? `#${idx + 1}` : '접촉 중심', - font: 'bold 11px sans-serif', - fill: new Fill({ color: '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: -18, - }), - })); - features.push(f); - - if (contact.contactStartTimestamp) { - const startLabel = `시작 ${formatTimestamp(contact.contactStartTimestamp)}`; - const endLabel = `종료 ${formatTimestamp(contact.contactEndTimestamp)}`; - const labelF = new Feature({ geometry: new Point(pos3857) }); - labelF.setStyle(new Style({ - text: new Text({ - text: `${startLabel}\n${endLabel}`, - font: '10px sans-serif', - fill: new Fill({ color: '#ced4da' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: 24, - }), - })); - features.push(labelF); - } + features.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: contact.contactCenterPoint, + }, + properties: { + riskColor: `rgba(${riskColor[0]},${riskColor[1]},${riskColor[2]},0.6)`, + labelText, + timeLabel, + }, + }); }); - return features; + return { + type: 'FeatureCollection', + features, + }; } const MODAL_WIDTH = 680; const MODAL_APPROX_HEIGHT = 780; -export default function StsContactDetailModal({ groupIndex, onClose }) { +interface StsContactDetailModalProps { + groupIndex: number; + onClose: () => void; +} + +export default function StsContactDetailModal({ groupIndex, onClose }: StsContactDetailModalProps) { const groupedContacts = useStsStore((s) => s.groupedContacts); const tracks = useStsStore((s) => s.tracks); const zones = useAreaSearchStore((s) => s.zones); - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const contentRef = useRef(null); + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); const [position, setPosition] = useState(() => ({ x: (window.innerWidth - MODAL_WIDTH) / 2, @@ -148,7 +153,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const dragging = useRef(false); const dragStart = useRef({ x: 0, y: 0 }); - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - posRef.current.x, @@ -158,7 +163,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { }, []); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, @@ -180,59 +185,175 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const group = useMemo(() => groupedContacts[groupIndex], [groupedContacts, groupIndex]); const vessel1Track = useMemo( - () => tracks.find((t) => t.vesselId === group?.vessel1?.vesselId), + () => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel1?.vesselId), [tracks, group], ); const vessel2Track = useMemo( - () => tracks.find((t) => t.vesselId === group?.vessel2?.vesselId), + () => tracks.find((t: ProcessedTrack) => t.vesselId === group?.vessel2?.vesselId), [tracks, group], ); - // OL 지도 초기화 + // MapLibre 지도 초기화 useEffect(() => { if (!mapContainerRef.current || !group || !vessel1Track || !vessel2Track) return; - const tileSource = new XYZ({ - url: mapLayerConfig.darkLayer.source.getUrls()[0], - minZoom: 6, - maxZoom: 11, - }); - const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); - - const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); - const zoneLayer = new VectorLayer({ source: zoneSource }); - - const trackSource = new VectorSource({ - features: [createTrackFeature(vessel1Track), createTrackFeature(vessel2Track)], - }); - const trackLayer = new VectorLayer({ source: trackSource }); - - const markerFeatures = createContactMarkers(group.contacts); - const markerSource = new VectorSource({ features: markerFeatures }); - const markerLayer = new VectorLayer({ source: markerSource }); - - const map = new Map({ - target: mapContainerRef.current, - layers: [tileLayer, zoneLayer, trackLayer, markerLayer], - view: new View({ center: [0, 0], zoom: 7 }), - controls: defaultControls({ attribution: false, zoom: false, rotate: false }) - .extend([new ScaleLine({ units: 'nautical' })]), - interactions: defaultInteractions({ doubleClickZoom: false }), - }); - - const allSource = new VectorSource(); - [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); - const extent = allSource.getExtent(); - if (extent && extent[0] !== Infinity) { - map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); + // 기존 맵 정리 + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; } - mapRef.current = map; + const mlMap = new maplibregl.Map({ + container: mapContainerRef.current, + style: { + version: 8, + sources: { + 'osm-tiles': { + type: 'raster', + tiles: [DARK_TILE_URL], + tileSize: 256, + }, + }, + layers: [ + { id: 'osm-layer', type: 'raster', source: 'osm-tiles' }, + ], + }, + center: [0, 0], + zoom: 6, + dragPan: false, + scrollZoom: false, + doubleClickZoom: false, + attributionControl: false, + }); + + mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right'); + + mlMap.on('load', () => { + // Zone source + layers + mlMap.addSource('zone-source', { + type: 'geojson', + data: buildZoneGeoJSON(zones), + }); + mlMap.addLayer({ + id: 'zone-fill', + type: 'fill', + source: 'zone-source', + paint: { + 'fill-color': ['get', 'fillColor'], + 'fill-opacity': 1, + }, + }); + mlMap.addLayer({ + id: 'zone-line', + type: 'line', + source: 'zone-source', + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': 2, + }, + }); + mlMap.addLayer({ + id: 'zone-label', + type: 'symbol', + source: 'zone-source', + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': 12, + 'text-font': ['Open Sans Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'labelColor'], + 'text-halo-color': 'rgba(0,0,0,0.7)', + 'text-halo-width': 2, + }, + }); + + // Track source + layer (2개 항적) + mlMap.addSource('track-source', { + type: 'geojson', + data: buildTrackGeoJSON([vessel1Track, vessel2Track]), + }); + mlMap.addLayer({ + id: 'track-line', + type: 'line', + source: 'track-source', + paint: { + 'line-color': ['get', 'color'], + 'line-width': 2, + 'line-opacity': 0.8, + }, + }); + + // Contact source + layers + mlMap.addSource('contact-source', { + type: 'geojson', + data: buildContactGeoJSON(group.contacts), + }); + mlMap.addLayer({ + id: 'contact-circle', + type: 'circle', + source: 'contact-source', + paint: { + 'circle-radius': 10, + 'circle-color': ['get', 'riskColor'], + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'contact-label', + type: 'symbol', + source: 'contact-source', + layout: { + 'text-field': ['get', 'labelText'], // "#1" or "접촉 중심" + 'text-size': 11, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0, -1.8], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': '#fff', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 3, + }, + }); + mlMap.addLayer({ + id: 'contact-time', + type: 'symbol', + source: 'contact-source', + layout: { + 'text-field': ['get', 'timeLabel'], // "시작 ...\n종료 ..." + 'text-size': 10, + 'text-font': ['Open Sans Regular'], + 'text-offset': [0, 2.4], + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#ced4da', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 3, + }, + }); + + // fitBounds: 모든 피처 범위에 맞춤 + const bounds = new maplibregl.LngLatBounds(); + zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]]))); + vessel1Track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + vessel2Track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + group.contacts.forEach((c) => { + if (c.contactCenterPoint) bounds.extend([c.contactCenterPoint[0], c.contactCenterPoint[1]]); + }); + mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 }); + }); + + mapRef.current = mlMap; return () => { - map.setTarget(null); - map.dispose(); - mapRef.current = null; + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } }; }, [group, vessel1Track, vessel2Track, zones]); @@ -240,7 +361,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { const el = contentRef.current; if (!el) return; - const modal = el.parentElement; + const modal = el.parentElement as HTMLElement; const saved = { elOverflow: el.style.overflow, modalMaxHeight: modal.style.maxHeight, @@ -261,7 +382,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) { if (!blob) return; const url = URL.createObjectURL(blob); const link = document.createElement('a'); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); const now = new Date(); const v1Name = group?.vessel1?.vesselName || 'V1'; const v2Name = group?.vessel2?.vesselName || 'V2'; @@ -301,7 +422,7 @@ export default function StsContactDetailModal({ groupIndex, onClose }) {
    - + {'\u2194'}
    @@ -167,7 +174,7 @@ function GroupCard({ group, index, onDetailClick }) { {group.contacts.length > 1 && (
    접촉 이력 ({group.contacts.length}회) - {group.contacts.map((c, ci) => ( + {group.contacts.map((c: StsContact, ci: number) => (
    #{ci + 1} {formatTimestamp(c.contactStartTimestamp)} ~ {formatTimestamp(c.contactEndTimestamp)} @@ -208,7 +215,12 @@ function GroupCard({ group, index, onDetailClick }) { ); } -function VesselDetail({ label, vessel }) { +interface VesselDetailProps { + label: string; + vessel: StsVessel; +} + +function VesselDetail({ label, vessel }: VesselDetailProps) { return (
    @@ -233,10 +245,14 @@ function VesselDetail({ label, vessel }) { ); } -export default function StsContactList({ onDetailClick }) { +interface StsContactListProps { + onDetailClick?: (idx: number) => void; +} + +export default function StsContactList({ onDetailClick }: StsContactListProps) { const groupedContacts = useStsStore((s) => s.groupedContacts); const highlightedGroupIndex = useStsStore((s) => s.highlightedGroupIndex); - const listRef = useRef(null); + const listRef = useRef(null); useEffect(() => { if (highlightedGroupIndex === null || !listRef.current) return; @@ -253,7 +269,7 @@ export default function StsContactList({ onDetailClick }) { return (
      - {groupedContacts.map((group, idx) => ( + {groupedContacts.map((group: StsGroupedContact, idx: number) => ( ))}
    diff --git a/src/areaSearch/components/VesselDetailModal.jsx b/src/areaSearch/components/VesselDetailModal.jsx deleted file mode 100644 index 112aca3b..00000000 --- a/src/areaSearch/components/VesselDetailModal.jsx +++ /dev/null @@ -1,459 +0,0 @@ -/** - * 선박 상세 모달 — 임베디드 OL 지도 + 시간순 방문 이력 + 이미지 저장 - */ -import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; -import { createPortal } from 'react-dom'; -import Map from 'ol/Map'; -import View from 'ol/View'; -import { XYZ } from 'ol/source'; -import TileLayer from 'ol/layer/Tile'; -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import { Feature } from 'ol'; -import { Point, LineString, Polygon } from 'ol/geom'; -import { fromLonLat } from 'ol/proj'; -import { Style, Fill, Stroke, Circle as CircleStyle, Text } from 'ol/style'; -import { defaults as defaultControls, ScaleLine } from 'ol/control'; -import { defaults as defaultInteractions } from 'ol/interaction'; -import html2canvas from 'html2canvas'; - -import { useAreaSearchStore } from '../stores/areaSearchStore'; -import { ZONE_COLORS } from '../types/areaSearch.types'; -import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; -import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; -import { mapLayerConfig } from '../../map/layers/baseLayer'; -import './VesselDetailModal.scss'; - -function getNationalFlagUrl(nationalCode) { - if (!nationalCode) return null; - return `/ship/image/small/${nationalCode}.svg`; -} - -function createZoneFeatures(zones) { - const features = []; - zones.forEach((zone) => { - const coords3857 = zone.coordinates.map((c) => fromLonLat(c)); - const polygon = new Polygon([coords3857]); - const feature = new Feature({ geometry: polygon }); - const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; - feature.setStyle([ - new Style({ - fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), - stroke: new Stroke({ color: `rgba(${color.stroke.join(',')})`, width: 2 }), - }), - new Style({ - geometry: () => { - const ext = polygon.getExtent(); - const center = [(ext[0] + ext[2]) / 2, (ext[1] + ext[3]) / 2]; - return new Point(center); - }, - text: new Text({ - text: `${zone.name}구역`, - font: 'bold 12px sans-serif', - fill: new Fill({ color: color.label || '#fff' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.7)', width: 3 }), - }), - }), - ]); - features.push(feature); - }); - return features; -} - -function createTrackFeature(track) { - const coords3857 = track.geometry.map((c) => fromLonLat(c)); - const line = new LineString(coords3857); - const feature = new Feature({ geometry: line }); - const color = getShipKindColor(track.shipKindCode); - feature.setStyle(new Style({ - stroke: new Stroke({ - color: `rgba(${color[0]},${color[1]},${color[2]},0.8)`, - width: 2, - }), - })); - return feature; -} - -function createMarkerFeatures(sortedHits) { - const features = []; - sortedHits.forEach((hit, idx) => { - const seqNum = idx + 1; - - if (hit.entryPosition) { - const pos3857 = fromLonLat(hit.entryPosition); - const f = new Feature({ geometry: new Point(pos3857) }); - const timeStr = formatTimestamp(hit.entryTimestamp); - f.set('_markerType', 'in'); - f.set('_seqNum', seqNum); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 7, - fill: new Fill({ color: '#2ecc71' }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: `${seqNum}-IN ${timeStr}`, - font: 'bold 10px sans-serif', - fill: new Fill({ color: '#2ecc71' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: -16, - textAlign: 'left', - offsetX: 10, - }), - })); - features.push(f); - } - - if (hit.exitPosition) { - const pos3857 = fromLonLat(hit.exitPosition); - const f = new Feature({ geometry: new Point(pos3857) }); - const timeStr = formatTimestamp(hit.exitTimestamp); - f.set('_markerType', 'out'); - f.set('_seqNum', seqNum); - f.setStyle(new Style({ - image: new CircleStyle({ - radius: 7, - fill: new Fill({ color: '#e74c3c' }), - stroke: new Stroke({ color: '#fff', width: 2 }), - }), - text: new Text({ - text: `${seqNum}-OUT ${timeStr}`, - font: 'bold 10px sans-serif', - fill: new Fill({ color: '#e74c3c' }), - stroke: new Stroke({ color: 'rgba(0,0,0,0.8)', width: 3 }), - offsetY: 16, - textAlign: 'left', - offsetX: 10, - }), - })); - features.push(f); - } - }); - return features; -} - -/** - * 마커 텍스트 겹침 보정 — 포인트(원)는 그대로, 텍스트 offsetY만 조정 - * 해상도 기반으로 근접 마커를 감지하고 텍스트를 수직 분산 배치 - */ -function adjustOverlappingLabels(features, resolution) { - if (!resolution || features.length < 2) return; - - const PROXIMITY_PX = 40; - const proximityMap = resolution * PROXIMITY_PX; - const LINE_HEIGHT_PX = 16; - - // 피처별 좌표 추출 - const items = features.map((f) => { - const coord = f.getGeometry().getCoordinates(); - return { feature: f, x: coord[0], y: coord[1] }; - }); - - // 근접 그룹 찾기 (Union-Find 방식) - const parent = items.map((_, i) => i); - const find = (i) => { while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; } return i; }; - const union = (a, b) => { parent[find(a)] = find(b); }; - - for (let i = 0; i < items.length; i++) { - for (let j = i + 1; j < items.length; j++) { - const dx = items[i].x - items[j].x; - const dy = items[i].y - items[j].y; - if (Math.sqrt(dx * dx + dy * dy) < proximityMap) { - union(i, j); - } - } - } - - // 그룹별 텍스트 offsetY 분산 (ol/Map import와 충돌 방지를 위해 plain object 사용) - const groups = {}; - items.forEach((item, i) => { - const root = find(i); - if (!groups[root]) groups[root] = []; - groups[root].push(item); - }); - - Object.values(groups).forEach((group) => { - if (group.length < 2) return; - // 시퀀스 번호 순 정렬 후 IN→OUT 순서 - group.sort((a, b) => { - const seqA = a.feature.get('_seqNum'); - const seqB = b.feature.get('_seqNum'); - if (seqA !== seqB) return seqA - seqB; - const typeA = a.feature.get('_markerType') === 'in' ? 0 : 1; - const typeB = b.feature.get('_markerType') === 'in' ? 0 : 1; - return typeA - typeB; - }); - - const totalHeight = group.length * LINE_HEIGHT_PX; - const startY = -totalHeight / 2 - 8; - - group.forEach((item, idx) => { - const style = item.feature.getStyle(); - const textStyle = style.getText(); - if (textStyle) { - textStyle.setOffsetY(startY + idx * LINE_HEIGHT_PX); - } - }); - }); -} - -const MODAL_WIDTH = 680; -const MODAL_APPROX_HEIGHT = 780; - -export default function VesselDetailModal({ vesselId, onClose }) { - const tracks = useAreaSearchStore((s) => s.tracks); - const hitDetails = useAreaSearchStore((s) => s.hitDetails); - const zones = useAreaSearchStore((s) => s.zones); - - const mapContainerRef = useRef(null); - const mapRef = useRef(null); - const contentRef = useRef(null); - - // 드래그 위치 관리 - const [position, setPosition] = useState(() => ({ - x: (window.innerWidth - MODAL_WIDTH) / 2, - y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2), - })); - const posRef = useRef(position); - const dragging = useRef(false); - const dragStart = useRef({ x: 0, y: 0 }); - - const handleMouseDown = useCallback((e) => { - dragging.current = true; - dragStart.current = { - x: e.clientX - posRef.current.x, - y: e.clientY - posRef.current.y, - }; - e.preventDefault(); - }, []); - - useEffect(() => { - const handleMouseMove = (e) => { - if (!dragging.current) return; - const newPos = { - x: e.clientX - dragStart.current.x, - y: e.clientY - dragStart.current.y, - }; - posRef.current = newPos; - setPosition(newPos); - }; - const handleMouseUp = () => { - dragging.current = false; - }; - window.addEventListener('mousemove', handleMouseMove); - window.addEventListener('mouseup', handleMouseUp); - return () => { - window.removeEventListener('mousemove', handleMouseMove); - window.removeEventListener('mouseup', handleMouseUp); - }; - }, []); - - const track = useMemo( - () => tracks.find((t) => t.vesselId === vesselId), - [tracks, vesselId], - ); - const hits = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]); - - const zoneMap = useMemo(() => { - const lookup = {}; - zones.forEach((z, idx) => { - lookup[z.id] = z; - lookup[z.name] = z; - lookup[idx] = z; - lookup[String(idx)] = z; - }); - return lookup; - }, [zones]); - - const sortedHits = useMemo( - () => [...hits].sort((a, b) => a.entryTimestamp - b.entryTimestamp), - [hits], - ); - - // OL 지도 초기화 - useEffect(() => { - if (!mapContainerRef.current || !track) return; - - const tileSource = new XYZ({ - url: mapLayerConfig.darkLayer.source.getUrls()[0], - minZoom: 6, - maxZoom: 11, - }); - const tileLayer = new TileLayer({ source: tileSource, preload: Infinity }); - - const zoneSource = new VectorSource({ features: createZoneFeatures(zones) }); - const zoneLayer = new VectorLayer({ source: zoneSource }); - - const trackSource = new VectorSource({ features: [createTrackFeature(track)] }); - const trackLayer = new VectorLayer({ source: trackSource }); - - const markerFeatures = createMarkerFeatures(sortedHits); - const markerSource = new VectorSource({ features: markerFeatures }); - const markerLayer = new VectorLayer({ source: markerSource }); - - const map = new Map({ - target: mapContainerRef.current, - layers: [tileLayer, zoneLayer, trackLayer, markerLayer], - view: new View({ center: [0, 0], zoom: 7 }), - controls: defaultControls({ attribution: false, zoom: false, rotate: false }) - .extend([new ScaleLine({ units: 'nautical' })]), - interactions: defaultInteractions({ doubleClickZoom: false }), - }); - - // 전체 extent에 맞춤 - const allSource = new VectorSource(); - [...zoneSource.getFeatures(), ...trackSource.getFeatures()].forEach((f) => allSource.addFeature(f.clone())); - const extent = allSource.getExtent(); - if (extent && extent[0] !== Infinity) { - map.getView().fit(extent, { padding: [50, 50, 50, 50], maxZoom: 14 }); - } - - // view fit 후 해상도 기반 텍스트 겹침 보정 - const resolution = map.getView().getResolution(); - adjustOverlappingLabels(markerFeatures, resolution); - - mapRef.current = map; - - return () => { - map.setTarget(null); - map.dispose(); - mapRef.current = null; - }; - }, [track, zones, sortedHits, zoneMap]); - - const handleSaveImage = useCallback(async () => { - const el = contentRef.current; - if (!el) return; - - const modal = el.parentElement; - const saved = { - elOverflow: el.style.overflow, - modalMaxHeight: modal.style.maxHeight, - modalOverflow: modal.style.overflow, - }; - - // 스크롤 영역 포함 전체 캡처를 위해 일시적으로 제약 해제 - el.style.overflow = 'visible'; - modal.style.maxHeight = 'none'; - modal.style.overflow = 'visible'; - - try { - const canvas = await html2canvas(el, { - backgroundColor: '#141820', - useCORS: true, - scale: 2, - }); - canvas.toBlob((blob) => { - if (!blob) return; - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - const pad = (n) => String(n).padStart(2, '0'); - const now = new Date(); - const name = track?.shipName || track?.targetId || 'vessel'; - link.href = url; - link.download = `항적분석_${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`; - link.click(); - URL.revokeObjectURL(url); - }, 'image/png'); - } catch (err) { - console.error('[VesselDetailModal] 이미지 저장 실패:', err); - } finally { - el.style.overflow = saved.elOverflow; - modal.style.maxHeight = saved.modalMaxHeight; - modal.style.overflow = saved.modalOverflow; - } - }, [track]); - - if (!track) return null; - - const kindName = getShipKindName(track.shipKindCode); - const sourceName = getSignalSourceName(track.sigSrcCd); - const flagUrl = getNationalFlagUrl(track.nationalCode); - - return createPortal( -
    -
    e.stopPropagation()} - > - {/* 헤더 (드래그 핸들) */} -
    -
    - {kindName} - {flagUrl && ( - - 국기 { e.target.style.display = 'none'; }} /> - - )} - - {track.shipName || track.targetId || '-'} - - {sourceName} -
    - -
    - - {/* 콘텐츠 (이미지 캡처 영역) */} -
    - {/* OL 지도 */} -
    - - {/* 방문 이력 */} -
    -

    방문 이력 (시간순)

    -
    - {sortedHits.map((hit, idx) => { - const zone = zoneMap[hit.polygonId]; - const zoneColor = zone - ? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b') - : '#adb5bd'; - const zoneName = zone - ? `${zone.name}구역` - : (hit.polygonName ? `${hit.polygonName}구역` : '구역'); - const visitLabel = hit.visitIndex > 1 || hits.filter((h) => h.polygonId === hit.polygonId).length > 1 - ? `${hit.visitIndex}차` - : ''; - const entryPos = formatPosition(hit.entryPosition); - const exitPos = formatPosition(hit.exitPosition); - - return ( -
    - {idx + 1}. -
    -
    - - {zoneName} - {visitLabel && {visitLabel}} -
    -
    - {idx + 1}-IN - {formatTimestamp(hit.entryTimestamp)} - {entryPos && {entryPos}} -
    -
    - {idx + 1}-OUT - {formatTimestamp(hit.exitTimestamp)} - {exitPos && {exitPos}} -
    -
    -
    - ); - })} -
    -
    -
    - - {/* 하단 버튼 */} -
    - -
    -
    -
    , - document.body, - ); -} diff --git a/src/areaSearch/components/VesselDetailModal.tsx b/src/areaSearch/components/VesselDetailModal.tsx new file mode 100644 index 00000000..aab8f9c3 --- /dev/null +++ b/src/areaSearch/components/VesselDetailModal.tsx @@ -0,0 +1,520 @@ +/** + * 선박 상세 모달 -- 임베디드 MapLibre 지도 + 시간순 방문 이력 + 이미지 저장 + */ +import { useEffect, useRef, useCallback, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; +import maplibregl from 'maplibre-gl'; +import type { FeatureCollection, Feature, Polygon, LineString, Point } from 'geojson'; +import html2canvas from 'html2canvas'; + +import { useAreaSearchStore } from '../stores/areaSearchStore'; +import { ZONE_COLORS } from '../types/areaSearch.types'; +import type { Zone, HitDetail } from '../types/areaSearch.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import { getShipKindColor, getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; +import { formatTimestamp, formatPosition } from './AreaSearchTooltip'; +import { DARK_TILE_URL } from '../../map/layers/baseLayer'; +import './VesselDetailModal.scss'; + +function getNationalFlagUrl(nationalCode: string | undefined): string | null { + if (!nationalCode) return null; + return `/ship/image/small/${nationalCode}.svg`; +} + +/** + * MapLibre GeoJSON 빌더: 관심구역 폴리곤 + */ +function buildZoneGeoJSON(zones: Zone[]): FeatureCollection { + return { + type: 'FeatureCollection', + features: zones.map((zone) => { + const coords = zone.coordinates; + // 폴리곤 닫기 보장 + const closed = [...coords]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + closed.push([first[0], first[1]]); + } + + const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; + return { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [closed], // [lon, lat] 직접 사용 (fromLonLat 불필요) + }, + properties: { + fillColor: `rgba(${color.fill.join(',')})`, + outlineColor: `rgba(${color.stroke.join(',')})`, + labelText: `${zone.name}구역`, + labelColor: color.label || '#fff', + }, + }; + }), + }; +} + +/** + * MapLibre GeoJSON 빌더: 항적 LineString + */ +function buildTrackGeoJSON(track: ProcessedTrack): FeatureCollection { + const coords = track.geometry; // [[lon, lat], ...] + const shipColor = getShipKindColor(track.shipKindCode); + const colorStr = `rgba(${shipColor[0]},${shipColor[1]},${shipColor[2]},0.8)`; + + return { + type: 'FeatureCollection', + features: [{ + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: coords, + }, + properties: { + color: colorStr, + }, + }], + }; +} + +/** + * MapLibre GeoJSON 빌더: 입퇴점 마커 (IN/OUT 분리) + */ +function buildMarkerGeoJSON(sortedHits: HitDetail[]): { + inPoints: FeatureCollection; + outPoints: FeatureCollection; +} { + const inFeatures: Feature[] = []; + const outFeatures: Feature[] = []; + + sortedHits.forEach((hit, idx) => { + const seqNum = idx + 1; + + if (hit.entryPosition) { + inFeatures.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: hit.entryPosition, // [lon, lat] + }, + properties: { + label: `${seqNum}-IN ${formatTimestamp(hit.entryTimestamp)}`, + seqNum, + }, + }); + } + + if (hit.exitPosition) { + outFeatures.push({ + type: 'Feature', + geometry: { + type: 'Point', + coordinates: hit.exitPosition, + }, + properties: { + label: `${seqNum}-OUT ${formatTimestamp(hit.exitTimestamp)}`, + seqNum, + }, + }); + } + }); + + return { + inPoints: { type: 'FeatureCollection', features: inFeatures }, + outPoints: { type: 'FeatureCollection', features: outFeatures }, + }; +} + +const MODAL_WIDTH = 680; +const MODAL_APPROX_HEIGHT = 780; + +interface VesselDetailModalProps { + vesselId: string; + onClose: () => void; +} + +export default function VesselDetailModal({ vesselId, onClose }: VesselDetailModalProps) { + const tracks = useAreaSearchStore((s) => s.tracks); + const hitDetails = useAreaSearchStore((s) => s.hitDetails); + const zones = useAreaSearchStore((s) => s.zones); + + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const contentRef = useRef(null); + + // 드래그 위치 관리 + const [position, setPosition] = useState(() => ({ + x: (window.innerWidth - MODAL_WIDTH) / 2, + y: Math.max(20, (window.innerHeight - MODAL_APPROX_HEIGHT) / 2), + })); + const posRef = useRef(position); + const dragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + dragging.current = true; + dragStart.current = { + x: e.clientX - posRef.current.x, + y: e.clientY - posRef.current.y, + }; + e.preventDefault(); + }, []); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + const newPos = { + x: e.clientX - dragStart.current.x, + y: e.clientY - dragStart.current.y, + }; + posRef.current = newPos; + setPosition(newPos); + }; + const handleMouseUp = () => { + dragging.current = false; + }; + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const track = useMemo( + () => tracks.find((t: ProcessedTrack) => t.vesselId === vesselId), + [tracks, vesselId], + ); + const hits: HitDetail[] = useMemo(() => hitDetails[vesselId] || [], [hitDetails, vesselId]); + + const zoneMap = useMemo(() => { + const lookup: Record = {}; + zones.forEach((z: Zone, idx: number) => { + lookup[z.id] = z; + lookup[z.name] = z; + lookup[idx] = z; + lookup[String(idx)] = z; + }); + return lookup; + }, [zones]); + + const sortedHits = useMemo( + () => [...hits].sort((a, b) => (a.entryTimestamp ?? 0) - (b.entryTimestamp ?? 0)), + [hits], + ); + + // MapLibre 지도 초기화 + useEffect(() => { + if (!mapContainerRef.current || !track) return; + + // 기존 맵 정리 + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + + const mlMap = new maplibregl.Map({ + container: mapContainerRef.current, + style: { + version: 8, + sources: { + 'osm-tiles': { + type: 'raster', + tiles: [DARK_TILE_URL], + tileSize: 256, + }, + }, + layers: [ + { id: 'osm-layer', type: 'raster', source: 'osm-tiles' }, + ], + }, + center: [0, 0], + zoom: 6, + dragPan: false, + scrollZoom: false, + doubleClickZoom: false, + attributionControl: false, + }); + + mlMap.addControl(new maplibregl.ScaleControl({ unit: 'nautical' }), 'bottom-right'); + + mlMap.on('load', () => { + // Zone source + layers + mlMap.addSource('zone-source', { + type: 'geojson', + data: buildZoneGeoJSON(zones), + }); + mlMap.addLayer({ + id: 'zone-fill', + type: 'fill', + source: 'zone-source', + paint: { + 'fill-color': ['get', 'fillColor'], // data-driven + 'fill-opacity': 1, + }, + }); + mlMap.addLayer({ + id: 'zone-line', + type: 'line', + source: 'zone-source', + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': 2, + }, + }); + mlMap.addLayer({ + id: 'zone-label', + type: 'symbol', + source: 'zone-source', + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': 12, + 'text-font': ['Open Sans Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'labelColor'], + 'text-halo-color': 'rgba(0,0,0,0.7)', + 'text-halo-width': 2, + }, + }); + + // Track source + layer + mlMap.addSource('track-source', { + type: 'geojson', + data: buildTrackGeoJSON(track), + }); + mlMap.addLayer({ + id: 'track-line', + type: 'line', + source: 'track-source', + paint: { + 'line-color': ['get', 'color'], // data-driven + 'line-width': 2, + 'line-opacity': 0.8, + }, + }); + + // Marker source + layers (IN/OUT 분리) + const { inPoints, outPoints } = buildMarkerGeoJSON(sortedHits); + + mlMap.addSource('marker-in-source', { type: 'geojson', data: inPoints }); + mlMap.addLayer({ + id: 'marker-in-circle', + type: 'circle', + source: 'marker-in-source', + paint: { + 'circle-radius': 7, + 'circle-color': '#2ecc71', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'marker-in-text', + type: 'symbol', + source: 'marker-in-source', + layout: { + 'text-field': ['get', 'label'], // "{seqNum}-IN {time}" + 'text-size': 10, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0.8, -1.3], + 'text-anchor': 'left', + 'text-allow-overlap': false, // 자동 겹침 방지 + }, + paint: { + 'text-color': '#2ecc71', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 2, + }, + }); + + mlMap.addSource('marker-out-source', { type: 'geojson', data: outPoints }); + mlMap.addLayer({ + id: 'marker-out-circle', + type: 'circle', + source: 'marker-out-source', + paint: { + 'circle-radius': 7, + 'circle-color': '#e74c3c', + 'circle-stroke-color': '#fff', + 'circle-stroke-width': 2, + }, + }); + mlMap.addLayer({ + id: 'marker-out-text', + type: 'symbol', + source: 'marker-out-source', + layout: { + 'text-field': ['get', 'label'], // "{seqNum}-OUT {time}" + 'text-size': 10, + 'text-font': ['Open Sans Bold'], + 'text-offset': [0.8, 1.3], + 'text-anchor': 'left', + 'text-allow-overlap': false, + }, + paint: { + 'text-color': '#e74c3c', + 'text-halo-color': 'rgba(0,0,0,0.8)', + 'text-halo-width': 2, + }, + }); + + // fitBounds: 모든 피처 범위에 맞춤 + const bounds = new maplibregl.LngLatBounds(); + zones.forEach((z) => z.coordinates.forEach((c) => bounds.extend([c[0], c[1]]))); + track.geometry.forEach((c) => bounds.extend([c[0], c[1]])); + sortedHits.forEach((h) => { + if (h.entryPosition) bounds.extend([h.entryPosition[0], h.entryPosition[1]]); + if (h.exitPosition) bounds.extend([h.exitPosition[0], h.exitPosition[1]]); + }); + mlMap.fitBounds(bounds, { padding: 50, maxZoom: 14 }); + }); + + mapRef.current = mlMap; + + return () => { + if (mapRef.current) { + mapRef.current.remove(); + mapRef.current = null; + } + }; + }, [track, zones, sortedHits, zoneMap]); + + const handleSaveImage = useCallback(async () => { + const el = contentRef.current; + if (!el) return; + + const modal = el.parentElement as HTMLElement; + const saved = { + elOverflow: el.style.overflow, + modalMaxHeight: modal.style.maxHeight, + modalOverflow: modal.style.overflow, + }; + + // 스크롤 영역 포함 전체 캡처를 위해 일시적으로 제약 해제 + el.style.overflow = 'visible'; + modal.style.maxHeight = 'none'; + modal.style.overflow = 'visible'; + + try { + const canvas = await html2canvas(el, { + backgroundColor: '#141820', + useCORS: true, + scale: 2, + }); + canvas.toBlob((blob) => { + if (!blob) return; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + const pad = (n: number) => String(n).padStart(2, '0'); + const now = new Date(); + const name = track?.shipName || track?.targetId || 'vessel'; + link.href = url; + link.download = `항적분석_${name}_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.png`; + link.click(); + URL.revokeObjectURL(url); + }, 'image/png'); + } catch (err) { + console.error('[VesselDetailModal] 이미지 저장 실패:', err); + } finally { + el.style.overflow = saved.elOverflow; + modal.style.maxHeight = saved.modalMaxHeight; + modal.style.overflow = saved.modalOverflow; + } + }, [track]); + + if (!track) return null; + + const kindName = getShipKindName(track.shipKindCode); + const sourceName = getSignalSourceName(track.sigSrcCd); + const flagUrl = getNationalFlagUrl(track.nationalCode); + + return createPortal( +
    +
    e.stopPropagation()} + > + {/* 헤더 (드래그 핸들) */} +
    +
    + {kindName} + {flagUrl && ( + + 국기 { (e.target as HTMLImageElement).style.display = 'none'; }} /> + + )} + + {track.shipName || track.targetId || '-'} + + {sourceName} +
    + +
    + + {/* 콘텐츠 (이미지 캡처 영역) */} +
    + {/* OL 지도 */} +
    + + {/* 방문 이력 */} +
    +

    방문 이력 (시간순)

    +
    + {sortedHits.map((hit, idx) => { + const zone = zoneMap[hit.polygonId]; + const zoneColor = zone + ? (ZONE_COLORS[zone.colorIndex]?.label || '#ffd43b') + : '#adb5bd'; + const zoneName = zone + ? `${zone.name}구역` + : (hit.polygonName ? `${hit.polygonName}구역` : '구역'); + const visitLabel = hit.visitIndex > 1 || hits.filter((h) => h.polygonId === hit.polygonId).length > 1 + ? `${hit.visitIndex}차` + : ''; + const entryPos = formatPosition(hit.entryPosition); + const exitPos = formatPosition(hit.exitPosition); + + return ( +
    + {idx + 1}. +
    +
    + + {zoneName} + {visitLabel && {visitLabel}} +
    +
    + {idx + 1}-IN + {formatTimestamp(hit.entryTimestamp)} + {entryPos && {entryPos}} +
    +
    + {idx + 1}-OUT + {formatTimestamp(hit.exitTimestamp)} + {exitPos && {exitPos}} +
    +
    +
    + ); + })} +
    +
    +
    + + {/* 하단 버튼 */} +
    + +
    +
    +
    , + document.body, + ); +} diff --git a/src/areaSearch/components/ZoneDrawPanel.jsx b/src/areaSearch/components/ZoneDrawPanel.tsx similarity index 85% rename from src/areaSearch/components/ZoneDrawPanel.jsx rename to src/areaSearch/components/ZoneDrawPanel.tsx index 58bd374b..7ab67944 100644 --- a/src/areaSearch/components/ZoneDrawPanel.jsx +++ b/src/areaSearch/components/ZoneDrawPanel.tsx @@ -6,8 +6,14 @@ import { ZONE_DRAW_TYPES, ZONE_COLORS, } from '../types/areaSearch.types'; +import type { ZoneDrawType, Zone } from '../types/areaSearch.types'; -export default function ZoneDrawPanel({ disabled, maxZones }) { +interface ZoneDrawPanelProps { + disabled?: boolean; + maxZones?: number; +} + +export default function ZoneDrawPanel({ disabled, maxZones }: ZoneDrawPanelProps) { const effectiveMaxZones = maxZones ?? MAX_ZONES; const zones = useAreaSearchStore((s) => s.zones); const activeDrawType = useAreaSearchStore((s) => s.activeDrawType); @@ -21,13 +27,13 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { const canAddZone = zones.length < effectiveMaxZones; - const handleDrawClick = useCallback((type) => { + const handleDrawClick = useCallback((type: ZoneDrawType) => { if (!canAddZone || disabled) return; if (!confirmAndClearResults()) return; setActiveDrawType(activeDrawType === type ? null : type); }, [canAddZone, disabled, activeDrawType, setActiveDrawType, confirmAndClearResults]); - const handleZoneClick = useCallback((zoneId) => { + const handleZoneClick = useCallback((zoneId: string) => { if (disabled) return; if (selectedZoneId === zoneId) { deselectZone(); @@ -37,26 +43,26 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { } }, [disabled, selectedZoneId, deselectZone, selectZone, confirmAndClearResults]); - const handleRemoveZone = useCallback((e, zoneId) => { + const handleRemoveZone = useCallback((e: React.MouseEvent, zoneId: string) => { e.stopPropagation(); if (!confirmAndClearResults()) return; removeZone(zoneId); }, [removeZone, confirmAndClearResults]); // 드래그 순서 변경 (ref 기반 - dataTransfer보다 안정적) - const dragIndexRef = useRef(null); - const [dragOverIndex, setDragOverIndex] = useState(null); + const dragIndexRef = useRef(null); + const [dragOverIndex, setDragOverIndex] = useState(null); - const handleDragStart = useCallback((e, index) => { + const handleDragStart = useCallback((e: React.DragEvent, index: number) => { dragIndexRef.current = index; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); requestAnimationFrame(() => { - e.target.classList.add('dragging'); + (e.target as HTMLElement).classList.add('dragging'); }); }, []); - const handleDragOver = useCallback((e, index) => { + const handleDragOver = useCallback((e: React.DragEvent, index: number) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (dragIndexRef.current !== null && dragIndexRef.current !== index) { @@ -64,7 +70,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { } }, []); - const handleDrop = useCallback((e, toIndex) => { + const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => { e.preventDefault(); const fromIndex = dragIndexRef.current; if (fromIndex !== null && fromIndex !== toIndex) { @@ -127,7 +133,7 @@ export default function ZoneDrawPanel({ disabled, maxZones }) { {/* 구역 목록 */} {zones.length > 0 && (
      - {zones.map((zone, index) => { + {zones.map((zone: Zone, index: number) => { const color = ZONE_COLORS[zone.colorIndex] || ZONE_COLORS[0]; return (
    • ; + shipKindCodeFilter: Set; + highlightedVesselId: string | null; +} + +export default function useAreaSearchLayer(): void { + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); // 정적 레이어 캐시 (필터/하이라이트 변경 시에만 갱신) - const staticLayerCacheRef = useRef({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null }); // React 구독: 필터/상태 (비빈번 변경만) const queryCompleted = useAreaSearchStore((s) => s.queryCompleted); @@ -36,7 +53,7 @@ export default function useAreaSearchLayer() { const showPaths = useAreaSearchStore((s) => s.showPaths); const showTrail = useAreaSearchStore((s) => s.showTrail); const shipKindCodeFilter = useAreaSearchStore((s) => s.shipKindCodeFilter); - // currentTime — React 구독 제거, zustand.subscribe로 대체 + // currentTime -- React 구독 제거, zustand.subscribe로 대체 /** * 프레임 렌더링 (zustand.subscribe에서 직접 호출, React 리렌더 없음) @@ -48,14 +65,15 @@ export default function useAreaSearchLayer() { const ct = useAreaSearchAnimationStore.getState().currentTime; const allPositions = useAreaSearchStore.getState().getCurrentPositions(ct); const filteredPositions = allPositions.filter( - (p) => shipKindCodeFilter.has(p.shipKindCode), + (p: VesselPosition) => shipKindCodeFilter.has(p.shipKindCode), ); - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; - // 1. TripsLayer 궤적 (동적 — currentTime 의존) + // 1. TripsLayer 궤적 (동적 -- currentTime 의존) if (showTrail && tripsDataRef.current.length > 0) { - const iconVesselIds = new Set(filteredPositions.map((p) => p.vesselId)); + const iconVesselIds = new Set(filteredPositions.map((p: VesselPosition) => p.vesselId)); const filteredTripsData = tripsDataRef.current.filter( (d) => iconVesselIds.has(d.vesselId), ); @@ -65,8 +83,9 @@ export default function useAreaSearchLayer() { new TripsLayer({ id: AREA_SEARCH_LAYER_IDS.TRIPS_TRAIL, data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry + getPath: (d: TripsDataItem) => d.path, + getTimestamps: (d: TripsDataItem) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -80,7 +99,7 @@ export default function useAreaSearchLayer() { } } - // 2. 정적 PathLayer (캐싱 — 필터/하이라이트 변경 시에만 재생성) + // 2. 정적 PathLayer (캐싱 -- 필터/하이라이트 변경 시에만 재생성) if (showPaths) { const deps = staticLayerCacheRef.current.deps; const needsRebuild = !deps @@ -90,7 +109,7 @@ export default function useAreaSearchLayer() { || deps.highlightedVesselId !== highlightedVesselId; if (needsRebuild) { - const filteredTracks = tracks.filter((t) => + const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId) && shipKindCodeFilter.has(t.shipKindCode), ); staticLayerCacheRef.current = { @@ -98,7 +117,7 @@ export default function useAreaSearchLayer() { tracks: filteredTracks, showPoints: false, highlightedVesselId, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, layerIds: { path: AREA_SEARCH_LAYER_IDS.PATH }, @@ -114,14 +133,14 @@ export default function useAreaSearchLayer() { currentPositions: filteredPositions, showVirtualShip: filteredPositions.length > 0, showLabels: filteredPositions.length > 0, - onIconHover: (shipData, x, y) => { + onIconHover: (shipData, _x, _y) => { if (shipData) { useAreaSearchStore.getState().setHighlightedVesselId(shipData.vesselId); } else { useAreaSearchStore.getState().setHighlightedVesselId(null); } }, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { useAreaSearchStore.getState().setHighlightedVesselId(vesselId); }, layerIds: { @@ -156,17 +175,17 @@ export default function useAreaSearchLayer() { startTimeRef.current = sTime; tripsDataRef.current = tracks - .filter((t) => t.geometry.length >= 2) - .map((track) => ({ + .filter((t: ProcessedTrack) => t.geometry.length >= 2) + .map((track: ProcessedTrack) => ({ vesselId: track.vesselId, shipKindCode: track.shipKindCode, path: track.geometry, - timestamps: track.timestampsMs.map((t) => t - sTime), + timestamps: track.timestampsMs.map((t: number) => t - sTime), })); }, [queryCompleted, tracks]); /** - * currentTime 구독 (zustand.subscribe — React 리렌더 바이패스) + * currentTime 구독 (zustand.subscribe -- React 리렌더 바이패스) * 재생 중: ~10fps 쓰로틀 (RENDER_INTERVAL_MS) * seek/정지: 즉시 렌더 (슬라이더 조작 반응성 유지) */ @@ -176,7 +195,7 @@ export default function useAreaSearchLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAreaSearchAnimationStore.subscribe( (s) => s.currentTime, diff --git a/src/areaSearch/hooks/useStsLayer.js b/src/areaSearch/hooks/useStsLayer.ts similarity index 74% rename from src/areaSearch/hooks/useStsLayer.js rename to src/areaSearch/hooks/useStsLayer.ts index 4c0d3225..c2baea6d 100644 --- a/src/areaSearch/hooks/useStsLayer.js +++ b/src/areaSearch/hooks/useStsLayer.ts @@ -15,6 +15,9 @@ import { useStsStore } from '../stores/stsStore'; import { useAreaSearchAnimationStore } from '../stores/areaSearchAnimationStore'; import { STS_LAYER_IDS } from '../types/sts.types'; import { getContactRiskColor } from '../types/sts.types'; +import type { StsGroupedContact, StsIndicators } from '../types/sts.types'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { VesselPosition } from '../types/areaSearch.types'; import { registerStsLayers, unregisterStsLayers, @@ -25,11 +28,39 @@ import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; const TRAIL_LENGTH_MS = 3600000; const RENDER_INTERVAL_MS = 100; -export default function useStsLayer() { - const tripsDataRef = useRef([]); +interface TripsDataItem { + vesselId: string; + shipKindCode: string; + path: number[][]; + timestamps: number[]; +} + +interface EnabledContact { + contactCenterPoint?: number[]; + indicators?: StsIndicators; + _groupIdx: number; + [key: string]: unknown; +} + +interface ContactLayerCacheDeps { + groupedContacts: StsGroupedContact[]; + disabledGroupIndices: Set; + highlightedGroupIndex: number | null; +} + +interface StaticLayerCacheDeps { + tracks: ProcessedTrack[]; + disabledGroupIndices: Set; + highlightedGroupIndex: number | null; +} + +export default function useStsLayer(): void { + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); - const staticLayerCacheRef = useRef({ layers: [], deps: null }); - const contactLayerCacheRef = useRef({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const staticLayerCacheRef = useRef<{ layers: any[]; deps: StaticLayerCacheDeps | null }>({ layers: [], deps: null }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const contactLayerCacheRef = useRef<{ layers: any[]; deps: ContactLayerCacheDeps | null }>({ layers: [], deps: null }); // React 구독: 그룹 기반 const queryCompleted = useStsStore((s) => s.queryCompleted); @@ -52,11 +83,12 @@ export default function useStsLayer() { if (!needsRebuild) return contactLayerCacheRef.current.layers; - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; // disabled가 아닌 그룹의 모든 하위 contacts를 flat - const enabledContacts = []; - groupedContacts.forEach((group, gIdx) => { + const enabledContacts: EnabledContact[] = []; + groupedContacts.forEach((group: StsGroupedContact, gIdx: number) => { if (disabledGroupIndices.has(gIdx)) return; group.contacts.forEach((c) => { enabledContacts.push({ @@ -79,9 +111,10 @@ export default function useStsLayer() { new ScatterplotLayer({ id: STS_LAYER_IDS.CONTACT_POINT, data: enabledContacts.filter((c) => c.contactCenterPoint), - getPosition: (d) => d.contactCenterPoint, - getRadius: (d) => d._groupIdx === highlightedGroupIndex ? 800 : 500, - getFillColor: (d) => getContactRiskColor(d.indicators), + // @ts-expect-error Deck.gl runtime accepts number[] for Position + getPosition: (d: EnabledContact) => d.contactCenterPoint as number[], + getRadius: (d: EnabledContact) => d._groupIdx === highlightedGroupIndex ? 800 : 500, + getFillColor: (d: EnabledContact) => getContactRiskColor(d.indicators ?? null), radiusMinPixels: 4, radiusMaxPixels: 12, pickable: true, @@ -107,11 +140,12 @@ export default function useStsLayer() { const ct = useAreaSearchAnimationStore.getState().currentTime; const allPositions = useStsStore.getState().getCurrentPositions(ct); - const layers = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Deck.gl Layer 타입이 복잡하여 any 사용 + const layers: any[] = []; // 1. TripsLayer 궤적 if (showTrail && tripsDataRef.current.length > 0) { - const iconVesselIds = new Set(allPositions.map((p) => p.vesselId)); + const iconVesselIds = new Set(allPositions.map((p: VesselPosition) => p.vesselId)); const filteredTripsData = tripsDataRef.current.filter( (d) => iconVesselIds.has(d.vesselId), ); @@ -121,8 +155,9 @@ export default function useStsLayer() { new TripsLayer({ id: STS_LAYER_IDS.TRIPS_TRAIL, data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry + getPath: (d: TripsDataItem) => d.path, + getTimestamps: (d: TripsDataItem) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -141,7 +176,7 @@ export default function useStsLayer() { const disabledVesselIds = useStsStore.getState().getDisabledVesselIds(); // 접촉 쌍의 양쪽 선박 항적 하이라이트 - let stsHighlightedVesselIds = null; + let stsHighlightedVesselIds: Set | null = null; if (highlightedGroupIndex !== null && groupedContacts[highlightedGroupIndex]) { const g = groupedContacts[highlightedGroupIndex]; stsHighlightedVesselIds = new Set([g.vessel1.vesselId, g.vessel2.vesselId]); @@ -154,21 +189,21 @@ export default function useStsLayer() { || deps.highlightedGroupIndex !== highlightedGroupIndex; if (needsRebuild) { - const filteredTracks = tracks.filter((t) => !disabledVesselIds.has(t.vesselId)); + const filteredTracks = tracks.filter((t: ProcessedTrack) => !disabledVesselIds.has(t.vesselId)); staticLayerCacheRef.current = { layers: createStaticTrackLayers({ tracks: filteredTracks, showPoints: false, highlightedVesselIds: stsHighlightedVesselIds, layerIds: { path: STS_LAYER_IDS.TRACK_PATH }, - onPathHover: (vesselId) => { + onPathHover: (vesselId: string | null) => { if (!vesselId) { useStsStore.getState().setHighlightedGroupIndex(null); return; } const groups = useStsStore.getState().groupedContacts; const idx = groups.findIndex( - (g) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId, + (g: StsGroupedContact) => g.vessel1.vesselId === vesselId || g.vessel2.vesselId === vesselId, ); useStsStore.getState().setHighlightedGroupIndex(idx >= 0 ? idx : null); }, @@ -220,12 +255,12 @@ export default function useStsLayer() { startTimeRef.current = sTime; tripsDataRef.current = tracks - .filter((t) => t.geometry.length >= 2) - .map((track) => ({ + .filter((t: ProcessedTrack) => t.geometry.length >= 2) + .map((track: ProcessedTrack) => ({ vesselId: track.vesselId, shipKindCode: track.shipKindCode, path: track.geometry, - timestamps: track.timestampsMs.map((t) => t - sTime), + timestamps: track.timestampsMs.map((t: number) => t - sTime), })); }, [queryCompleted, tracks]); @@ -238,7 +273,7 @@ export default function useStsLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAreaSearchAnimationStore.subscribe( (s) => s.currentTime, diff --git a/src/areaSearch/hooks/useZoneDraw.js b/src/areaSearch/hooks/useZoneDraw.ts similarity index 76% rename from src/areaSearch/hooks/useZoneDraw.js rename to src/areaSearch/hooks/useZoneDraw.ts index a79f1cbe..bf378b74 100644 --- a/src/areaSearch/hooks/useZoneDraw.js +++ b/src/areaSearch/hooks/useZoneDraw.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 구역 그리기 OpenLayers Draw 인터랙션 훅 * @@ -15,15 +17,21 @@ import { createBox } from 'ol/interaction/Draw'; import { Style, Fill, Stroke } from 'ol/style'; import { transform } from 'ol/proj'; import { fromCircle } from 'ol/geom/Polygon'; +import type OlMap from 'ol/Map'; +import type { Coordinate } from 'ol/coordinate'; +import type Feature from 'ol/Feature'; +import type { Geometry, Circle as OlCircleGeom, Polygon } from 'ol/geom'; +import type { DrawEvent } from 'ol/interaction/Draw'; import { useMapStore } from '../../stores/mapStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types'; +import type { ZoneDrawType, Zone, CircleMeta } from '../types/areaSearch.types'; import { setZoneSource, getZoneSource, setZoneLayer, getZoneLayer } from '../utils/zoneLayerRefs'; /** * 3857 좌표를 4326 좌표로 변환하고 폐곡선 보장 */ -function toWgs84Polygon(coords3857) { +function toWgs84Polygon(coords3857: Coordinate[]): number[][] { const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326')); // 폐곡선 보장 (첫점 == 끝점) if (coords4326.length > 0) { @@ -39,7 +47,7 @@ function toWgs84Polygon(coords3857) { /** * 구역 인덱스에 맞는 OL 스타일 생성 */ -function createZoneStyle(index) { +function createZoneStyle(index: number): Style { const color = ZONE_COLORS[index] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -47,10 +55,10 @@ function createZoneStyle(index) { }); } -export default function useZoneDraw() { +export default function useZoneDraw(): void { const map = useMapStore((s) => s.map); - const drawRef = useRef(null); - const mapRef = useRef(null); + const drawRef = useRef(null); + const mapRef = useRef(null); // map ref 동기화 (클린업에서 사용) useEffect(() => { @@ -61,6 +69,12 @@ export default function useZoneDraw() { useEffect(() => { if (!map) return; + // MapLibre 전환 후 OL VectorLayer는 호환 불가 — Session G에서 MapLibre 네이티브로 마이그레이션 + if (typeof map.getCanvas === 'function') { + console.warn('[useZoneDraw] MapLibre 맵 감지 — OL 구역 레이어 비활성화 (Session G에서 마이그레이션)'); + return; + } + const source = new VectorSource({ wrapX: false }); const layer = new VectorLayer({ source, @@ -72,10 +86,10 @@ export default function useZoneDraw() { // 기존 zones가 있으면 동기화 const { zones } = useAreaSearchStore.getState(); - zones.forEach((zone) => { + zones.forEach((zone: Zone) => { if (!zone.olFeature) return; zone.olFeature.setStyle(createZoneStyle(zone.colorIndex)); - source.addFeature(zone.olFeature); + source.addFeature(zone.olFeature as Feature); }); return () => { @@ -93,15 +107,15 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.zones, - (zones) => { + (zones: Zone[]) => { const source = getZoneSource(); if (!source) return; source.clear(); - zones.forEach((zone) => { + zones.forEach((zone: Zone) => { if (!zone.olFeature) return; zone.olFeature.setStyle(createZoneStyle(zone.colorIndex)); - source.addFeature(zone.olFeature); + source.addFeature(zone.olFeature as Feature); }); }, ); @@ -112,7 +126,7 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.showZones, - (show) => { + (show: boolean) => { const layer = getZoneLayer(); if (layer) layer.setVisible(show); }, @@ -121,7 +135,7 @@ export default function useZoneDraw() { }, []); // Draw 인터랙션 생성 함수 - const setupDraw = useCallback((currentMap, drawType) => { + const setupDraw = useCallback((currentMap: OlMap, drawType: ZoneDrawType | null) => { // 기존 인터랙션 제거 if (drawRef.current) { currentMap.removeInteraction(drawRef.current); @@ -137,7 +151,7 @@ export default function useZoneDraw() { // OL Draw는 drawend 이벤트 후에 source.addFeature()를 자동 호출하는데, // 우리 drawend 핸들러의 addZone() → subscription → source.addFeature() 와 충돌하여 // "feature already added" 에러 발생. source를 제거하면 자동 추가가 비활성화됨. - let draw; + let draw: Draw; if (drawType === ZONE_DRAW_TYPES.BOX) { draw = new Draw({ type: 'Circle', geometryFunction: createBox() }); } else if (drawType === ZONE_DRAW_TYPES.CIRCLE) { @@ -146,22 +160,23 @@ export default function useZoneDraw() { draw = new Draw({ type: 'Polygon' }); } - draw.on('drawend', (evt) => { + draw.on('drawend', (evt: DrawEvent) => { const feature = evt.feature; - let geom = feature.getGeometry(); + let geom = feature.getGeometry()!; const typeName = drawType; // Circle → Polygon 변환 (center/radius 보존) - let circleMeta = null; + let circleMeta: CircleMeta | null = null; if (drawType === ZONE_DRAW_TYPES.CIRCLE) { - circleMeta = { center: geom.getCenter(), radius: geom.getRadius() }; - const polyGeom = fromCircle(geom, 64); + const circleGeom = geom as OlCircleGeom; + circleMeta = { center: circleGeom.getCenter() as [number, number], radius: circleGeom.getRadius() }; + const polyGeom = fromCircle(circleGeom, 64); feature.setGeometry(polyGeom); geom = polyGeom; } // EPSG:3857 → 4326 좌표 추출 - const coords3857 = geom.getCoordinates()[0]; + const coords3857 = (geom as Polygon).getCoordinates()[0]; const coordinates = toWgs84Polygon(coords3857); // 최소 4점 확인 @@ -187,7 +202,7 @@ export default function useZoneDraw() { type: typeName, source: 'draw', coordinates, - olFeature: feature, + olFeature: feature as Feature, circleMeta, }); // addZone → activeDrawType: null → subscription → removeInteraction @@ -204,7 +219,7 @@ export default function useZoneDraw() { const unsub = useAreaSearchStore.subscribe( (s) => s.activeDrawType, - (drawType) => { + (drawType: ZoneDrawType | null) => { setupDraw(map, drawType); }, ); @@ -227,7 +242,7 @@ export default function useZoneDraw() { // ESC 키로 그리기 취소 useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { const { activeDrawType } = useAreaSearchStore.getState(); if (activeDrawType) { @@ -243,15 +258,15 @@ export default function useZoneDraw() { useEffect(() => { const unsub = useAreaSearchStore.subscribe( (s) => s.zones, - (zones, prevZones) => { + (zones: Zone[], prevZones: Zone[]) => { if (!prevZones || zones.length >= prevZones.length) return; const source = getZoneSource(); if (!source) return; - const currentIds = new Set(zones.map((z) => z.id)); - prevZones.forEach((z) => { + const currentIds = new Set(zones.map((z: Zone) => z.id)); + prevZones.forEach((z: Zone) => { if (!currentIds.has(z.id) && z.olFeature) { - try { source.removeFeature(z.olFeature); } catch { /* already removed */ } + try { source.removeFeature(z.olFeature as Feature); } catch { /* already removed */ } } }); }, diff --git a/src/areaSearch/hooks/useZoneEdit.js b/src/areaSearch/hooks/useZoneEdit.ts similarity index 74% rename from src/areaSearch/hooks/useZoneEdit.js rename to src/areaSearch/hooks/useZoneEdit.ts index 89a16d5f..6669b03f 100644 --- a/src/areaSearch/hooks/useZoneEdit.js +++ b/src/areaSearch/hooks/useZoneEdit.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 구역 편집 인터랙션 훅 * @@ -14,15 +16,23 @@ import { Modify, Translate } from 'ol/interaction'; import Collection from 'ol/Collection'; import { Style, Fill, Stroke, Circle as CircleStyle } from 'ol/style'; import { transform } from 'ol/proj'; +import type OlMap from 'ol/Map'; +import type Feature from 'ol/Feature'; +import type { Geometry, Polygon } from 'ol/geom'; +import type { Coordinate } from 'ol/coordinate'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type { FeatureLike } from 'ol/Feature'; +import type Layer from 'ol/layer/Layer'; import { useMapStore } from '../../stores/mapStore'; import { useAreaSearchStore } from '../stores/areaSearchStore'; import { ZONE_COLORS, ZONE_DRAW_TYPES } from '../types/areaSearch.types'; +import type { Zone, CircleMeta } from '../types/areaSearch.types'; import { getZoneSource } from '../utils/zoneLayerRefs'; import BoxResizeInteraction from '../interactions/BoxResizeInteraction'; import CircleResizeInteraction from '../interactions/CircleResizeInteraction'; /** 3857 좌표를 4326으로 변환 + 폐곡선 보장 */ -function toWgs84Polygon(coords3857) { +function toWgs84Polygon(coords3857: Coordinate[]): number[][] { const coords4326 = coords3857.map((c) => transform(c, 'EPSG:3857', 'EPSG:4326')); if (coords4326.length > 0) { const first = coords4326[0]; @@ -35,7 +45,7 @@ function toWgs84Polygon(coords3857) { } /** 선택된 구역의 하이라이트 스타일 */ -function createSelectedStyle(colorIndex) { +function createSelectedStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill[0]},${color.fill[1]},${color.fill[2]},0.25)` }), @@ -57,7 +67,7 @@ const MODIFY_STYLE = new Style({ }); /** 기본 구역 스타일 복원 */ -function createNormalStyle(colorIndex) { +function createNormalStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -66,7 +76,7 @@ function createNormalStyle(colorIndex) { } /** 호버 스타일 (스트로크 강조) */ -function createHoverStyle(colorIndex) { +function createHoverStyle(colorIndex: number): Style { const color = ZONE_COLORS[colorIndex] || ZONE_COLORS[0]; return new Style({ fill: new Fill({ color: `rgba(${color.fill.join(',')})` }), @@ -78,7 +88,7 @@ function createHoverStyle(colorIndex) { } /** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */ -function pointToSegmentDist(p, a, b) { +function pointToSegmentDist(p: number[], a: number[], b: number[]): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lenSq = dx * dx + dy * dy; @@ -91,8 +101,8 @@ function pointToSegmentDist(p, a, b) { const HANDLE_TOLERANCE = 12; /** Polygon 꼭짓점/변 근접 검사 */ -function isNearPolygonHandle(map, pixel, feature) { - const coords = feature.getGeometry().getCoordinates()[0]; +function isNearPolygonHandle(map: OlMap, pixel: number[], feature: Feature): boolean { + const coords = (feature.getGeometry() as Polygon).getCoordinates()[0]; const n = coords.length - 1; for (let i = 0; i < n; i++) { const vp = map.getPixelFromCoordinate(coords[i]); @@ -103,7 +113,7 @@ function isNearPolygonHandle(map, pixel, feature) { for (let i = 0; i < n; i++) { const p1 = map.getPixelFromCoordinate(coords[i]); const p2 = map.getPixelFromCoordinate(coords[(i + 1) % n]); - if (pointToSegmentDist(pixel, p1, p2) < HANDLE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < HANDLE_TOLERANCE) { return true; } } @@ -111,12 +121,12 @@ function isNearPolygonHandle(map, pixel, feature) { } /** Feature에서 좌표를 추출하여 store에 동기화 */ -function syncZoneToStore(zoneId, feature, zone) { - const geom = feature.getGeometry(); +function syncZoneToStore(zoneId: string, feature: Feature, zone: Zone): void { + const geom = feature.getGeometry() as Polygon; const coords3857 = geom.getCoordinates()[0]; const coords4326 = toWgs84Polygon(coords3857); - let circleMeta; + let circleMeta: CircleMeta | undefined; if (zone.type === ZONE_DRAW_TYPES.CIRCLE && zone.circleMeta) { // 폴리곤 중심에서 첫 번째 점까지의 거리로 반지름 재계산 const center = computeCentroid(coords3857); @@ -129,7 +139,7 @@ function syncZoneToStore(zoneId, feature, zone) { } /** 다각형 중심점 계산 */ -function computeCentroid(coords) { +function computeCentroid(coords: Coordinate[]): [number, number] { let sumX = 0, sumY = 0; const n = coords.length - 1; // 마지막(닫힘) 좌표 제외 for (let i = 0; i < n; i++) { @@ -139,17 +149,17 @@ function computeCentroid(coords) { return [sumX / n, sumY / n]; } -export default function useZoneEdit() { +export default function useZoneEdit(): void { const map = useMapStore((s) => s.map); - const mapRef = useRef(null); - const modifyRef = useRef(null); - const translateRef = useRef(null); - const customResizeRef = useRef(null); - const selectedCollectionRef = useRef(new Collection()); - const clickListenerRef = useRef(null); - const contextMenuRef = useRef(null); - const keydownRef = useRef(null); - const hoveredZoneIdRef = useRef(null); + const mapRef = useRef(null); + const modifyRef = useRef(null); + const translateRef = useRef(null); + const customResizeRef = useRef(null); + const selectedCollectionRef = useRef(new Collection>()); + const clickListenerRef = useRef<((evt: MapBrowserEvent) => void) | null>(null); + const contextMenuRef = useRef<((e: MouseEvent) => void) | null>(null); + const keydownRef = useRef<((e: KeyboardEvent) => void) | null>(null); + const hoveredZoneIdRef = useRef(null); useEffect(() => { mapRef.current = map; }, [map]); @@ -164,13 +174,13 @@ export default function useZoneEdit() { }, []); /** 선택된 구역에 대해 인터랙션 설정 */ - const setupInteractions = useCallback((currentMap, zone) => { + const setupInteractions = useCallback((currentMap: OlMap, zone: Zone) => { removeInteractions(); if (!zone || !zone.olFeature) return; - const feature = zone.olFeature; + const feature = zone.olFeature as Feature; const collection = selectedCollectionRef.current; - collection.push(feature); + collection.push(feature as Feature); // 선택 스타일 적용 feature.setStyle(createSelectedStyle(zone.colorIndex)); @@ -180,11 +190,11 @@ export default function useZoneEdit() { translate.on('translateend', () => { // Circle의 경우 center 업데이트 if (zone.type === ZONE_DRAW_TYPES.CIRCLE && customResizeRef.current) { - const coords = feature.getGeometry().getCoordinates()[0]; + const coords = feature.getGeometry()!.getCoordinates()[0]; const newCenter = computeCentroid(coords); - customResizeRef.current.setCenter(newCenter); + (customResizeRef.current as CircleResizeInteraction).setCenter(newCenter); } - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }); currentMap.addInteraction(translate); translateRef.current = translate; @@ -197,7 +207,7 @@ export default function useZoneEdit() { deleteCondition: () => false, // 기본 삭제 비활성화 (우클릭으로 대체) }); modify.on('modifyend', () => { - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }); currentMap.addInteraction(modify); modifyRef.current = modify; @@ -205,19 +215,19 @@ export default function useZoneEdit() { } else if (zone.type === ZONE_DRAW_TYPES.BOX) { const boxResize = new BoxResizeInteraction({ feature, - onResize: () => syncZoneToStore(zone.id, feature, zone), + onResize: () => syncZoneToStore(zone.id, feature as Feature, zone), }); currentMap.addInteraction(boxResize); customResizeRef.current = boxResize; } else if (zone.type === ZONE_DRAW_TYPES.CIRCLE) { - const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry().getCoordinates()[0]); + const center = zone.circleMeta?.center || computeCentroid(feature.getGeometry()!.getCoordinates()[0]); const circleResize = new CircleResizeInteraction({ feature, center, - onResize: (f) => { + onResize: (f: Feature) => { // 리사이즈 후 circleMeta 업데이트 - const coords = f.getGeometry().getCoordinates()[0]; + const coords = f.getGeometry()!.getCoordinates()[0]; const newCenter = computeCentroid(coords); const dx = coords[0][0] - newCenter[0]; const dy = coords[0][1] - newCenter[1]; @@ -232,9 +242,9 @@ export default function useZoneEdit() { }, [removeInteractions]); /** 구역 선택 해제 시 스타일 복원 */ - const restoreStyle = useCallback((zoneId) => { + const restoreStyle = useCallback((zoneId: string) => { const { zones } = useAreaSearchStore.getState(); - const zone = zones.find(z => z.id === zoneId); + const zone = zones.find((z: Zone) => z.id === zoneId); if (zone && zone.olFeature) { zone.olFeature.setStyle(createNormalStyle(zone.colorIndex)); } @@ -244,11 +254,11 @@ export default function useZoneEdit() { useEffect(() => { if (!map) return; - let prevSelectedId = null; + let prevSelectedId: string | null = null; const unsub = useAreaSearchStore.subscribe( (s) => s.selectedZoneId, - (zoneId) => { + (zoneId: string | null) => { // 이전 선택 스타일 복원 if (prevSelectedId) restoreStyle(prevSelectedId); prevSelectedId = zoneId; @@ -259,7 +269,7 @@ export default function useZoneEdit() { } const { zones } = useAreaSearchStore.getState(); - const zone = zones.find(z => z.id === zoneId); + const zone = zones.find((z: Zone) => z.id === zoneId); if (zone) { setupInteractions(map, zone); } @@ -290,7 +300,10 @@ export default function useZoneEdit() { useEffect(() => { if (!map) return; - const handleClick = (evt) => { + // MapLibre 전환 후 OL 이벤트 호환 불가 — Session G에서 마이그레이션 + if (typeof map.getCanvas === 'function') return; + + const handleClick = (evt: MapBrowserEvent) => { // Drawing 중이면 무시 if (useAreaSearchStore.getState().activeDrawType) return; @@ -301,24 +314,24 @@ export default function useZoneEdit() { if (!source) return; // 클릭 지점의 feature 탐색 - let clickedZone = null; + let clickedZone: Zone | undefined; const { zones } = useAreaSearchStore.getState(); - map.forEachFeatureAtPixel(evt.pixel, (feature) => { + map.forEachFeatureAtPixel(evt.pixel, (feature: FeatureLike) => { if (clickedZone) return; // 이미 찾았으면 무시 - const zone = zones.find(z => z.olFeature === feature); + const zone = zones.find((z: Zone) => z.olFeature === feature); if (zone) clickedZone = zone; - }, { layerFilter: (layer) => layer.getSource() === source }); + }, { layerFilter: (layer: Layer) => layer.getSource() === source }); const { selectedZoneId } = useAreaSearchStore.getState(); if (clickedZone) { - if (clickedZone.id === selectedZoneId) return; // 이미 선택됨 + if ((clickedZone as Zone).id === selectedZoneId) return; // 이미 선택됨 // 결과 표시 중이면 confirmAndClearResults if (!useAreaSearchStore.getState().confirmAndClearResults()) return; - useAreaSearchStore.getState().selectZone(clickedZone.id); + useAreaSearchStore.getState().selectZone((clickedZone as Zone).id); } else { // 빈 영역 클릭 → 선택 해제 if (selectedZoneId) { @@ -339,18 +352,19 @@ export default function useZoneEdit() { // 우클릭 꼭짓점 삭제 (Polygon 전용) useEffect(() => { if (!map) return; + if (typeof map.getCanvas === 'function') return; // MapLibre 가드 - const handleContextMenu = (e) => { + const handleContextMenu = (e: MouseEvent) => { const { selectedZoneId, zones } = useAreaSearchStore.getState(); if (!selectedZoneId) return; - const zone = zones.find(z => z.id === selectedZoneId); + const zone = zones.find((z: Zone) => z.id === selectedZoneId); if (!zone || zone.type !== ZONE_DRAW_TYPES.POLYGON) return; const feature = zone.olFeature; if (!feature) return; - const geom = feature.getGeometry(); + const geom = feature.getGeometry() as Polygon; const coords = geom.getCoordinates()[0]; const vertexCount = coords.length - 1; // 마지막 닫힘 좌표 제외 if (vertexCount <= 3) return; // 최소 삼각형 유지 @@ -380,7 +394,7 @@ export default function useZoneEdit() { newCoords[newCoords.length - 1] = [...newCoords[0]]; } geom.setCoordinates([newCoords]); - syncZoneToStore(zone.id, feature, zone); + syncZoneToStore(zone.id, feature as Feature, zone); }; const viewport = map.getViewport(); @@ -395,7 +409,7 @@ export default function useZoneEdit() { // 키보드: ESC → 선택 해제, Delete → 구역 삭제 useEffect(() => { - const handleKeyDown = (e) => { + const handleKeyDown = (e: KeyboardEvent) => { const { selectedZoneId, activeDrawType } = useAreaSearchStore.getState(); if (e.key === 'Escape' && selectedZoneId && !activeDrawType) { @@ -420,10 +434,11 @@ export default function useZoneEdit() { // pointermove → 호버 피드백 (커서 + 스타일) useEffect(() => { if (!map) return; + if (typeof map.getCanvas === 'function') return; // MapLibre 가드 const viewport = map.getViewport(); - const handlePointerMove = (evt) => { + const handlePointerMove = (evt: MapBrowserEvent) => { if (evt.dragging) return; // Drawing 중이면 호버 해제 @@ -443,11 +458,11 @@ export default function useZoneEdit() { // 1. 선택된 구역 — 리사이즈 핸들 / 내부 커서 if (selectedZoneId) { - const zone = zones.find(z => z.id === selectedZoneId); + const zone = zones.find((z: Zone) => z.id === selectedZoneId); if (zone && zone.olFeature) { // Box/Circle: isOverHandle if (customResizeRef.current && customResizeRef.current.isOverHandle) { - const handle = customResizeRef.current.isOverHandle(map, evt.pixel); + const handle = customResizeRef.current.isOverHandle(map, evt.pixel as unknown as number[]); if (handle) { viewport.style.cursor = handle.cursor; return; @@ -456,7 +471,7 @@ export default function useZoneEdit() { // Polygon: 꼭짓점/변 근접 if (zone.type === ZONE_DRAW_TYPES.POLYGON) { - if (isNearPolygonHandle(map, evt.pixel, zone.olFeature)) { + if (isNearPolygonHandle(map, evt.pixel as unknown as number[], zone.olFeature as Feature)) { viewport.style.cursor = 'crosshair'; return; } @@ -464,9 +479,9 @@ export default function useZoneEdit() { // 선택된 구역 내부 → move let overSelected = false; - map.forEachFeatureAtPixel(evt.pixel, (feature) => { + map.forEachFeatureAtPixel(evt.pixel, (feature: FeatureLike) => { if (feature === zone.olFeature) overSelected = true; - }, { layerFilter: (l) => l.getSource() === source }); + }, { layerFilter: (l: Layer) => l.getSource() === source }); if (overSelected) { viewport.style.cursor = 'move'; @@ -476,19 +491,20 @@ export default function useZoneEdit() { } // 2. 비선택 구역 호버 - let hoveredZone = null; - map.forEachFeatureAtPixel(evt.pixel, (feature) => { + let hoveredZone: Zone | undefined; + map.forEachFeatureAtPixel(evt.pixel, (feature: FeatureLike) => { if (hoveredZone) return; - const zone = zones.find(z => z.olFeature === feature && z.id !== selectedZoneId); + const zone = zones.find((z: Zone) => z.olFeature === feature && z.id !== selectedZoneId); if (zone) hoveredZone = zone; - }, { layerFilter: (l) => l.getSource() === source }); + }, { layerFilter: (l: Layer) => l.getSource() === source }); if (hoveredZone) { + const hz = hoveredZone as Zone; viewport.style.cursor = 'pointer'; - if (hoveredZoneIdRef.current !== hoveredZone.id) { + if (hoveredZoneIdRef.current !== hz.id) { if (hoveredZoneIdRef.current) restoreStyle(hoveredZoneIdRef.current); - hoveredZoneIdRef.current = hoveredZone.id; - hoveredZone.olFeature.setStyle(createHoverStyle(hoveredZone.colorIndex)); + hoveredZoneIdRef.current = hz.id; + hz.olFeature!.setStyle(createHoverStyle(hz.colorIndex)); } } else { viewport.style.cursor = ''; diff --git a/src/areaSearch/interactions/BoxResizeInteraction.js b/src/areaSearch/interactions/BoxResizeInteraction.ts similarity index 60% rename from src/areaSearch/interactions/BoxResizeInteraction.js rename to src/areaSearch/interactions/BoxResizeInteraction.ts index 6e72a818..512dc0e0 100644 --- a/src/areaSearch/interactions/BoxResizeInteraction.js +++ b/src/areaSearch/interactions/BoxResizeInteraction.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 사각형(Box) 리사이즈 커스텀 인터랙션 * @@ -6,12 +8,17 @@ * - 변 드래그: 반대쪽 변 고정, 1축 리사이즈 */ import PointerInteraction from 'ol/interaction/Pointer'; +import type Feature from 'ol/Feature'; +import type { Polygon } from 'ol/geom'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type OlMap from 'ol/Map'; +import type { Coordinate } from 'ol/coordinate'; const CORNER_TOLERANCE = 16; const EDGE_TOLERANCE = 12; /** 점(p)과 선분(a-b) 사이 최소 픽셀 거리 */ -function pointToSegmentDist(p, a, b) { +function pointToSegmentDist(p: number[], a: number[], b: number[]): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; const lenSq = dx * dx + dy * dy; @@ -21,34 +28,57 @@ function pointToSegmentDist(p, a, b) { return Math.hypot(p[0] - (a[0] + t * dx), p[1] - (a[1] + t * dy)); } +interface BoxResizeInteractionOptions { + feature: Feature; + onResize?: (feature: Feature) => void; +} + +interface HandleResult { + cursor: string; +} + +interface BBox { + minX: number; + maxX: number; + minY: number; + maxY: number; +} + export default class BoxResizeInteraction extends PointerInteraction { - constructor(options) { + private feature_: Feature; + private onResize_: ((feature: Feature) => void) | null; + // corner mode + private mode_: 'corner' | 'edge' | null; + private anchorCoord_: Coordinate | null; + // edge mode + private edgeIndex_: number | null; + private bbox_: BBox | null; + + constructor(options: BoxResizeInteractionOptions) { super({ - handleDownEvent: (evt) => BoxResizeInteraction.prototype._handleDown.call(this, evt), - handleDragEvent: (evt) => BoxResizeInteraction.prototype._handleDrag.call(this, evt), - handleUpEvent: (evt) => BoxResizeInteraction.prototype._handleUp.call(this, evt), + handleDownEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDown.call(this, evt), + handleDragEvent: (evt: MapBrowserEvent) => BoxResizeInteraction.prototype._handleDrag.call(this, evt), + handleUpEvent: () => BoxResizeInteraction.prototype._handleUp.call(this), }); this.feature_ = options.feature; this.onResize_ = options.onResize || null; // corner mode this.mode_ = null; // 'corner' | 'edge' - this.draggedIndex_ = null; this.anchorCoord_ = null; // edge mode this.edgeIndex_ = null; this.bbox_ = null; } - _handleDown(evt) { - const pixel = evt.pixel; - const coords = this.feature_.getGeometry().getCoordinates()[0]; + private _handleDown(evt: MapBrowserEvent): boolean { + const pixel = evt.pixel as unknown as number[]; + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 1. 모서리 감지 (우선) for (let i = 0; i < 4; i++) { const vp = evt.map.getPixelFromCoordinate(coords[i]); if (Math.hypot(pixel[0] - vp[0], pixel[1] - vp[1]) < CORNER_TOLERANCE) { this.mode_ = 'corner'; - this.draggedIndex_ = i; this.anchorCoord_ = coords[(i + 2) % 4]; return true; } @@ -59,11 +89,11 @@ export default class BoxResizeInteraction extends PointerInteraction { const j = (i + 1) % 4; const p1 = evt.map.getPixelFromCoordinate(coords[i]); const p2 = evt.map.getPixelFromCoordinate(coords[j]); - if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { this.mode_ = 'edge'; this.edgeIndex_ = i; - const xs = coords.slice(0, 4).map(c => c[0]); - const ys = coords.slice(0, 4).map(c => c[1]); + const xs = coords.slice(0, 4).map((c: Coordinate) => c[0]); + const ys = coords.slice(0, 4).map((c: Coordinate) => c[1]); this.bbox_ = { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), @@ -75,21 +105,21 @@ export default class BoxResizeInteraction extends PointerInteraction { return false; } - _handleDrag(evt) { + private _handleDrag(evt: MapBrowserEvent): void { const coord = evt.coordinate; if (this.mode_ === 'corner') { - const anchor = this.anchorCoord_; + const anchor = this.anchorCoord_!; const minX = Math.min(coord[0], anchor[0]); const maxX = Math.max(coord[0], anchor[0]); const minY = Math.min(coord[1], anchor[1]); const maxY = Math.max(coord[1], anchor[1]); - this.feature_.getGeometry().setCoordinates([[ + this.feature_.getGeometry()!.setCoordinates([[ [minX, maxY], [maxX, maxY], [maxX, minY], [minX, minY], [minX, maxY], ]]); } else if (this.mode_ === 'edge') { - let { minX, maxX, minY, maxY } = this.bbox_; - // Edge 0: top(TL→TR), 1: right(TR→BR), 2: bottom(BR→BL), 3: left(BL→TL) + let { minX, maxX, minY, maxY } = this.bbox_!; + // Edge 0: top(TL->TR), 1: right(TR->BR), 2: bottom(BR->BL), 3: left(BL->TL) switch (this.edgeIndex_) { case 0: maxY = coord[1]; break; case 1: maxX = coord[0]; break; @@ -98,16 +128,15 @@ export default class BoxResizeInteraction extends PointerInteraction { } const x1 = Math.min(minX, maxX), x2 = Math.max(minX, maxX); const y1 = Math.min(minY, maxY), y2 = Math.max(minY, maxY); - this.feature_.getGeometry().setCoordinates([[ + this.feature_.getGeometry()!.setCoordinates([[ [x1, y2], [x2, y2], [x2, y1], [x1, y1], [x1, y2], ]]); } } - _handleUp() { + private _handleUp(): boolean { if (this.mode_) { this.mode_ = null; - this.draggedIndex_ = null; this.anchorCoord_ = null; this.edgeIndex_ = null; this.bbox_ = null; @@ -119,10 +148,9 @@ export default class BoxResizeInteraction extends PointerInteraction { /** * 호버 감지: 픽셀이 리사이즈 핸들 위인지 확인 - * @returns {{ cursor: string }} | null */ - isOverHandle(map, pixel) { - const coords = this.feature_.getGeometry().getCoordinates()[0]; + isOverHandle(map: OlMap, pixel: number[]): HandleResult | null { + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; // 모서리 감지 const cornerCursors = ['nwse-resize', 'nesw-resize', 'nwse-resize', 'nesw-resize']; @@ -139,7 +167,7 @@ export default class BoxResizeInteraction extends PointerInteraction { const j = (i + 1) % 4; const p1 = map.getPixelFromCoordinate(coords[i]); const p2 = map.getPixelFromCoordinate(coords[j]); - if (pointToSegmentDist(pixel, p1, p2) < EDGE_TOLERANCE) { + if (pointToSegmentDist(pixel, p1 as unknown as number[], p2 as unknown as number[]) < EDGE_TOLERANCE) { return { cursor: edgeCursors[i] }; } } diff --git a/src/areaSearch/interactions/CircleResizeInteraction.js b/src/areaSearch/interactions/CircleResizeInteraction.ts similarity index 59% rename from src/areaSearch/interactions/CircleResizeInteraction.js rename to src/areaSearch/interactions/CircleResizeInteraction.ts index f6331ceb..70a6afd2 100644 --- a/src/areaSearch/interactions/CircleResizeInteraction.js +++ b/src/areaSearch/interactions/CircleResizeInteraction.ts @@ -1,3 +1,5 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) /** * 원(Circle) 리사이즈 커스텀 인터랙션 * @@ -10,16 +12,35 @@ import PointerInteraction from 'ol/interaction/Pointer'; import { fromCircle } from 'ol/geom/Polygon'; import OlCircle from 'ol/geom/Circle'; +import type Feature from 'ol/Feature'; +import type { Polygon } from 'ol/geom'; +import type MapBrowserEvent from 'ol/MapBrowserEvent'; +import type OlMap from 'ol/Map'; const PIXEL_TOLERANCE = 16; const MIN_RADIUS = 100; // 최소 반지름 (미터) +interface CircleResizeInteractionOptions { + feature: Feature; + center: [number, number]; // EPSG:3857 [x, y] + onResize?: (feature: Feature) => void; +} + +interface HandleResult { + cursor: string; +} + export default class CircleResizeInteraction extends PointerInteraction { - constructor(options) { + private feature_: Feature; + private center_: [number, number]; + private onResize_: ((feature: Feature) => void) | null; + private dragging_: boolean; + + constructor(options: CircleResizeInteractionOptions) { super({ - handleDownEvent: (evt) => CircleResizeInteraction.prototype._handleDown.call(this, evt), - handleDragEvent: (evt) => CircleResizeInteraction.prototype._handleDrag.call(this, evt), - handleUpEvent: (evt) => CircleResizeInteraction.prototype._handleUp.call(this, evt), + handleDownEvent: (evt: MapBrowserEvent) => CircleResizeInteraction.prototype._handleDown.call(this, evt), + handleDragEvent: (evt: MapBrowserEvent) => CircleResizeInteraction.prototype._handleDrag.call(this, evt), + handleUpEvent: () => CircleResizeInteraction.prototype._handleUp.call(this), }); this.feature_ = options.feature; this.center_ = options.center; // EPSG:3857 [x, y] @@ -28,9 +49,9 @@ export default class CircleResizeInteraction extends PointerInteraction { } /** 중심~포인터 픽셀 거리와 표시 반지름 비교 */ - _isNearEdge(map, pixel) { + private _isNearEdge(map: OlMap, pixel: number[]): boolean { const centerPixel = map.getPixelFromCoordinate(this.center_); - const coords = this.feature_.getGeometry().getCoordinates()[0]; + const coords = this.feature_.getGeometry()!.getCoordinates()[0]; const edgePixel = map.getPixelFromCoordinate(coords[0]); const radiusPixels = Math.hypot( edgePixel[0] - centerPixel[0], @@ -43,15 +64,15 @@ export default class CircleResizeInteraction extends PointerInteraction { return Math.abs(distFromCenter - radiusPixels) < PIXEL_TOLERANCE; } - _handleDown(evt) { - if (this._isNearEdge(evt.map, evt.pixel)) { + private _handleDown(evt: MapBrowserEvent): boolean { + if (this._isNearEdge(evt.map, evt.pixel as unknown as number[])) { this.dragging_ = true; return true; } return false; } - _handleDrag(evt) { + private _handleDrag(evt: MapBrowserEvent): void { if (!this.dragging_) return; const coord = evt.coordinate; const dx = coord[0] - this.center_[0]; @@ -63,7 +84,7 @@ export default class CircleResizeInteraction extends PointerInteraction { this.feature_.setGeometry(polyGeom); } - _handleUp() { + private _handleUp(): boolean { if (this.dragging_) { this.dragging_ = false; if (this.onResize_) this.onResize_(this.feature_); @@ -73,15 +94,14 @@ export default class CircleResizeInteraction extends PointerInteraction { } /** 외부에서 center 업데이트 (Translate 후) */ - setCenter(center) { + setCenter(center: [number, number]): void { this.center_ = center; } /** * 호버 감지: 픽셀이 리사이즈 핸들(테두리) 위인지 확인 - * @returns {{ cursor: string }} | null */ - isOverHandle(map, pixel) { + isOverHandle(map: OlMap, pixel: number[]): HandleResult | null { if (this._isNearEdge(map, pixel)) { return { cursor: 'nesw-resize' }; } diff --git a/src/areaSearch/services/areaSearchApi.js b/src/areaSearch/services/areaSearchApi.ts similarity index 73% rename from src/areaSearch/services/areaSearchApi.js rename to src/areaSearch/services/areaSearchApi.ts index 16dd25d1..1ea943d7 100644 --- a/src/areaSearch/services/areaSearchApi.js +++ b/src/areaSearch/services/areaSearchApi.ts @@ -6,14 +6,35 @@ */ import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi'; import { fetchWithAuth } from '../../api/fetchWithAuth'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { HitDetail } from '../types/areaSearch.types'; const API_ENDPOINT = '/api/v2/tracks/area-search'; +interface AreaSearchPolygon { + id: string; + name: string; + coordinates: number[][]; +} + +interface AreaSearchParams { + startTime: string; + endTime: string; + mode: string; + polygons: AreaSearchPolygon[]; +} + +interface AreaSearchResult { + tracks: ProcessedTrack[]; + hitDetails: Record; + summary: { totalVessels: number; processingTimeMs?: number } | null; +} + /** * 타임스탬프 기반 위치 보간 (이진 탐색) * track의 timestampsMs/geometry에서 targetTime 시점의 [lon, lat]을 계산 */ -function interpolatePositionAtTime(track, targetTime) { +function interpolatePositionAtTime(track: ProcessedTrack, targetTime: number | null): number[] | null { const { timestampsMs, geometry } = track; if (!timestampsMs || timestampsMs.length === 0 || !targetTime) return null; @@ -47,15 +68,8 @@ function interpolatePositionAtTime(track, targetTime) { /** * 구역 기반 항적 검색 - * - * @param {Object} params - * @param {string} params.startTime ISO 8601 시작 시간 - * @param {string} params.endTime ISO 8601 종료 시간 - * @param {string} params.mode 'ANY' | 'ALL' | 'SEQUENTIAL' - * @param {Array<{id: string, name: string, coordinates: number[][]}>} params.polygons - * @returns {Promise<{tracks: Array, hitDetails: Object, summary: Object}>} */ -export async function fetchAreaSearch(params) { +export async function fetchAreaSearch(params: AreaSearchParams): Promise { const request = { startTime: params.startTime, endTime: params.endTime, @@ -76,20 +90,21 @@ export async function fetchAreaSearch(params) { const result = await response.json(); const rawTracks = Array.isArray(result.tracks) ? result.tracks : []; - const tracks = convertToProcessedTracks(rawTracks); + const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[]; // vesselId → track 빠른 조회용 const trackMap = new Map(tracks.map((t) => [t.vesselId, t])); // hitDetails: timestamp 초→밀리초 변환 + 진입/진출 위치 보간 const rawHitDetails = result.hitDetails || {}; - const hitDetails = {}; + const hitDetails: Record = {}; for (const [vesselId, hits] of Object.entries(rawHitDetails)) { const track = trackMap.get(vesselId); - hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit) => { - const toMs = (ts) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + hitDetails[vesselId] = (Array.isArray(hits) ? hits : []).map((hit: any) => { + const toMs = (ts: string | number | null | undefined): number | null => { if (!ts) return null; - const num = typeof ts === 'number' ? ts : parseInt(ts, 10); + const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10); return num < 10000000000 ? num * 1000 : num; }; const entryMs = toMs(hit.entryTimestamp); diff --git a/src/areaSearch/services/stsApi.js b/src/areaSearch/services/stsApi.ts similarity index 63% rename from src/areaSearch/services/stsApi.js rename to src/areaSearch/services/stsApi.ts index b3f27905..ff8c80c4 100644 --- a/src/areaSearch/services/stsApi.js +++ b/src/areaSearch/services/stsApi.ts @@ -6,30 +6,45 @@ */ import { convertToProcessedTracks } from '../../tracking/services/trackQueryApi'; import { fetchWithAuth } from '../../api/fetchWithAuth'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { StsContact } from '../types/sts.types'; +import type { StsSummary } from '../stores/stsStore'; const API_ENDPOINT = '/api/v2/tracks/vessel-contacts'; +interface StsSearchPolygon { + id: string; + name: string; + coordinates: number[][]; +} + +interface StsSearchParams { + startTime: string; + endTime: string; + polygon: StsSearchPolygon; + minContactDurationMinutes: number; + maxContactDistanceMeters: number; +} + +interface StsSearchResult { + contacts: StsContact[]; + tracks: ProcessedTrack[]; + summary: StsSummary | null; +} + /** * Unix 초/밀리초 → 밀리초 변환 */ -function toMs(ts) { +function toMs(ts: string | number | null | undefined): number | null { if (!ts) return null; - const num = typeof ts === 'number' ? ts : parseInt(ts, 10); + const num = typeof ts === 'number' ? ts : parseInt(ts as string, 10); return num < 10000000000 ? num * 1000 : num; } /** * STS 접촉 검출 API 호출 - * - * @param {Object} params - * @param {string} params.startTime ISO 8601 - * @param {string} params.endTime ISO 8601 - * @param {{id: string, name: string, coordinates: number[][]}} params.polygon 단일 폴리곤 - * @param {number} params.minContactDurationMinutes 30~360 - * @param {number} params.maxContactDistanceMeters 50~5000 - * @returns {Promise<{contacts: Array, tracks: Array, summary: Object}>} */ -export async function fetchVesselContacts(params) { +export async function fetchVesselContacts(params: StsSearchParams): Promise { const request = { startTime: params.startTime, endTime: params.endTime, @@ -52,11 +67,13 @@ export async function fetchVesselContacts(params) { // tracks 변환 const rawTracks = Array.isArray(result.tracks) ? result.tracks : []; - const tracks = convertToProcessedTracks(rawTracks); + const tracks = convertToProcessedTracks(rawTracks) as ProcessedTrack[]; // contacts: timestamp 초→밀리초 변환 - const rawContacts = Array.isArray(result.contacts) ? result.contacts : []; - const contacts = rawContacts.map((c) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rawContacts = Array.isArray(result.contacts) ? result.contacts : [] as any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const contacts: StsContact[] = rawContacts.map((c: any) => ({ ...c, contactStartTimestamp: toMs(c.contactStartTimestamp), contactEndTimestamp: toMs(c.contactEndTimestamp), diff --git a/src/areaSearch/stores/areaSearchAnimationStore.js b/src/areaSearch/stores/areaSearchAnimationStore.ts similarity index 80% rename from src/areaSearch/stores/areaSearchAnimationStore.js rename to src/areaSearch/stores/areaSearchAnimationStore.ts index ed542c1a..fe2251ba 100644 --- a/src/areaSearch/stores/areaSearchAnimationStore.js +++ b/src/areaSearch/stores/areaSearchAnimationStore.ts @@ -9,11 +9,28 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; -let animationFrameId = null; -let lastFrameTime = null; +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; -export const useAreaSearchAnimationStore = create(subscribeWithSelector((set, get) => { - const animate = () => { +interface AreaSearchAnimationState { + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + + play: () => void; + pause: () => void; + stop: () => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + setTimeRange: (start: number, end: number) => void; + getProgress: () => number; + reset: () => void; +} + +export const useAreaSearchAnimationStore = create()(subscribeWithSelector((set, get) => { + const animate = (): void => { const state = get(); if (!state.isPlaying) return; diff --git a/src/areaSearch/stores/areaSearchStore.js b/src/areaSearch/stores/areaSearchStore.ts similarity index 71% rename from src/areaSearch/stores/areaSearchStore.js rename to src/areaSearch/stores/areaSearchStore.ts index 529205f9..ab168b5e 100644 --- a/src/areaSearch/stores/areaSearchStore.js +++ b/src/areaSearch/stores/areaSearchStore.ts @@ -9,12 +9,50 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { SEARCH_MODES, MAX_ZONES, ZONE_NAMES, ALL_SHIP_KIND_CODES, ANALYSIS_TABS } from '../types/areaSearch.types'; +import type { + AnalysisTab, + SearchMode, + ZoneDrawType, + Zone, + CircleMeta, + AreaSearchTooltip, + HitDetail, + VesselPosition, +} from '../types/areaSearch.types'; import { showLiveShips } from '../../utils/liveControl'; +// ========== ProcessedTrack 인터페이스 (trackQueryApi에서 반환하는 형태) ========== + +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId?: string; + geometry: number[][]; + timestampsMs: number[]; + speeds: number[]; + stats: { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; + }; +} + +// ========== Summary 인터페이스 ========== + +export interface AreaSearchSummary { + totalVessels: number; + processingTimeMs?: number; +} + /** * 두 지점 사이 선박 위치를 시간 기반 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -25,7 +63,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 두 지점 간 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: number[], p2: number[]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -39,9 +77,81 @@ let zoneIdCounter = 0; // 커서 기반 선형 탐색용 (vesselId → lastIndex) // 재생 중 시간은 단조 증가 → O(1~2) 전진, seek 시 이진탐색 fallback -const positionCursors = new Map(); +const positionCursors = new Map(); -export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ +interface AreaSearchState { + // 탭 상태 + activeTab: AnalysisTab; + + // 검색 조건 + zones: Zone[]; + searchMode: SearchMode; + + // 검색 결과 + tracks: ProcessedTrack[]; + hitDetails: Record; + summary: AreaSearchSummary | null; + + // UI 상태 + isLoading: boolean; + queryCompleted: boolean; + disabledVesselIds: Set; + highlightedVesselId: string | null; + showZones: boolean; + activeDrawType: ZoneDrawType | null; + areaSearchTooltip: AreaSearchTooltip | null; + selectedZoneId: string | null; + _lastZoneAddedAt: number; + + // 필터 상태 + showPaths: boolean; + showTrail: boolean; + shipKindCodeFilter: Set; + + // 구역 관리 + addZone: (zone: Omit & { circleMeta?: CircleMeta | null }) => void; + removeZone: (zoneId: string) => void; + clearZones: () => void; + reorderZones: (fromIndex: number, toIndex: number) => void; + + // 탭 전환 + setActiveTab: (tab: AnalysisTab) => void; + + // 검색 조건 + setSearchMode: (mode: SearchMode) => void; + setActiveDrawType: (type: ZoneDrawType | null) => void; + setShowZones: (show: boolean) => void; + + // 구역 편집 + selectZone: (zoneId: string) => void; + deselectZone: () => void; + updateZoneGeometry: (zoneId: string, coordinates4326: number[][], circleMeta?: CircleMeta) => void; + confirmAndClearResults: () => boolean; + + // 검색 결과 + setTracks: (tracks: ProcessedTrack[]) => void; + setHitDetails: (hitDetails: Record) => void; + setSummary: (summary: AreaSearchSummary | null) => void; + setLoading: (loading: boolean) => void; + + // 선박 토글 + toggleVesselEnabled: (vesselId: string) => void; + setHighlightedVesselId: (vesselId: string | null) => void; + setAreaSearchTooltip: (tooltip: AreaSearchTooltip | null) => void; + + // 필터 토글 + setShowPaths: (show: boolean) => void; + setShowTrail: (show: boolean) => void; + toggleShipKindCode: (code: string) => void; + getEnabledTracks: () => ProcessedTrack[]; + getCurrentPositions: (currentTime: number) => VesselPosition[]; + + // 초기화 + clearResults: () => void; + reset: () => void; +} + +export const useAreaSearchStore = create()(subscribeWithSelector((set, get) => ({ // 탭 상태 activeTab: ANALYSIS_TABS.AREA, @@ -57,7 +167,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ // UI 상태 isLoading: false, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, showZones: true, activeDrawType: null, @@ -81,7 +191,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ let colorIndex = 0; while (usedColors.has(colorIndex)) colorIndex++; - const newZone = { + const newZone: Zone = { ...zone, id: `zone-${++zoneIdCounter}`, name: ZONE_NAMES[colorIndex] || `${colorIndex + 1}`, @@ -94,7 +204,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ removeZone: (zoneId) => { const { zones, selectedZoneId } = get(); const filtered = zones.filter(z => z.id !== zoneId); - const updates = { zones: filtered }; + const updates: Partial = { zones: filtered }; if (selectedZoneId === zoneId) updates.selectedZoneId = null; set(updates); }, @@ -130,7 +240,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ const { zones } = get(); const updated = zones.map(z => { if (z.id !== zoneId) return z; - const patch = { ...z, coordinates: coordinates4326 }; + const patch: Zone = { ...z, coordinates: coordinates4326 }; if (circleMeta !== undefined) patch.circleMeta = circleMeta; return patch; }); @@ -140,7 +250,6 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ /** * 조회 조건 변경 시 결과 초기화 확인 * 결과가 없으면 true 반환, 있으면 confirm 후 초기화 - * @returns {boolean} 진행 허용 여부 */ confirmAndClearResults: () => { const { queryCompleted } = get(); @@ -209,7 +318,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: (currentTime) => { const { tracks, disabledVesselIds } = get(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach(track => { if (disabledVesselIds.has(track.vesselId)) return; @@ -247,7 +356,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let position, heading, speed; + let position: number[], heading: number, speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -281,7 +390,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ hitDetails: {}, summary: null, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, areaSearchTooltip: null, showPaths: true, @@ -301,7 +410,7 @@ export const useAreaSearchStore = create(subscribeWithSelector((set, get) => ({ summary: null, isLoading: false, queryCompleted: false, - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), highlightedVesselId: null, showZones: true, activeDrawType: null, diff --git a/src/areaSearch/stores/stsStore.js b/src/areaSearch/stores/stsStore.ts similarity index 74% rename from src/areaSearch/stores/stsStore.js rename to src/areaSearch/stores/stsStore.ts index 1cad93ae..8e85a15c 100644 --- a/src/areaSearch/stores/stsStore.js +++ b/src/areaSearch/stores/stsStore.ts @@ -9,8 +9,11 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { STS_DEFAULTS } from '../types/sts.types'; +import type { StsContact, StsGroupedContact } from '../types/sts.types'; +import type { ProcessedTrack } from './areaSearchStore'; +import type { VesselPosition } from '../types/areaSearch.types'; -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: number[], p2: number[], t1: number, t2: number, currentTime: number): number[] { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -18,7 +21,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { return [p1[0] + (p2[0] - p1[0]) * ratio, p1[1] + (p2[1] - p1[1]) * ratio]; } -function calculateHeading(p1, p2) { +function calculateHeading(p1: number[], p2: number[]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -28,11 +31,18 @@ function calculateHeading(p1, p2) { return angle; } +export interface StsSummary { + totalContactPairs: number; + totalVesselsInvolved: number; + totalVesselsInPolygon: number; + processingTimeMs?: number; +} + /** * contacts 배열을 선박 쌍 기준으로 그룹핑 */ -function groupContactsByPair(contacts) { - const groupMap = new Map(); +function groupContactsByPair(contacts: StsContact[]): StsGroupedContact[] { + const groupMap = new Map(); contacts.forEach((contact) => { const v1Id = contact.vessel1.vesselId; @@ -45,13 +55,19 @@ function groupContactsByPair(contacts) { vessel1: v1Id < v2Id ? contact.vessel1 : contact.vessel2, vessel2: v1Id < v2Id ? contact.vessel2 : contact.vessel1, contacts: [], + totalDurationMinutes: 0, + avgDistanceMeters: 0, + minDistanceMeters: 0, + maxDistanceMeters: 0, + totalContactPointCount: 0, + indicators: {}, }); } - groupMap.get(pairKey).contacts.push(contact); + groupMap.get(pairKey)!.contacts.push(contact); }); return [...groupMap.values()].map((group) => { - group.contacts.sort((a, b) => a.contactStartTimestamp - b.contactStartTimestamp); + group.contacts.sort((a, b) => (a.contactStartTimestamp ?? 0) - (b.contactStartTimestamp ?? 0)); // 합산 통계 group.totalDurationMinutes = group.contacts.reduce( @@ -89,9 +105,55 @@ function groupContactsByPair(contacts) { }); } -const positionCursors = new Map(); +const positionCursors = new Map(); -export const useStsStore = create(subscribeWithSelector((set, get) => ({ +interface StsState { + // STS 파라미터 + minContactDurationMinutes: number; + maxContactDistanceMeters: number; + + // 결과 + contacts: StsContact[]; + groupedContacts: StsGroupedContact[]; + tracks: ProcessedTrack[]; + summary: StsSummary | null; + + // UI + isLoading: boolean; + queryCompleted: boolean; + highlightedGroupIndex: number | null; + disabledGroupIndices: Set; + expandedGroupIndex: number | null; + + // 필터 상태 + showPaths: boolean; + showTrail: boolean; + + // 파라미터 설정 + setMinContactDuration: (val: number) => void; + setMaxContactDistance: (val: number) => void; + + // 결과 설정 + setResults: (result: { contacts: StsContact[]; tracks: ProcessedTrack[]; summary: StsSummary | null }) => void; + setLoading: (loading: boolean) => void; + + // 그룹 UI + setHighlightedGroupIndex: (idx: number | null) => void; + setExpandedGroupIndex: (idx: number | null) => void; + toggleGroupEnabled: (idx: number) => void; + + // 필터 + setShowPaths: (show: boolean) => void; + setShowTrail: (show: boolean) => void; + getDisabledVesselIds: () => Set; + getCurrentPositions: (currentTime: number) => VesselPosition[]; + + // 초기화 + clearResults: () => void; + reset: () => void; +} + +export const useStsStore = create()(subscribeWithSelector((set, get) => ({ // STS 파라미터 minContactDurationMinutes: STS_DEFAULTS.MIN_CONTACT_DURATION, maxContactDistanceMeters: STS_DEFAULTS.MAX_CONTACT_DISTANCE, @@ -106,7 +168,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ isLoading: false, queryCompleted: false, highlightedGroupIndex: null, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), expandedGroupIndex: null, // 필터 상태 @@ -129,7 +191,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ tracks, summary, queryCompleted: true, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, }); @@ -160,7 +222,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ getDisabledVesselIds: () => { const { groupedContacts, disabledGroupIndices } = get(); - const ids = new Set(); + const ids = new Set(); disabledGroupIndices.forEach((idx) => { const g = groupedContacts[idx]; if (g) { @@ -174,7 +236,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ getCurrentPositions: (currentTime) => { const { tracks } = get(); const disabledVesselIds = get().getDisabledVesselIds(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach((track) => { if (disabledVesselIds.has(track.vesselId)) return; @@ -209,7 +271,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let position, heading, speed; + let position: number[], heading: number, speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -244,7 +306,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ tracks: [], summary: null, queryCompleted: false, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, showPaths: true, @@ -263,7 +325,7 @@ export const useStsStore = create(subscribeWithSelector((set, get) => ({ summary: null, isLoading: false, queryCompleted: false, - disabledGroupIndices: new Set(), + disabledGroupIndices: new Set(), highlightedGroupIndex: null, expandedGroupIndex: null, showPaths: true, diff --git a/src/areaSearch/types/areaSearch.types.js b/src/areaSearch/types/areaSearch.types.ts similarity index 56% rename from src/areaSearch/types/areaSearch.types.js rename to src/areaSearch/types/areaSearch.types.ts index ae6af06f..7773c65e 100644 --- a/src/areaSearch/types/areaSearch.types.js +++ b/src/areaSearch/types/areaSearch.types.ts @@ -7,7 +7,9 @@ export const ANALYSIS_TABS = { AREA: 'area', STS: 'sts', -}; +} as const; + +export type AnalysisTab = typeof ANALYSIS_TABS[keyof typeof ANALYSIS_TABS]; // ========== 검색 모드 ========== @@ -15,9 +17,11 @@ export const SEARCH_MODES = { ANY: 'ANY', ALL: 'ALL', SEQUENTIAL: 'SEQUENTIAL', -}; +} as const; -export const SEARCH_MODE_LABELS = { +export type SearchMode = typeof SEARCH_MODES[keyof typeof SEARCH_MODES]; + +export const SEARCH_MODE_LABELS: Record = { [SEARCH_MODES.ANY]: 'ANY (합집합)', [SEARCH_MODES.ALL]: 'ALL (교집합)', [SEARCH_MODES.SEQUENTIAL]: 'SEQUENTIAL (순차통과)', @@ -31,11 +35,19 @@ export const ZONE_DRAW_TYPES = { POLYGON: 'Polygon', BOX: 'Box', CIRCLE: 'Circle', -}; +} as const; + +export type ZoneDrawType = typeof ZONE_DRAW_TYPES[keyof typeof ZONE_DRAW_TYPES]; export const ZONE_NAMES = ['A', 'B', 'C']; -export const ZONE_COLORS = [ +export interface ZoneColor { + fill: [number, number, number, number]; + stroke: [number, number, number, number]; + label: string; +} + +export const ZONE_COLORS: ZoneColor[] = [ { fill: [255, 59, 48, 0.15], stroke: [255, 59, 48, 0.8], label: '#FF3B30' }, { fill: [0, 199, 190, 0.15], stroke: [0, 199, 190, 0.8], label: '#00C7BE' }, { fill: [255, 204, 0, 0.15], stroke: [255, 204, 0, 0.8], label: '#FFCC00' }, @@ -49,7 +61,7 @@ export const QUERY_MAX_DAYS = 7; * 조회 가능 기간 계산 (D-7 ~ D-1) * 인메모리 캐시 기반, 오늘 데이터 없음 */ -export function getQueryDateRange() { +export function getQueryDateRange(): { startDate: Date; endDate: Date } { const now = new Date(); const endDate = new Date(now); @@ -80,7 +92,7 @@ import { SIGNAL_KIND_CODE_BUOY, } from '../../types/constants'; -export const ALL_SHIP_KIND_CODES = [ +export const ALL_SHIP_KIND_CODES: string[] = [ SIGNAL_KIND_CODE_FISHING, SIGNAL_KIND_CODE_KCGV, SIGNAL_KIND_CODE_PASSENGER, @@ -98,4 +110,59 @@ export const AREA_SEARCH_LAYER_IDS = { TRIPS_TRAIL: 'area-search-trips-trail', VIRTUAL_SHIP: 'area-search-virtual-ship-layer', VIRTUAL_SHIP_LABEL: 'area-search-virtual-ship-label-layer', -}; +} as const; + +// ========== Zone 인터페이스 ========== + +export interface CircleMeta { + center: [number, number]; + radius: number; +} + +export interface Zone { + id: string; + name: string; + type: ZoneDrawType; + source: string; + coordinates: number[][]; + colorIndex: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- OL Feature (기능 미사용, Session G 패스) + olFeature?: any; + circleMeta: CircleMeta | null; +} + +// ========== 툴팁 ========== + +export interface AreaSearchTooltip { + vesselId: string; + x: number; + y: number; +} + +// ========== HitDetail ========== + +export interface HitDetail { + polygonId: string; + polygonName?: string; + visitIndex: number; + entryTimestamp: number | null; + exitTimestamp: number | null; + entryPosition: number[] | null; + exitPosition: number[] | null; +} + +// ========== VesselPosition ========== + +export interface VesselPosition { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + lon: number; + lat: number; + heading: number; + speed: number; + timestamp: number; +} diff --git a/src/areaSearch/types/sts.types.js b/src/areaSearch/types/sts.types.ts similarity index 61% rename from src/areaSearch/types/sts.types.js rename to src/areaSearch/types/sts.types.ts index 103fa0dc..b542ac52 100644 --- a/src/areaSearch/types/sts.types.js +++ b/src/areaSearch/types/sts.types.ts @@ -8,14 +8,14 @@ import { getShipKindName } from '../../tracking/types/trackQuery.types'; export const STS_DEFAULTS = { MIN_CONTACT_DURATION: 60, MAX_CONTACT_DISTANCE: 500, -}; +} as const; export const STS_LIMITS = { DURATION_MIN: 30, DURATION_MAX: 360, DISTANCE_MIN: 50, DISTANCE_MAX: 5000, -}; +} as const; // ========== 레이어 ID ========== @@ -26,22 +26,71 @@ export const STS_LAYER_IDS = { TRIPS_TRAIL: 'sts-trips-trail-layer', VIRTUAL_SHIP: 'sts-virtual-ship-layer', VIRTUAL_SHIP_LABEL: 'sts-virtual-ship-label-layer', -}; +} as const; // ========== Indicator 라벨 ========== -export const INDICATOR_LABELS = { +export const INDICATOR_LABELS: Record = { lowSpeedContact: '저속', differentVesselTypes: '이종', differentNationalities: '외국적', nightTimeContact: '야간', }; +// ========== STS Contact / Vessel 인터페이스 ========== + +export interface StsVessel { + vesselId: string; + vesselName?: string; + shipKindCode?: string; + nationalCode?: string; + estimatedAvgSpeedKnots?: number | null; + insidePolygonDurationMinutes?: number; + insidePolygonStartTs?: number | null; + insidePolygonEndTs?: number | null; +} + +export interface StsIndicators { + lowSpeedContact?: boolean; + differentVesselTypes?: boolean; + differentNationalities?: boolean; + nightTimeContact?: boolean; + [key: string]: boolean | undefined; +} + +export interface StsContact { + vessel1: StsVessel; + vessel2: StsVessel; + contactStartTimestamp: number | null; + contactEndTimestamp: number | null; + contactDurationMinutes?: number; + contactPointCount?: number; + avgDistanceMeters?: number; + minDistanceMeters: number; + maxDistanceMeters: number; + contactCenterPoint?: number[]; + indicators?: StsIndicators; +} + +export interface StsGroupedContact { + pairKey: string; + vessel1: StsVessel; + vessel2: StsVessel; + contacts: StsContact[]; + totalDurationMinutes: number; + avgDistanceMeters: number; + minDistanceMeters: number; + maxDistanceMeters: number; + contactCenterPoint?: number[]; + totalContactPointCount: number; + indicators: StsIndicators; +} + /** * indicator 뱃지에 맥락 정보를 포함한 텍스트 생성 * 예: "저속 1.2/0.8kn", "이종 어선↔화물선" */ -export function getIndicatorDetail(key, contact) { +export function getIndicatorDetail(key: string, contact: StsContact): string { const { vessel1, vessel2 } = contact; switch (key) { @@ -73,7 +122,7 @@ export function getIndicatorDetail(key, contact) { /** * 거리 포맷 (미터 → 읽기 좋은 형태) */ -export function formatDistance(meters) { +export function formatDistance(meters: number | null | undefined): string { if (meters == null) return '-'; if (meters >= 1000) return `${(meters / 1000).toFixed(1)}km`; return `${Math.round(meters)}m`; @@ -82,7 +131,7 @@ export function formatDistance(meters) { /** * 시간 포맷 (분 → 시분) */ -export function formatDuration(minutes) { +export function formatDuration(minutes: number | null | undefined): string { if (minutes == null) return '-'; if (minutes < 60) return `${minutes}분`; const h = Math.floor(minutes / 60); @@ -96,7 +145,7 @@ export function formatDuration(minutes) { * contact의 indicators 활성 개수에 따라 위험도 색상 반환 * 3+: 빨강, 2: 주황, 1: 노랑, 0: 회색 */ -export function getContactRiskColor(indicators) { +export function getContactRiskColor(indicators: StsIndicators | null | undefined): [number, number, number, number] { if (!indicators) return [150, 150, 150, 200]; const count = Object.values(indicators).filter(Boolean).length; if (count >= 3) return [231, 76, 60, 220]; diff --git a/src/areaSearch/utils/areaSearchLayerRegistry.js b/src/areaSearch/utils/areaSearchLayerRegistry.ts similarity index 54% rename from src/areaSearch/utils/areaSearchLayerRegistry.js rename to src/areaSearch/utils/areaSearchLayerRegistry.ts index 1ac952e4..1ee63fe3 100644 --- a/src/areaSearch/utils/areaSearchLayerRegistry.js +++ b/src/areaSearch/utils/areaSearchLayerRegistry.ts @@ -1,19 +1,21 @@ /** * 항적분석 레이어 전역 레지스트리 - * 참조: src/replay/utils/replayLayerRegistry.js + * 참조: src/replay/utils/replayLayerRegistry.ts * * useAreaSearchLayer 훅이 레이어를 등록하면 * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerAreaSearchLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerAreaSearchLayers(layers: Layer[]): void { window.__areaSearchLayers__ = layers; } -export function getAreaSearchLayers() { +export function getAreaSearchLayers(): Layer[] { return window.__areaSearchLayers__ || []; } -export function unregisterAreaSearchLayers() { +export function unregisterAreaSearchLayers(): void { window.__areaSearchLayers__ = []; } diff --git a/src/areaSearch/utils/csvExport.js b/src/areaSearch/utils/csvExport.ts similarity index 81% rename from src/areaSearch/utils/csvExport.js rename to src/areaSearch/utils/csvExport.ts index 8605263e..ccfddd68 100644 --- a/src/areaSearch/utils/csvExport.js +++ b/src/areaSearch/utils/csvExport.ts @@ -4,15 +4,17 @@ */ import { getShipKindName, getSignalSourceName } from '../../tracking/types/trackQuery.types'; import { getCountryIsoCode } from '../../replay/components/VesselListManager/utils/countryCodeUtils'; +import type { ProcessedTrack } from '../stores/areaSearchStore'; +import type { HitDetail, Zone } from '../types/areaSearch.types'; -function formatTimestamp(ms) { +function formatTimestamp(ms: number | null): string { if (!ms) return ''; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } -function formatPosition(pos) { +function formatPosition(pos: number[] | null): string { if (!pos || pos.length < 2) return ''; const lon = pos[0]; const lat = pos[1]; @@ -21,7 +23,7 @@ function formatPosition(pos) { return `${Math.abs(lat).toFixed(4)}\u00B0${latDir} ${Math.abs(lon).toFixed(4)}\u00B0${lonDir}`; } -function escapeCsvField(value) { +function escapeCsvField(value: string | number): string { const str = String(value ?? ''); if (str.includes(',') || str.includes('"') || str.includes('\n')) { return `"${str.replace(/"/g, '""')}"`; @@ -32,16 +34,20 @@ function escapeCsvField(value) { /** * 검색 결과를 CSV로 내보내기 (다중 방문 동적 컬럼 지원) * - * @param {Array} tracks ProcessedTrack 배열 - * @param {Object} hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] } - * @param {Array} zones 구역 배열 + * @param tracks ProcessedTrack 배열 + * @param hitDetails { vesselId: [{ polygonId, visitIndex, entryTimestamp, exitTimestamp }] } + * @param zones 구역 배열 */ -export function exportSearchResultToCSV(tracks, hitDetails, zones) { +export function exportSearchResultToCSV( + tracks: ProcessedTrack[], + hitDetails: Record, + zones: Zone[], +): void { // 구역별 최대 방문 횟수 계산 - const maxVisitsPerZone = {}; + const maxVisitsPerZone: Record = {}; zones.forEach((z) => { maxVisitsPerZone[z.id] = 1; }); Object.values(hitDetails).forEach((hits) => { - const countByZone = {}; + const countByZone: Record = {}; (Array.isArray(hits) ? hits : []).forEach((h) => { countByZone[h.polygonId] = (countByZone[h.polygonId] || 0) + 1; }); @@ -56,7 +62,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { '포인트수', '이동거리(NM)', '평균속도(kn)', '최대속도(kn)', ]; - const zoneHeaders = []; + const zoneHeaders: string[] = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; if (max === 1) { @@ -78,7 +84,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { // 데이터 행 생성 const rows = tracks.map((track) => { - const baseRow = [ + const baseRow: (string | number)[] = [ getSignalSourceName(track.sigSrcCd), track.targetId || '', track.shipName || '', @@ -91,7 +97,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { ]; const hits = hitDetails[track.vesselId] || []; - const zoneData = []; + const zoneData: string[] = []; zones.forEach((zone) => { const max = maxVisitsPerZone[zone.id] || 1; const zoneHits = hits @@ -129,7 +135,7 @@ export function exportSearchResultToCSV(tracks, hitDetails, zones) { const url = URL.createObjectURL(blob); const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); const filename = `항적분석_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}.csv`; const link = document.createElement('a'); diff --git a/src/areaSearch/utils/stsLayerRegistry.js b/src/areaSearch/utils/stsLayerRegistry.ts similarity index 53% rename from src/areaSearch/utils/stsLayerRegistry.js rename to src/areaSearch/utils/stsLayerRegistry.ts index 60fce214..812cbf2c 100644 --- a/src/areaSearch/utils/stsLayerRegistry.js +++ b/src/areaSearch/utils/stsLayerRegistry.ts @@ -1,19 +1,21 @@ /** * STS 분석 레이어 전역 레지스트리 - * 참조: src/areaSearch/utils/areaSearchLayerRegistry.js + * 참조: src/areaSearch/utils/areaSearchLayerRegistry.ts * * useStsLayer 훅이 레이어를 등록하면 * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerStsLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerStsLayers(layers: Layer[]): void { window.__stsLayers__ = layers; } -export function getStsLayers() { +export function getStsLayers(): Layer[] { return window.__stsLayers__ || []; } -export function unregisterStsLayers() { +export function unregisterStsLayers(): void { window.__stsLayers__ = []; } diff --git a/src/areaSearch/utils/zoneLayerRefs.js b/src/areaSearch/utils/zoneLayerRefs.js deleted file mode 100644 index 81e9a4dc..00000000 --- a/src/areaSearch/utils/zoneLayerRefs.js +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 구역 VectorSource/VectorLayer 모듈 스코프 참조 - * useZoneDraw와 useZoneEdit 간 공유 - */ - -let _source = null; -let _layer = null; - -export function setZoneSource(source) { _source = source; } -export function getZoneSource() { return _source; } -export function setZoneLayer(layer) { _layer = layer; } -export function getZoneLayer() { return _layer; } diff --git a/src/areaSearch/utils/zoneLayerRefs.ts b/src/areaSearch/utils/zoneLayerRefs.ts new file mode 100644 index 00000000..f13830b9 --- /dev/null +++ b/src/areaSearch/utils/zoneLayerRefs.ts @@ -0,0 +1,19 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-nocheck -- OL 패키지 제거됨 (기능 미사용, Session G 패스) +/** + * 구역 VectorSource/VectorLayer 모듈 스코프 참조 + * useZoneDraw와 useZoneEdit 간 공유 + */ + +import type VectorSource from 'ol/source/Vector'; +import type VectorLayer from 'ol/layer/Vector'; +import type Feature from 'ol/Feature'; +import type { Geometry } from 'ol/geom'; + +let _source: VectorSource | null = null; +let _layer: VectorLayer> | null = null; + +export function setZoneSource(source: VectorSource | null): void { _source = source; } +export function getZoneSource(): VectorSource | null { return _source; } +export function setZoneLayer(layer: VectorLayer> | null): void { _layer = layer; } +export function getZoneLayer(): VectorLayer> | null { return _layer; } diff --git a/src/assets/data/shiptype.js b/src/assets/data/shiptype.ts similarity index 99% rename from src/assets/data/shiptype.js rename to src/assets/data/shiptype.ts index adcacd13..1d1b77f0 100644 --- a/src/assets/data/shiptype.js +++ b/src/assets/data/shiptype.ts @@ -1,4 +1,4 @@ -export const shipTypeMap = new Map(); +export const shipTypeMap: Map = new Map(); shipTypeMap.set('0', 'Not available'); shipTypeMap.set('1', 'Reserved for future use'); diff --git a/src/common/stompClient.js b/src/common/stompClient.ts similarity index 74% rename from src/common/stompClient.js rename to src/common/stompClient.ts index 03cc2fa4..43a95251 100644 --- a/src/common/stompClient.js +++ b/src/common/stompClient.ts @@ -3,9 +3,54 @@ * 참조: mda-react-front/src/common/stompClient.ts * 참조: mda-react-front/src/map/MapUpdater.tsx */ -import { Client } from '@stomp/stompjs'; +import { Client, IFrame, StompSubscription } from '@stomp/stompjs'; import { SHIP_MSG_INDEX, STOMP_TOPICS } from '../types/constants'; +/** 선박 데이터 객체 (stompClient에서 파싱) */ +export interface ShipObject { + featureId: string; + targetId: string; + originalTargetId: string; + signalSourceCode: string; + shipName: string; + shipType: string; + longitude: number; + latitude: number; + sog: number; + cog: number; + receivedTime: string; + signalKindCode: string; + lost: boolean; + integrate: boolean; + isPriority: boolean; + hazardousCategory: string; + nationalCode: string; + imo: string; + draught: string; + dimA: string; + dimB: string; + dimC: string; + dimD: string; + ais: string | undefined; + vpass: string | undefined; + enav: string | undefined; + vtsAis: string | undefined; + dMfHf: string | undefined; + vtsRadar: string | undefined; + _raw: string[]; +} + +/** STOMP 연결 콜백 */ +interface StompCallbacks { + onConnect?: (frame: IFrame) => void; + onDisconnect?: () => void; + onError?: (frame: IFrame) => void; +} + +/** 선박 카운트 메시지 (JSON) */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ShipCountData = any; + /** * STOMP 클라이언트 인스턴스 * 환경변수: VITE_SIGNAL_WS (예: ws://10.26.252.39:9090/connect) @@ -17,7 +62,7 @@ export const signalStompClient = new Client({ brokerURL, reconnectDelay: 10000, connectionTimeout: 5000, - debug: (str) => { + debug: (str: string) => { // STOMP 디버그 로그 (연결 관련 메시지만 출력) if (str.includes('Opening') || str.includes('connected') || str.includes('error') || str.includes('closed')) { console.log('[STOMP Debug]', str); @@ -31,7 +76,7 @@ export const signalStompClient = new Client({ * @param {string} msgString - 파이프 구분 문자열 * @returns {Array} 파싱된 배열 */ -export function parsePipeMessage(msgString) { +export function parsePipeMessage(msgString: string): string[] { return msgString.split('|'); } @@ -40,9 +85,9 @@ export function parsePipeMessage(msgString) { * 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget() * * @param {Array} row - 파싱된 메시지 배열 (38개 요소) - * @returns {Object} 선박 데이터 객체 + * @returns {ShipObject} 선박 데이터 객체 */ -export function rowToShipObject(row) { +export function rowToShipObject(row: string[]): ShipObject { const idx = SHIP_MSG_INDEX; const targetId = row[idx.TARGET_ID] || ''; @@ -122,10 +167,10 @@ export function rowToShipObject(row) { * @param {Function} callbacks.onDisconnect - 연결 해제 시 * @param {Function} callbacks.onError - 에러 발생 시 */ -export function connectStomp(callbacks = {}) { +export function connectStomp(callbacks: StompCallbacks = {}): void { const { onConnect, onDisconnect, onError } = callbacks; - signalStompClient.onConnect = (frame) => { + signalStompClient.onConnect = (frame: IFrame) => { console.log('[STOMP] Connected'); onConnect?.(frame); }; @@ -135,7 +180,7 @@ export function connectStomp(callbacks = {}) { onDisconnect?.(); }; - signalStompClient.onStompError = (frame) => { + signalStompClient.onStompError = (frame: IFrame) => { console.error('[STOMP] Error:', frame.headers?.message || 'Unknown error'); onError?.(frame); }; @@ -146,7 +191,7 @@ export function connectStomp(callbacks = {}) { /** * STOMP 연결 해제 */ -export function disconnectStomp() { +export function disconnectStomp(): void { if (signalStompClient.connected) { signalStompClient.deactivate(); } @@ -157,9 +202,9 @@ export function disconnectStomp() { * - 개발: /topic/ship (실시간) * - 프로덕션: /topic/ship-throttled-60s (위성망 대응) * @param {Function} onMessage - 메시지 수신 콜백 (파싱된 선박 데이터 배열) - * @returns {Object} 구독 객체 (unsubscribe 호출용) + * @returns {StompSubscription} 구독 객체 (unsubscribe 호출용) */ -export function subscribeShips(onMessage) { +export function subscribeShips(onMessage: (ships: ShipObject[]) => void): StompSubscription { // 환경변수로 쓰로틀 설정 (VITE_SHIP_THROTTLE: 0=실시간, 5/10/30/60=쓰로틀) const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); @@ -172,9 +217,9 @@ export function subscribeShips(onMessage) { return signalStompClient.subscribe(topic, (message) => { try { const body = message.body; - const lines = body.split('\n').filter(line => line.trim()); + const lines = body.split('\n').filter((line: string) => line.trim()); - const ships = lines.map(line => { + const ships = lines.map((line: string) => { const row = parsePipeMessage(line); return rowToShipObject(row); }); @@ -190,9 +235,9 @@ export function subscribeShips(onMessage) { * 선박 토픽 구독 (Raw 문자열 반환, Worker용) * - Web Worker에서 파싱을 수행할 때 사용 * @param {Function} onMessage - 메시지 수신 콜백 (파이프 구분 문자열 배열) - * @returns {Object} 구독 객체 (unsubscribe 호출용) + * @returns {StompSubscription} 구독 객체 (unsubscribe 호출용) */ -export function subscribeShipsRaw(onMessage) { +export function subscribeShipsRaw(onMessage: (lines: string[]) => void): StompSubscription { const throttleSeconds = parseInt(import.meta.env.VITE_SHIP_THROTTLE || '0', 10); const topic = throttleSeconds > 0 @@ -205,7 +250,7 @@ export function subscribeShipsRaw(onMessage) { try { const body = message.body; // 파싱 없이 줄 단위로 분리만 해서 전달 - const lines = body.split('\n').filter(line => line.trim()); + const lines = body.split('\n').filter((line: string) => line.trim()); onMessage(lines); } catch (error) { console.error('[STOMP] Ship message parse error:', error); @@ -216,9 +261,9 @@ export function subscribeShipsRaw(onMessage) { /** * 선박 삭제 토픽 구독 * @param {Function} onDelete - 삭제 메시지 수신 콜백 (featureId) - * @returns {Object} 구독 객체 + * @returns {StompSubscription} 구독 객체 */ -export function subscribeShipDelete(onDelete) { +export function subscribeShipDelete(onDelete: (featureId: string) => void): StompSubscription { console.log(`[STOMP] Subscribing to ${STOMP_TOPICS.SHIP_DELETE}`); return signalStompClient.subscribe(STOMP_TOPICS.SHIP_DELETE, (message) => { @@ -239,9 +284,9 @@ export function subscribeShipDelete(onDelete) { /** * 선박 카운트 토픽 구독 * @param {Function} onCount - 카운트 메시지 수신 콜백 - * @returns {Object} 구독 객체 + * @returns {StompSubscription} 구독 객체 */ -export function subscribeShipCount(onCount) { +export function subscribeShipCount(onCount: (counts: ShipCountData) => void): StompSubscription { return signalStompClient.subscribe(STOMP_TOPICS.COUNT, (message) => { try { const counts = JSON.parse(message.body); diff --git a/src/components/auth/SessionGuard.jsx b/src/components/auth/SessionGuard.tsx similarity index 83% rename from src/components/auth/SessionGuard.jsx rename to src/components/auth/SessionGuard.tsx index 40c83d30..904659d5 100644 --- a/src/components/auth/SessionGuard.jsx +++ b/src/components/auth/SessionGuard.tsx @@ -1,10 +1,14 @@ -import { useEffect } from 'react'; +import { useEffect, type ReactNode } from 'react'; import { useAuthStore } from '../../stores/authStore'; import './SessionGuard.scss'; const SKIP_AUTH = import.meta.env.VITE_DEV_SKIP_AUTH === 'true'; -export default function SessionGuard({ children }) { +interface SessionGuardProps { + children: ReactNode; +} + +export default function SessionGuard({ children }: SessionGuardProps) { const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isChecking = useAuthStore((s) => s.isChecking); diff --git a/src/components/common/AlertModal.jsx b/src/components/common/AlertModal.tsx similarity index 67% rename from src/components/common/AlertModal.jsx rename to src/components/common/AlertModal.tsx index ec7e8b09..0e3949c3 100644 --- a/src/components/common/AlertModal.jsx +++ b/src/components/common/AlertModal.tsx @@ -2,19 +2,24 @@ import { useState } from 'react'; import { createPortal } from 'react-dom'; import './AlertModal.scss'; -let showAlertFn = null; +interface AlertState { + message: string; + errorCode?: string; +} -export function showAlert(message, errorCode) { +let showAlertFn: ((message: string, errorCode?: string) => void) | null = null; + +export function showAlert(message: string, errorCode?: string): void { if (showAlertFn) { showAlertFn(message, errorCode); } } export function AlertModalContainer() { - const [alert, setAlert] = useState(null); + const [alert, setAlert] = useState(null); useState(() => { - showAlertFn = (message, errorCode) => { + showAlertFn = (message: string, errorCode?: string) => { setAlert({ message, errorCode }); }; @@ -31,7 +36,7 @@ export function AlertModalContainer() { return createPortal(
      -
      e.stopPropagation()}> +
      ) => e.stopPropagation()}>
      {alert.message}
      {alert.errorCode && (
      오류 코드: {alert.errorCode}
      diff --git a/src/components/common/LoadingOverlay.jsx b/src/components/common/LoadingOverlay.tsx similarity index 87% rename from src/components/common/LoadingOverlay.jsx rename to src/components/common/LoadingOverlay.tsx index 6939ce74..4ca5b14c 100644 --- a/src/components/common/LoadingOverlay.jsx +++ b/src/components/common/LoadingOverlay.tsx @@ -7,7 +7,11 @@ import { createPortal } from 'react-dom'; import './LoadingOverlay.scss'; -export default function LoadingOverlay({ message = '조회중...' }) { +interface LoadingOverlayProps { + message?: string; +} + +export default function LoadingOverlay({ message = '조회중...' }: LoadingOverlayProps) { return createPortal(
      diff --git a/src/components/common/Toast.jsx b/src/components/common/Toast.tsx similarity index 72% rename from src/components/common/Toast.jsx rename to src/components/common/Toast.tsx index 61bb66c4..72a69c2c 100644 --- a/src/components/common/Toast.jsx +++ b/src/components/common/Toast.tsx @@ -6,10 +6,16 @@ import { useEffect, useState } from 'react'; import { createPortal } from 'react-dom'; import './Toast.scss'; -// 토스트 메시지 표시 함수 (외부에서 호출용) -let showToastFn = null; +interface ToastData { + id: number; + message: string; + duration: number; +} -export function showToast(message, duration = 3000) { +// 토스트 메시지 표시 함수 (외부에서 호출용) +let showToastFn: ((message: string, duration: number) => void) | null = null; + +export function showToast(message: string, duration: number = 3000): void { if (showToastFn) { showToastFn(message, duration); } @@ -20,10 +26,10 @@ export function showToast(message, duration = 3000) { * App 최상위에 한 번만 마운트 */ export function ToastContainer() { - const [toasts, setToasts] = useState([]); + const [toasts, setToasts] = useState([]); useEffect(() => { - showToastFn = (message, duration) => { + showToastFn = (message: string, duration: number) => { const id = Date.now(); setToasts((prev) => [...prev, { id, message, duration }]); }; @@ -33,7 +39,7 @@ export function ToastContainer() { }; }, []); - const removeToast = (id) => { + const removeToast = (id: number) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }; @@ -52,12 +58,18 @@ export function ToastContainer() { ); } +interface ToastItemProps { + message: string; + duration: number; + onClose: () => void; +} + /** * 개별 토스트 아이템 */ -function ToastItem({ message, duration, onClose }) { - const [isVisible, setIsVisible] = useState(false); - const [isExiting, setIsExiting] = useState(false); +function ToastItem({ message, duration, onClose }: ToastItemProps) { + const [isVisible, setIsVisible] = useState(false); + const [isExiting, setIsExiting] = useState(false); useEffect(() => { // 마운트 후 바로 표시 diff --git a/src/components/layout/Header.jsx b/src/components/layout/Header.tsx similarity index 95% rename from src/components/layout/Header.jsx rename to src/components/layout/Header.tsx index ebc56310..bc6bd3a5 100644 --- a/src/components/layout/Header.jsx +++ b/src/components/layout/Header.tsx @@ -16,9 +16,9 @@ const SAMPLE_ALERTS = [ */ export default function Header() { const user = useAuthStore((s) => s.user); - const alertIndexRef = useRef(0); + const alertIndexRef = useRef(0); - const handleAlarmClick = (e) => { + const handleAlarmClick = (e: React.MouseEvent) => { e.preventDefault(); const alert = SAMPLE_ALERTS[alertIndexRef.current % SAMPLE_ALERTS.length]; alertIndexRef.current++; diff --git a/src/components/layout/MainLayout.jsx b/src/components/layout/MainLayout.tsx similarity index 100% rename from src/components/layout/MainLayout.jsx rename to src/components/layout/MainLayout.tsx diff --git a/src/components/layout/SideNav.jsx b/src/components/layout/SideNav.tsx similarity index 74% rename from src/components/layout/SideNav.jsx rename to src/components/layout/SideNav.tsx index e5953b4a..7d9b4079 100644 --- a/src/components/layout/SideNav.jsx +++ b/src/components/layout/SideNav.tsx @@ -2,7 +2,14 @@ * 사이드 네비게이션 메뉴 */ -const gnbList = [ +interface GnbItem { + key: string; + className: string; + label: string; + path: string; +} + +const gnbList: GnbItem[] = [ { key: 'gnb1', className: 'gnb1', label: '선박', path: 'ship' }, { key: 'gnb4', className: 'gnb4', label: '분석', path: 'analysis' }, { key: 'gnb5', className: 'gnb5', label: '타임라인', path: 'timeline' }, @@ -10,7 +17,12 @@ const gnbList = [ { key: 'gnb8', className: 'gnb8', label: '항적분석', path: 'area-search' }, ]; -export default function SideNav({ activeKey, onChange }) { +interface SideNavProps { + activeKey: string | null; + onChange: (key: string) => void; +} + +export default function SideNav({ activeKey, onChange }: SideNavProps) { return (
      @@ -383,7 +396,7 @@ export default function TopBar() { {/* 검색 결과 목록 */} {isSearchFocused && searchValue && results.length > 0 && (
        - {results.map((result) => ( + {results.map((result: SearchResult) => (
      • 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passIcon, @@ -42,10 +42,15 @@ const SHIP_KIND_ICONS = { [SIGNAL_KIND_CODE_BUOY]: bouyIcon, }; +interface LegendItemConfig { + code: string; + label: string; +} + /** * 범례 항목 설정 */ -const LEGEND_ITEMS = [ +const LEGEND_ITEMS: LegendItemConfig[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, { code: SIGNAL_KIND_CODE_CARGO, label: '화물선' }, @@ -56,10 +61,19 @@ const LEGEND_ITEMS = [ { code: SIGNAL_KIND_CODE_NORMAL, label: '기타' }, ]; +interface ReplayLegendItemProps { + code: string; + label: string; + count: number; + icon: string; + isVisible: boolean; + onToggle: (code: string) => void; +} + /** * 리플레이 범례 항목 */ -const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { +const ReplayLegendItem = memo(function ReplayLegendItem({ code, label, count, icon, isVisible, onToggle }: ReplayLegendItemProps) { const isBuoy = code === SIGNAL_KIND_CODE_BUOY; return ( @@ -82,17 +96,20 @@ const ReplayLegendItem = memo(({ code, label, count, icon, isVisible, onToggle } /** * 리플레이 전용 범례 컴포넌트 */ -const ReplayLegend = memo(() => { +const ReplayLegend = memo(function ReplayLegend() { const { replayShipCounts, replayTotalCount, shipKindCodeFilter } = useReplayStore( - (state) => ({ + useShallow((state: { + replayShipCounts: Record; + replayTotalCount: number; + shipKindCodeFilter: Set; + }) => ({ replayShipCounts: state.replayShipCounts, replayTotalCount: state.replayTotalCount, shipKindCodeFilter: state.shipKindCodeFilter, - }), - shallow + })) ); - const toggleShipKindCode = useReplayStore((state) => state.toggleShipKindCode); + const toggleShipKindCode = useReplayStore((state: { toggleShipKindCode: (code: string) => void }) => state.toggleShipKindCode); return (
        diff --git a/src/components/ship/ShipContextMenu.jsx b/src/components/ship/ShipContextMenu.tsx similarity index 78% rename from src/components/ship/ShipContextMenu.jsx rename to src/components/ship/ShipContextMenu.tsx index 7d159409..1855cba4 100644 --- a/src/components/ship/ShipContextMenu.jsx +++ b/src/components/ship/ShipContextMenu.tsx @@ -13,15 +13,29 @@ import { buildVesselListForQuery, deduplicateVessels, } from '../../tracking/services/trackQueryApi'; +import type { VesselQueryTarget } from '../../tracking/services/trackQueryApi'; +import type { ShipFeature } from '../../types/ship'; import './ShipContextMenu.scss'; +interface ContextMenuData { + x: number; + y: number; + ships: ShipFeature[]; +} + +interface MenuItem { + key: string; + label: string; + hasSubmenu?: boolean; +} + /** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */ -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } -const MENU_ITEMS = [ +const MENU_ITEMS: MenuItem[] = [ { key: 'track', label: '항적조회' }, // TODO: 임시 배포용 - 미구현 기능 숨김 // { key: 'analysis', label: '항적분석' }, @@ -30,20 +44,20 @@ const MENU_ITEMS = [ ]; export default function ShipContextMenu() { - const contextMenu = useShipStore((s) => s.contextMenu); - const closeContextMenu = useShipStore((s) => s.closeContextMenu); - const setRadius = useTrackingModeStore((s) => s.setRadius); - const selectTrackedShip = useTrackingModeStore((s) => s.selectTrackedShip); - const currentRadius = useTrackingModeStore((s) => s.radiusNM); - const menuRef = useRef(null); - const [hoveredItem, setHoveredItem] = useState(null); + const contextMenu = useShipStore((s: { contextMenu: ContextMenuData | null }) => s.contextMenu); + const closeContextMenu = useShipStore((s: { closeContextMenu: () => void }) => s.closeContextMenu); + const setRadius = useTrackingModeStore((s: { setRadius: (r: number) => void }) => s.setRadius); + const selectTrackedShip = useTrackingModeStore((s: { selectTrackedShip: (id: string, ship: ShipFeature) => void }) => s.selectTrackedShip); + const currentRadius = useTrackingModeStore((s: { radiusNM: number }) => s.radiusNM); + const menuRef = useRef(null); + const [hoveredItem, setHoveredItem] = useState(null); // 외부 클릭 시 닫기 useEffect(() => { if (!contextMenu) return; - const handleClick = (e) => { - if (menuRef.current && !menuRef.current.contains(e.target)) { + const handleClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { closeContextMenu(); } }; @@ -53,7 +67,7 @@ export default function ShipContextMenu() { }, [contextMenu, closeContextMenu]); // 반경 선택 핸들러 - const handleRadiusSelect = useCallback((radius) => { + const handleRadiusSelect = useCallback((radius: number) => { if (!contextMenu) return; const { ships } = contextMenu; @@ -67,7 +81,7 @@ export default function ShipContextMenu() { }, [contextMenu, setRadius, selectTrackedShip, closeContextMenu]); // 메뉴 항목 클릭 - const handleAction = useCallback(async (key) => { + const handleAction = useCallback(async (key: string) => { if (!contextMenu) return; const { ships } = contextMenu; @@ -86,8 +100,8 @@ export default function ShipContextMenu() { const { isIntegrate, features } = useShipStore.getState(); - const allVessels = []; - const errors = []; + const allVessels: VesselQueryTarget[] = []; + const errors: string[] = []; ships.forEach(ship => { const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features); if (result.canQuery) allVessels.push(...result.vessels); @@ -134,7 +148,7 @@ export default function ShipContextMenu() { const { isIntegrate, features } = useShipStore.getState(); - const allVessels = []; + const allVessels: VesselQueryTarget[] = []; ships.forEach(ship => { const result = buildVesselListForQuery(ship, 'rightClick', isIntegrate, features); if (result.canQuery) allVessels.push(...result.vessels); @@ -213,28 +227,28 @@ export default function ShipContextMenu() { style={{ left: adjustedX, top: adjustedY }} >
        {title}
        - {visibleMenuItems.map((item, index) => ( + {visibleMenuItems.map((_item, _index) => (
        handleAction(item.key)} - onMouseEnter={() => setHoveredItem(item.key)} + key={_item.key} + className={`ship-context-menu__item ${_item.hasSubmenu ? 'has-submenu' : ''}`} + onClick={() => handleAction(_item.key)} + onMouseEnter={() => setHoveredItem(_item.key)} onMouseLeave={() => setHoveredItem(null)} > - {item.label} - {item.hasSubmenu && } + {_item.label} + {_item.hasSubmenu && } {/* 반경설정 서브메뉴 */} - {item.key === 'radius' && hoveredItem === 'radius' && ( + {_item.key === 'radius' && hoveredItem === 'radius' && (
        - {RADIUS_OPTIONS.map((radius) => ( + {RADIUS_OPTIONS.map((radius: number) => (
        { + onClick={(e: React.MouseEvent) => { e.stopPropagation(); handleRadiusSelect(radius); }} diff --git a/src/components/ship/ShipDetailModal.jsx b/src/components/ship/ShipDetailModal.tsx similarity index 86% rename from src/components/ship/ShipDetailModal.jsx rename to src/components/ship/ShipDetailModal.tsx index 017e2e8a..f414540b 100644 --- a/src/components/ship/ShipDetailModal.jsx +++ b/src/components/ship/ShipDetailModal.tsx @@ -7,8 +7,10 @@ * - 선박 사진 갤러리 (없으면 기본 이미지) * - 새 모달은 직전 모달의 현재 위치(드래그 반영) 기준 우측 140px 오프셋으로 생성 */ -import { useRef, useState, useCallback, useEffect } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; import useShipStore from '../../stores/shipStore'; +import type { DetailModal } from '../../stores/shipStore'; +import type { ShipFeature } from '../../types/ship'; import { useTrackQueryStore } from '../../tracking/stores/trackQueryStore'; import { fetchVesselTracksV2, @@ -39,7 +41,7 @@ import etcIcon from '../../assets/img/shipDetail/detailKindIcon/etc.svg'; import './ShipDetailModal.scss'; /** 선종코드 → 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, @@ -49,7 +51,7 @@ const SHIP_KIND_ICONS = { }; /** 선종 아이콘 URL 반환 */ -function getShipKindIcon(signalKindCode) { +function getShipKindIcon(signalKindCode: string): string { return SHIP_KIND_ICONS[signalKindCode] || etcIcon; } @@ -57,10 +59,8 @@ function getShipKindIcon(signalKindCode) { * 국기 아이콘 URL 반환 (서버 API) * 참조: mda-react-front/src/services/filterCheck.ts - filterNationFlag() * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 - * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') - * @returns {string} 국기 이미지 URL */ -function getNationalFlagUrl(nationalCode) { +function getNationalFlagUrl(nationalCode: string | undefined): string | null { if (!nationalCode) return null; // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) return `/ship/image/small/${nationalCode}.svg`; @@ -69,10 +69,8 @@ function getNationalFlagUrl(nationalCode) { /** * receivedTime 문자열을 YYYY-MM-DD HH:mm:ss 형식으로 변환 * 입력 예: '20241123112300' 또는 '2024-11-23 11:23:00' 또는 '2024-11-23T11:23:00' - * @param {string} raw - * @returns {string} */ -function formatDateTime(raw) { +function formatDateTime(raw: string | undefined): string { if (!raw) return '-'; // 이미 YYYY-MM-DD HH:mm:ss 형태면 그대로 반환 @@ -97,10 +95,21 @@ function formatDateTime(raw) { return raw; } +/** 시간 범위 */ +interface TimeRange { + fromDate: string; + toDate: string; +} + +/** SignalFlags Props */ +interface SignalFlagsProps { + ship: ShipFeature; +} + /** * AVETDR 신호 플래그 표시 */ -function SignalFlags({ ship }) { +function SignalFlags({ ship }: SignalFlagsProps) { const isIntegrate = useShipStore((s) => s.isIntegrate); // 통합선박 판별: 언더스코어 또는 integrate 플래그 const isIntegratedShip = ship.targetId && (ship.targetId.includes('_') || ship.integrate); @@ -113,7 +122,7 @@ function SignalFlags({ ship }) { let isVisible = false; if (useIntegratedMode) { - const val = ship[config.dataKey]; + const val = ship[config.dataKey] as string | undefined; if (val === '1') { isVisible = true; isActive = true; } else if (val === '0') { isVisible = true; } } else { @@ -139,11 +148,16 @@ function SignalFlags({ ship }) { ); } +/** ShipGallery Props */ +interface ShipGalleryProps { + imageUrlList: string[] | undefined; +} + /** * 선박 사진 갤러리 * 이미지가 없으면 기본 이미지(default-ship.png) 표시 */ -function ShipGallery({ imageUrlList }) { +function ShipGallery({ imageUrlList }: ShipGalleryProps) { const [currentIndex, setCurrentIndex] = useState(0); const hasImages = imageUrlList && imageUrlList.length > 0; const images = hasImages ? imageUrlList : [defaultShipImg]; @@ -158,7 +172,7 @@ function ShipGallery({ imageUrlList }) { setCurrentIndex((prev) => (prev === total - 1 ? 0 : prev + 1)); }, [total]); - const handleIndicatorClick = useCallback((index) => { + const handleIndicatorClick = useCallback((index: number) => { setCurrentIndex(index); }, []); @@ -179,7 +193,7 @@ function ShipGallery({ imageUrlList }) { className="galleryImg" src={images[currentIndex]} alt="선박 이미지" - onError={(e) => { e.target.src = defaultShipImg; }} + onError={(e) => { (e.target as HTMLImageElement).src = defaultShipImg; }} />
        {canSlide && ( @@ -199,11 +213,15 @@ function ShipGallery({ imageUrlList }) { ); } +/** ShipDetailModal Props */ +interface ShipDetailModalProps { + modal: DetailModal; +} + /** * 단일 선박 상세 모달 - * @param {Object} props.modal - { ship, id, initialPos } */ -export default function ShipDetailModal({ modal }) { +export default function ShipDetailModal({ modal }: ShipDetailModalProps) { const closeDetailModal = useShipStore((s) => s.closeDetailModal); const updateModalPos = useShipStore((s) => s.updateModalPos); const isIntegrateMode = useShipStore((s) => s.isIntegrate); @@ -211,11 +229,11 @@ export default function ShipDetailModal({ modal }) { // 항적조회 패널 상태 const [showTrackPanel, setShowTrackPanel] = useState(false); const [isQuerying, setIsQuerying] = useState(false); - const [timeRange, setTimeRange] = useState(() => { + const [timeRange, setTimeRange] = useState(() => { const now = new Date(); const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); // 3일 전 - const pad = (n) => String(n).padStart(2, '0'); - const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + const pad = (n: number) => String(n).padStart(2, '0'); + const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; return { fromDate: toLocal(from), toDate: toLocal(now) }; }); @@ -226,7 +244,7 @@ export default function ShipDetailModal({ modal }) { const dragStart = useRef({ x: 0, y: 0 }); // 드래그 핸들러 - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - position.x, @@ -236,7 +254,7 @@ export default function ShipDetailModal({ modal }) { }, [position]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; const newPos = { x: e.clientX - dragStart.current.x, @@ -263,13 +281,13 @@ export default function ShipDetailModal({ modal }) { }, [modal.id, updateModalPos]); // KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC 기준이므로 사용하지 않음) - const toKstISOString = useCallback((date) => { - const pad = (n, len = 2) => String(n).padStart(len, '0'); + const toKstISOString = useCallback((date: Date): string => { + const pad = (n: number, len = 2) => String(n).padStart(len, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }, []); // 항적 조회 실행 (공용) - const executeTrackQuery = useCallback(async (fromDate, toDate) => { + const executeTrackQuery = useCallback(async (fromDate: string | Date, toDate: string | Date) => { const { ship } = modal; const startTime = new Date(fromDate); const endTime = new Date(toDate); @@ -277,7 +295,6 @@ export default function ShipDetailModal({ modal }) { if (isNaN(startTime.getTime()) || isNaN(endTime.getTime())) return; if (startTime >= endTime) return; - const isIntegrated = isIntegratedTargetId(ship.targetId); // 모달 항적조회: 통합모드 ON이면 전체 장비 조회, OFF면 단일 장비 조회 // isIntegration API 파라미터는 항상 '0' (개별 항적 반환) const queryResult = buildVesselListForQuery(ship, 'modal', isIntegrateMode); @@ -320,8 +337,8 @@ export default function ShipDetailModal({ modal }) { // 즉시 3일 항적 조회 const now = new Date(); const from = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); - const pad = (n) => String(n).padStart(2, '0'); - const toLocal = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; + const pad = (n: number) => String(n).padStart(2, '0'); + const toLocal = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; const newTimeRange = { fromDate: toLocal(from), toDate: toLocal(now) }; setTimeRange(newTimeRange); @@ -366,9 +383,9 @@ export default function ShipDetailModal({ modal }) { {ship.nationalCode && ( 국기 { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> )} @@ -384,7 +401,7 @@ export default function ShipDetailModal({ modal }) {
        {/* gallery */} - + {/* body */}
        diff --git a/src/components/ship/ShipFilterPanel.tsx b/src/components/ship/ShipFilterPanel.tsx new file mode 100644 index 00000000..8cf99987 --- /dev/null +++ b/src/components/ship/ShipFilterPanel.tsx @@ -0,0 +1,152 @@ +/** + * 선박 필터 패널 + * - 선종별 표시/숨김 토글 + * - 기존 DisplayComponent의 필터 탭을 민간화 버전으로 재구현 + */ +import { useState, memo, useCallback, ReactNode } from 'react'; +import useShipStore from '../../stores/shipStore'; +import { SHIP_KIND_LIST } from '../../types/constants'; + +interface SwitchGroupProps { + title: string; + children: ReactNode; + defaultOpen?: boolean; +} + +/** + * 스위치 그룹 헤더 + 접이식 본문 + */ +const SwitchGroup = memo(function SwitchGroup({ title, children, defaultOpen = true }: SwitchGroupProps) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( +
        +
        +
        {title}
        +
        +
        + {children} +
        +
        + ); +}); + +interface ToggleSwitchProps { + label: string; + checked: boolean; + onChange: () => void; +} + +/** + * 개별 토글 스위치 (CSS 토글은 기존 common.css의 .switch 클래스 사용) + */ +const ToggleSwitch = memo(function ToggleSwitch({ label, checked, onChange }: ToggleSwitchProps) { + return ( +
      • + {label} + +
      • + ); +}); + +interface AllToggleProps { + label: string; + allChecked: boolean; + onToggleAll: () => void; +} + +/** + * 전체 ON/OFF 토글 + */ +const AllToggle = memo(function AllToggle({ label, allChecked, onToggleAll }: AllToggleProps) { + return ( +
      • + {label} + +
      • + ); +}); + +interface ShipFilterPanelProps { + isOpen: boolean; + onToggle: () => void; +} + +/** + * 선박 필터 패널 메인 컴포넌트 + */ +export default function ShipFilterPanel({ isOpen, onToggle }: ShipFilterPanelProps) { + const kindVisibility = useShipStore((s: { kindVisibility: Record }) => s.kindVisibility); + const kindCounts = useShipStore((s: { kindCounts: Record }) => s.kindCounts); + const toggleKindVisibility = useShipStore((s: { toggleKindVisibility: (code: string) => void }) => s.toggleKindVisibility); + + // 선종 전체 토글 + const allKindVisible = Object.values(kindVisibility).every(Boolean); + const handleToggleAllKind = useCallback(() => { + const nextValue = !allKindVisible; + SHIP_KIND_LIST.forEach(({ code }: { code: string }) => { + if (kindVisibility[code] !== nextValue) { + toggleKindVisibility(code); + } + }); + }, [allKindVisible, kindVisibility, toggleKindVisibility]); + + // 전체 선박 수 + const totalCount = Object.values(kindCounts).reduce((sum: number, v: number) => sum + v, 0); + + return ( +
        + {/* 탭 헤더 */} +
        +
          +
        • + +
        • +
        +
        + + {/* 필터 본문 */} +
        +
        +
        + + {/* 선종 필터 */} + +
          + + {SHIP_KIND_LIST.map(({ code, label }: { code: string; label: string }) => ( + toggleKindVisibility(code)} + /> + ))} +
        +
        + +
        +
        +
        + + {/* 패널 토글 버튼 */} +
        + ); +} diff --git a/src/components/ship/ShipLegend.jsx b/src/components/ship/ShipLegend.tsx similarity index 73% rename from src/components/ship/ShipLegend.jsx rename to src/components/ship/ShipLegend.tsx index 237f184d..7c28671a 100644 --- a/src/components/ship/ShipLegend.jsx +++ b/src/components/ship/ShipLegend.tsx @@ -6,7 +6,7 @@ * - 항적분석 활성 시 결과 카운트 표시 */ import { memo, useMemo } from 'react'; -import { shallow } from 'zustand/shallow'; +import { useShallow } from 'zustand/shallow'; import useShipStore from '../../stores/shipStore'; import { useAreaSearchStore } from '../../areaSearch/stores/areaSearchStore'; import { @@ -32,9 +32,9 @@ import bouyIcon from '../../assets/img/shipKindIcons/bouy.svg'; import etcIcon from '../../assets/img/shipKindIcons/etc.svg'; /** - * 선박 종류 코드 → 아이콘 매핑 + * 선박 종류 코드 -> 아이콘 매핑 */ -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, [SIGNAL_KIND_CODE_PASSENGER]: passIcon, @@ -45,10 +45,15 @@ const SHIP_KIND_ICONS = { [SIGNAL_KIND_CODE_BUOY]: bouyIcon, }; +interface LegendItemConfig { + code: string; + label: string; +} + /** * 범례 항목 설정 */ -const LEGEND_ITEMS = [ +const LEGEND_ITEMS: LegendItemConfig[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, { code: SIGNAL_KIND_CODE_CARGO, label: '화물선' }, @@ -59,10 +64,19 @@ const LEGEND_ITEMS = [ { code: SIGNAL_KIND_CODE_NORMAL, label: '기타' }, ]; +interface LegendItemProps { + code: string; + label: string; + count: number; + icon: string; + isVisible: boolean; + onToggle: (code: string) => void; +} + /** * 선박 범례 항목 */ -const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { +const LegendItem = memo(function LegendItem({ code, label, count, icon, isVisible, onToggle }: LegendItemProps) { // 부이는 회전하지 않음 const isBuoy = code === SIGNAL_KIND_CODE_BUOY; @@ -83,36 +97,51 @@ const LegendItem = memo(({ code, label, count, icon, isVisible, onToggle }) => { ); }); +interface AreaSearchCounts { + counts: Record; + total: number; +} + +interface AreaSearchTrack { + vesselId: string; + shipKindCode: string; +} + /** * 선박 범례 컴포넌트 */ -const ShipLegend = memo(() => { +const ShipLegend = memo(function ShipLegend() { // 셀렉터 사용: 구독 중인 값이 실제로 바뀔 때만 리렌더 - // useShipStore() 전체 구독 → featuresVersion 변경마다 리렌더되는 문제 방지 + // useShipStore() 전체 구독 -> featuresVersion 변경마다 리렌더되는 문제 방지 const { kindCounts, kindVisibility, isShipVisible, totalCount, isConnected } = useShipStore( - (state) => ({ + useShallow((state: { + kindCounts: Record; + kindVisibility: Record; + isShipVisible: boolean; + totalCount: number; + isConnected: boolean; + }) => ({ kindCounts: state.kindCounts, kindVisibility: state.kindVisibility, isShipVisible: state.isShipVisible, totalCount: state.totalCount, isConnected: state.isConnected, - }), - shallow + })) ); - const toggleKindVisibility = useShipStore((state) => state.toggleKindVisibility); - const toggleShipVisible = useShipStore((state) => state.toggleShipVisible); + const toggleKindVisibility = useShipStore((state: { toggleKindVisibility: (code: string) => void }) => state.toggleKindVisibility); + const toggleShipVisible = useShipStore((state: { toggleShipVisible: () => void }) => state.toggleShipVisible); // 항적분석 활성 시 결과 카운트 - const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted); - const areaSearchTracks = useAreaSearchStore((s) => s.tracks); - const areaSearchDisabledIds = useAreaSearchStore((s) => s.disabledVesselIds); - const areaSearchKindFilter = useAreaSearchStore((s) => s.shipKindCodeFilter); - const toggleAreaSearchKind = useAreaSearchStore((s) => s.toggleShipKindCode); + const areaSearchCompleted = useAreaSearchStore((s: { queryCompleted: boolean }) => s.queryCompleted); + const areaSearchTracks = useAreaSearchStore((s: { tracks: AreaSearchTrack[] }) => s.tracks); + const areaSearchDisabledIds = useAreaSearchStore((s: { disabledVesselIds: Set }) => s.disabledVesselIds); + const areaSearchKindFilter = useAreaSearchStore((s: { shipKindCodeFilter: Set }) => s.shipKindCodeFilter); + const toggleAreaSearchKind = useAreaSearchStore((s: { toggleShipKindCode: (code: string) => void }) => s.toggleShipKindCode); - const areaSearchCounts = useMemo(() => { + const areaSearchCounts = useMemo((): AreaSearchCounts | null => { if (!areaSearchCompleted || areaSearchTracks.length === 0) return null; - const counts = {}; + const counts: Record = {}; let total = 0; areaSearchTracks.forEach((track) => { if (areaSearchDisabledIds.has(track.vesselId)) return; diff --git a/src/components/ship/ShipTooltip.jsx b/src/components/ship/ShipTooltip.tsx similarity index 73% rename from src/components/ship/ShipTooltip.jsx rename to src/components/ship/ShipTooltip.tsx index f2280b31..e36fa795 100644 --- a/src/components/ship/ShipTooltip.jsx +++ b/src/components/ship/ShipTooltip.tsx @@ -8,10 +8,24 @@ import './ShipTooltip.scss'; const OFFSET_X = 12; const OFFSET_Y = -40; -export default function ShipTooltip({ ship, x, y }) { +interface ShipData { + signalKindCode?: string; + sog?: number | string; + cog?: number | string; + shipName?: string; + targetId?: string; +} + +interface ShipTooltipProps { + ship: ShipData | null; + x: number; + y: number; +} + +export default function ShipTooltip({ ship, x, y }: ShipTooltipProps) { if (!ship) return null; - const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode] || '기타'; + const kindLabel = SHIP_KIND_LABELS[ship.signalKindCode as string] || '기타'; const sog = Number(ship.sog) || 0; const cog = Number(ship.cog) || 0; const isMoving = sog > SPEED_THRESHOLD; diff --git a/src/components/ship/TrackQueryModal.jsx b/src/components/ship/TrackQueryModal.tsx similarity index 90% rename from src/components/ship/TrackQueryModal.jsx rename to src/components/ship/TrackQueryModal.tsx index bde49c0f..15bdd7a8 100644 --- a/src/components/ship/TrackQueryModal.jsx +++ b/src/components/ship/TrackQueryModal.tsx @@ -11,12 +11,21 @@ * * 모달 모드에서는 재생/배속 컨트롤 없음 (Phase 2 확장점) */ -import { useRef, useState, useCallback, useEffect } from 'react'; +import React, { useRef, useState, useCallback, useEffect } from 'react'; import useTrackStore, { getShipKindTrackColor } from '../../stores/trackStore'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; +import type { ShipFeature } from '../../types/ship'; import { SHIP_KIND_LABELS, SIGNAL_FLAG_CONFIGS, SIGNAL_SOURCE_LABELS, TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../../types/constants'; import { showToast } from '../common/Toast'; import './TrackQueryModal.scss'; +/** TrackModal 인터페이스 (trackStore 내부 정의와 동일) */ +interface TrackModal { + ships: ShipFeature[]; + id: string; + isIntegrated: boolean; +} + /** 기본 조회 기간 (일) */ const DEFAULT_QUERY_DAYS = TRACK_QUERY_DEFAULT_DAYS; @@ -27,31 +36,36 @@ const MAX_QUERY_DAYS = TRACK_QUERY_MAX_DAYS; const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** datetime-local 입력용 포맷 */ -function toDateTimeLocal(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toDateTimeLocal(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** MM-DD HH:mm 형식 */ -function formatShortDateTime(ms) { +function formatShortDateTime(ms: number): string { if (!ms) return '--/-- --:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** YYYY-MM-DD HH:mm:ss 형식 */ -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms) return '-'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +/** TrackQueryModal Props */ +interface TrackQueryModalProps { + modal: TrackModal; +} + /** * 항적 조회 뷰어 패널 */ -export default function TrackQueryModal({ modal }) { +export default function TrackQueryModal({ modal }: TrackQueryModalProps) { const closeTrackModal = useTrackStore((s) => s.closeTrackModal); // 스토어 상태 구독 @@ -73,24 +87,24 @@ export default function TrackQueryModal({ modal }) { const [endInput, setEndInput] = useState(() => toDateTimeLocal(new Date())); // 시작일 변경 핸들러 - const handleStartChange = useCallback((e) => { + const handleStartChange = useCallback((e: React.ChangeEvent) => { setStartInput(e.target.value); }, []); // 종료일 변경 핸들러 - const handleEndChange = useCallback((e) => { + const handleEndChange = useCallback((e: React.ChangeEvent) => { setEndInput(e.target.value); }, []); // 조회 기간 검증 및 자동 조정 (blur 시 실행) - const validateAndAdjustDates = useCallback((changedField) => { + const validateAndAdjustDates = useCallback((changedField: 'start' | 'end') => { const startDate = new Date(startInput); const endDate = new Date(endInput); // 유효하지 않은 날짜면 무시 if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) return; - const diffDays = (endDate - startDate) / DAYS_TO_MS; + const diffDays = (endDate.getTime() - startDate.getTime()) / DAYS_TO_MS; if (changedField === 'start') { // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 @@ -151,7 +165,7 @@ export default function TrackQueryModal({ modal }) { : 0; // 드래그 핸들러 - const handleDragStart = useCallback((e) => { + const handleDragStart = useCallback((e: React.MouseEvent) => { dragging.current = true; dragStart.current = { x: e.clientX - position.x, @@ -161,7 +175,7 @@ export default function TrackQueryModal({ modal }) { }, [position]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!dragging.current) return; setPosition({ x: e.clientX - dragStart.current.x, @@ -197,7 +211,7 @@ export default function TrackQueryModal({ modal }) { }, [startInput, endInput, modal.ships]); // 프로그레스 바 클릭 - const handleProgressClick = useCallback((e) => { + const handleProgressClick = useCallback((e: React.MouseEvent) => { const rect = e.currentTarget.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); useTrackStore.getState().setProgressByRatio(ratio); @@ -392,18 +406,23 @@ export default function TrackQueryModal({ modal }) { ); } +/** EquipmentFilter Props */ +interface EquipmentFilterProps { + ship: ShipFeature; +} + /** * 장비 필터 (통합선박 AVETDR) * 참조: mda-react-front/src/tracking/hooks/useEquipmentFilter.ts */ -function EquipmentFilter({ ship }) { +function EquipmentFilter({ ship }: EquipmentFilterProps) { const tracks = useTrackStore((s) => s.tracks); const disabledSigSrcCds = useTrackStore((s) => s.disabledSigSrcCds); // 항적 데이터에 존재하는 장비만 표시 const availableSigSrcCds = new Set(tracks.map((t) => t.sigSrcCd)); - const handleToggle = useCallback((sigSrcCd) => { + const handleToggle = useCallback((sigSrcCd: string) => { useTrackStore.getState().toggleEquipment(sigSrcCd); }, []); @@ -461,10 +480,15 @@ function EquipmentFilter({ ship }) { ); } +/** VesselItem Props */ +interface VesselItemProps { + track: ProcessedTrack; +} + /** * 다중 선박 목록 아이템 */ -function VesselItem({ track }) { +function VesselItem({ track }: VesselItemProps) { const handleToggle = useCallback(() => { useTrackStore.getState().toggleVesselEnabled(track.vesselId); }, [track.vesselId]); diff --git a/src/hooks/useFavoriteData.js b/src/hooks/useFavoriteData.js deleted file mode 100644 index 4d243ba1..00000000 --- a/src/hooks/useFavoriteData.js +++ /dev/null @@ -1,40 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { fetchFavoriteShips, fetchRealms } from '../api/favoriteApi'; -import useFavoriteStore from '../stores/favoriteStore'; - -/** - * 관심선박 + 관심구역 데이터 로딩 훅 - * MapContainer에서 1회 호출 - */ -export default function useFavoriteData() { - const loaded = useRef(false); - - useEffect(() => { - if (loaded.current) return; - loaded.current = true; - - const load = async () => { - const [ships, realms] = await Promise.allSettled([ - fetchFavoriteShips(), - fetchRealms(), - ]); - - const shipList = ships.status === 'fulfilled' ? ships.value : []; - const realmList = realms.status === 'fulfilled' ? realms.value : []; - - if (ships.status === 'rejected') { - console.warn('[useFavoriteData] 관심선박 로드 실패:', ships.reason); - } - if (realms.status === 'rejected') { - console.warn('[useFavoriteData] 관심구역 로드 실패:', realms.reason); - } - - useFavoriteStore.getState().setFavoriteList(shipList); - useFavoriteStore.getState().setRealmList(realmList); - - console.log(`[useFavoriteData] 관심선박 ${shipList.length}건, 관심구역 ${realmList.length}건 로드`); - }; - - load(); - }, []); -} diff --git a/src/hooks/useFavoriteData.ts b/src/hooks/useFavoriteData.ts new file mode 100644 index 00000000..b0fbee2a --- /dev/null +++ b/src/hooks/useFavoriteData.ts @@ -0,0 +1,10 @@ +/** + * 관심선박 + 관심구역 데이터 로딩 훅 + * MapContainer에서 1회 호출 + * + * 비활성화: 내부망 API(/api/gis) 접근 불가 (민간화) + * TODO: 외부 API 연동 시 복원 + */ +export default function useFavoriteData(): void { + // noop — 내부망 /api/gis 의존 제거 +} diff --git a/src/hooks/useRadiusFilter.js b/src/hooks/useRadiusFilter.ts similarity index 72% rename from src/hooks/useRadiusFilter.js rename to src/hooks/useRadiusFilter.ts index 85725c75..4a609566 100644 --- a/src/hooks/useRadiusFilter.js +++ b/src/hooks/useRadiusFilter.ts @@ -12,6 +12,44 @@ import useTrackingModeStore, { NM_TO_METERS, } from '../stores/trackingModeStore'; +/** 위치 좌표를 가진 선박 (최소 인터페이스) */ +interface ShipWithCoords { + longitude: number; + latitude: number; +} + +/** 반경 중심 좌표 */ +interface RadiusCenter { + lon: number; + lat: number; +} + +/** Bounding Box */ +interface BoundingBox { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** useRadiusFilter 반환 타입 */ +interface UseRadiusFilterReturn { + filterByRadius: (ships: T[]) => T[]; + filterFeaturesMapByRadius: (featuresMap: Map) => Map; + isShipInRadius: (ship: ShipWithCoords) => boolean; + isRadiusFilterActive: boolean; + radiusCenter: RadiusCenter | null; + radiusNM: number; + boundingBox: BoundingBox | null; +} + +/** 반경 필터 상태 (비훅 버전) */ +interface RadiusFilterState { + isActive: boolean; + center: RadiusCenter | null; + radiusNM: number; +} + /** * 경도 1도당 대략적인 미터 (위도에 따라 다름) * 중위도(35도) 기준 약 91km @@ -21,9 +59,9 @@ const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km /** * 반경 필터링 훅 - * @returns {Object} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM } + * @returns {UseRadiusFilterReturn} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM } */ -export default function useRadiusFilter() { +export default function useRadiusFilter(): UseRadiusFilterReturn { const mode = useTrackingModeStore((s) => s.mode); const trackedShip = useTrackingModeStore((s) => s.trackedShip); const radiusNM = useTrackingModeStore((s) => s.radiusNM); @@ -32,7 +70,7 @@ export default function useRadiusFilter() { const isRadiusFilterActive = mode === 'ship' && trackedShip !== null; // 반경 중심 좌표 - const radiusCenter = useMemo(() => { + const radiusCenter = useMemo((): RadiusCenter | null => { if (!isRadiusFilterActive || !trackedShip) return null; return { lon: trackedShip.longitude, @@ -44,7 +82,7 @@ export default function useRadiusFilter() { * Bounding Box 계산 (사전 필터링용) * 반경을 감싸는 사각형 영역 */ - const boundingBox = useMemo(() => { + const boundingBox = useMemo((): BoundingBox | null => { if (!radiusCenter) return null; const radiusMeters = radiusNM * NM_TO_METERS; @@ -62,7 +100,7 @@ export default function useRadiusFilter() { /** * 선박이 Bounding Box 내에 있는지 빠른 체크 */ - const isInBoundingBox = useCallback((ship) => { + const isInBoundingBox = useCallback((ship: ShipWithCoords): boolean => { if (!boundingBox) return true; if (!ship.longitude || !ship.latitude) return false; @@ -79,7 +117,7 @@ export default function useRadiusFilter() { * @param {Array} ships - 선박 배열 * @returns {Array} 반경 내 선박만 */ - const filterByRadius = useCallback((ships) => { + const filterByRadius = useCallback((ships: T[]): T[] => { // 반경 필터 비활성화 시 전체 반환 if (!isRadiusFilterActive || !radiusCenter) { return ships; @@ -99,7 +137,7 @@ export default function useRadiusFilter() { * @param {Object} ship * @returns {boolean} */ - const isShipInRadius = useCallback((ship) => { + const isShipInRadius = useCallback((ship: ShipWithCoords): boolean => { if (!isRadiusFilterActive || !radiusCenter) return true; if (!isInBoundingBox(ship)) return false; return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM); @@ -110,12 +148,12 @@ export default function useRadiusFilter() { * @param {Map} featuresMap - featureId -> ship Map * @returns {Map} 반경 내 선박만 */ - const filterFeaturesMapByRadius = useCallback((featuresMap) => { + const filterFeaturesMapByRadius = useCallback((featuresMap: Map): Map => { if (!isRadiusFilterActive || !radiusCenter) { return featuresMap; } - const filteredMap = new Map(); + const filteredMap = new Map(); featuresMap.forEach((ship, featureId) => { if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) { filteredMap.set(featureId, ship); @@ -140,7 +178,7 @@ export default function useRadiusFilter() { * 반경 필터 유틸리티 (비훅 버전) * shipStore나 다른 스토어에서 직접 사용 */ -export function getRadiusFilterState() { +export function getRadiusFilterState(): RadiusFilterState { const state = useTrackingModeStore.getState(); const { mode, trackedShip, radiusNM } = state; @@ -160,7 +198,7 @@ export function getRadiusFilterState() { /** * 선박이 반경 내에 있는지 확인 (비훅 버전) */ -export function checkShipInRadius(ship) { +export function checkShipInRadius(ship: ShipWithCoords): boolean { const { isActive, center, radiusNM } = getRadiusFilterState(); if (!isActive || !center) return true; return isWithinRadius(ship, center.lon, center.lat, radiusNM); diff --git a/src/hooks/useRealmLayer.js b/src/hooks/useRealmLayer.js deleted file mode 100644 index ca4ea962..00000000 --- a/src/hooks/useRealmLayer.js +++ /dev/null @@ -1,143 +0,0 @@ -import { useEffect, useRef } from 'react'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import Feature from 'ol/Feature'; -import Polygon from 'ol/geom/Polygon'; -import { Style, Fill, Stroke, Text } from 'ol/style'; -import useFavoriteStore from '../stores/favoriteStore'; -import { useMapStore } from '../stores/mapStore'; - -/** - * 관심구역 OpenLayers 레이어 관리 훅 - * 참조: mda-react-front/src/services/commonService.ts - getRealmLayer() - */ -export default function useRealmLayer() { - const map = useMapStore((s) => s.map); - const layerRef = useRef(null); - const sourceRef = useRef(null); - - useEffect(() => { - if (!map) return; - - const source = new VectorSource(); - const layer = new VectorLayer({ - source, - zIndex: 50, - }); - - map.addLayer(layer); - layerRef.current = layer; - sourceRef.current = source; - - // 초기 데이터 렌더링 - const { realmList, isRealmVisible } = useFavoriteStore.getState(); - console.log(`[useRealmLayer] 초기화: realmList=${realmList.length}건, visible=${isRealmVisible}`); - layer.setVisible(isRealmVisible); - if (realmList.length > 0) { - renderRealms(source, realmList); - } - - // realmList 변경 구독 - const unsubRealmList = useFavoriteStore.subscribe( - (state) => state.realmList, - (newRealmList) => { - console.log(`[useRealmLayer] realmList 변경 감지: ${newRealmList.length}건`); - if (newRealmList.length > 0) { - console.log('[useRealmLayer] 첫 번째 realm 샘플:', JSON.stringify(newRealmList[0]).slice(0, 300)); - } - renderRealms(source, newRealmList); - } - ); - - // isRealmVisible 변경 구독 - const unsubVisible = useFavoriteStore.subscribe( - (state) => state.isRealmVisible, - (isVisible) => { - console.log(`[useRealmLayer] visible 토글: ${isVisible}, layer=${!!layerRef.current}, features=${sourceRef.current?.getFeatures()?.length || 0}`); - if (layerRef.current) { - layerRef.current.setVisible(isVisible); - } - } - ); - - return () => { - unsubRealmList(); - unsubVisible(); - if (map && layerRef.current) { - map.removeLayer(layerRef.current); - } - layerRef.current = null; - sourceRef.current = null; - }; - }, [map]); -} - -/** - * 좌표 배열 정규화 - * API 응답 형식에 관계없이 Polygon 생성에 적합한 형식으로 변환 - * @param {Array} coordinates - 좌표 데이터 - * @returns {Array} Polygon rings 배열 [[lon,lat], ...] - */ -function normalizeCoordinates(coordinates) { - if (!Array.isArray(coordinates) || coordinates.length === 0) return null; - - // coordinates[0]이 숫자 배열이면 → 이미 ring 형태: [[lon,lat], ...] - if (Array.isArray(coordinates[0]) && typeof coordinates[0][0] === 'number') { - return coordinates; - } - - // coordinates[0][0]이 숫자 배열이면 → 이미 rings 형태: [[[lon,lat], ...]] - if (Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0]) && typeof coordinates[0][0][0] === 'number') { - return coordinates[0]; // 첫 번째 ring만 사용 - } - - return null; -} - -/** - * 관심구역 Feature 렌더링 - * @param {VectorSource} source - OL VectorSource - * @param {Array} realmList - 관심구역 데이터 배열 - */ -function renderRealms(source, realmList) { - source.clear(); - - if (!realmList || realmList.length === 0) return; - - let count = 0; - realmList.forEach((realm) => { - const ring = normalizeCoordinates(realm.coordinates); - if (!ring) return; - - try { - const polygon = new Polygon([ring]).transform('EPSG:4326', 'EPSG:3857'); - const feature = new Feature({ geometry: polygon }); - - feature.setStyle( - new Style({ - text: new Text({ - text: realm.seaRelmNameYn === 'Y' ? (realm.seaRelmName || '') : '', - fill: new Fill({ color: realm.fontColor || '#000000' }), - font: `${realm.fontSize || 12}px ${realm.fontKind || 'sans-serif'}`, - }), - stroke: new Stroke({ - color: realm.outlineColor || '#333333', - width: Number(realm.outlineWidth) || 2, - lineDash: realm.outlineType === 'dot' ? [15, 15] : undefined, - }), - fill: new Fill({ color: realm.fillColor || 'rgba(0,0,0,0.1)' }), - }) - ); - - if (realm.seaRelmId) { - feature.setId(realm.seaRelmId); - } - source.addFeature(feature); - count++; - } catch (err) { - console.warn('[useRealmLayer] Feature 생성 실패:', realm.seaRelmName, err); - } - }); - - console.log(`[useRealmLayer] ${count}/${realmList.length}건 렌더링 완료`); -} diff --git a/src/hooks/useRealmLayer.ts b/src/hooks/useRealmLayer.ts new file mode 100644 index 00000000..8869dc06 --- /dev/null +++ b/src/hooks/useRealmLayer.ts @@ -0,0 +1,265 @@ +import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource } from 'maplibre-gl'; +import type { Feature, FeatureCollection, Polygon } from 'geojson'; +import useFavoriteStore from '../stores/favoriteStore'; +import { useMapStore } from '../stores/mapStore'; + +/** MapLibre source/layer ID */ +const REALM_SOURCE_ID = 'realm-source'; +const REALM_FILL_LAYER_ID = 'realm-fill-layer'; +const REALM_LINE_SOLID_LAYER_ID = 'realm-line-solid-layer'; +const REALM_LINE_DOTTED_LAYER_ID = 'realm-line-dotted-layer'; +const REALM_LABEL_LAYER_ID = 'realm-label-layer'; + +const ALL_REALM_LAYER_IDS = [ + REALM_LABEL_LAYER_ID, + REALM_LINE_DOTTED_LAYER_ID, + REALM_LINE_SOLID_LAYER_ID, + REALM_FILL_LAYER_ID, +] as const; + +/** 관심구역 데이터 (API 응답 형태) */ +interface RealmData { + coordinates: number[] | number[][] | number[][][]; + seaRelmNameYn?: string; + seaRelmName?: string; + fontColor?: string; + fontSize?: number; + fontKind?: string; + outlineColor?: string; + outlineWidth?: number | string; + outlineType?: string; + fillColor?: string; + seaRelmId?: string | number; +} + +/** 빈 FeatureCollection */ +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +/** + * 좌표 배열 정규화 + * API 응답 형식에 관계없이 Polygon ring에 적합한 형식으로 변환 + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function normalizeCoordinates(coordinates: any): number[][] | null { + if (!Array.isArray(coordinates) || coordinates.length === 0) return null; + + if (Array.isArray(coordinates[0]) && typeof coordinates[0][0] === 'number') { + return coordinates; + } + + if (Array.isArray(coordinates[0]) && Array.isArray(coordinates[0][0]) && typeof coordinates[0][0][0] === 'number') { + return coordinates[0]; + } + + return null; +} + +/** + * RealmData 배열 → GeoJSON FeatureCollection 변환 + * properties에 스타일 정보를 포함하여 data-driven expression으로 개별 스타일링 + */ +function buildRealmGeoJSON(realmList: RealmData[]): FeatureCollection { + if (!realmList || realmList.length === 0) return EMPTY_FC; + + const features: Feature[] = []; + + realmList.forEach((realm) => { + const ring = normalizeCoordinates(realm.coordinates); + if (!ring || ring.length < 3) return; + + try { + // GeoJSON Polygon ring은 닫혀야 함 (first == last) + const coords = [...ring]; + const first = coords[0]; + const last = coords[coords.length - 1]; + if (first[0] !== last[0] || first[1] !== last[1]) { + coords.push([first[0], first[1]]); + } + + features.push({ + type: 'Feature', + id: realm.seaRelmId != null ? Number(realm.seaRelmId) : undefined, + properties: { + fillColor: realm.fillColor || 'rgba(0,0,0,0.1)', + outlineColor: realm.outlineColor || '#333333', + outlineWidth: Number(realm.outlineWidth) || 2, + isDotted: realm.outlineType === 'dot', + showLabel: realm.seaRelmNameYn === 'Y', + labelText: realm.seaRelmName || '', + fontColor: realm.fontColor || '#000000', + fontSize: realm.fontSize || 12, + }, + geometry: { + type: 'Polygon', + coordinates: [coords], + }, + }); + } catch (err) { + console.warn('[useRealmLayer] Feature 생성 실패:', realm.seaRelmName, err); + } + }); + + return { type: 'FeatureCollection', features }; +} + +/** + * Realm source/layer를 맵에 추가 (멱등) + */ +function ensureRealmLayers(map: maplibregl.Map): void { + if (!map.getSource(REALM_SOURCE_ID)) { + map.addSource(REALM_SOURCE_ID, { + type: 'geojson', + data: EMPTY_FC, + }); + } + + if (!map.getLayer(REALM_FILL_LAYER_ID)) { + map.addLayer({ + id: REALM_FILL_LAYER_ID, + type: 'fill', + source: REALM_SOURCE_ID, + paint: { + 'fill-color': ['get', 'fillColor'], + 'fill-opacity': 1, + }, + }); + } + + // 실선 레이어 (line-dasharray가 data-driven 미지원이므로 실선/점선 분리) + if (!map.getLayer(REALM_LINE_SOLID_LAYER_ID)) { + map.addLayer({ + id: REALM_LINE_SOLID_LAYER_ID, + type: 'line', + source: REALM_SOURCE_ID, + filter: ['!=', ['get', 'isDotted'], true], + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': ['get', 'outlineWidth'], + }, + }); + } + + // 점선 레이어 + if (!map.getLayer(REALM_LINE_DOTTED_LAYER_ID)) { + map.addLayer({ + id: REALM_LINE_DOTTED_LAYER_ID, + type: 'line', + source: REALM_SOURCE_ID, + filter: ['==', ['get', 'isDotted'], true], + paint: { + 'line-color': ['get', 'outlineColor'], + 'line-width': ['get', 'outlineWidth'], + 'line-dasharray': [4, 4], + }, + }); + } + + // 라벨 레이어 + if (!map.getLayer(REALM_LABEL_LAYER_ID)) { + map.addLayer({ + id: REALM_LABEL_LAYER_ID, + type: 'symbol', + source: REALM_SOURCE_ID, + filter: ['==', ['get', 'showLabel'], true], + layout: { + 'text-field': ['get', 'labelText'], + 'text-size': ['get', 'fontSize'], + 'text-font': ['Open Sans Regular', 'Arial Unicode MS Regular'], + 'text-allow-overlap': true, + }, + paint: { + 'text-color': ['get', 'fontColor'], + }, + }); + } +} + +/** + * 관심구역 MapLibre 레이어 관리 훅 + * - GeoJSON source + fill/line/symbol layer로 폴리곤 및 라벨 표시 + * - favoriteStore 구독으로 realmList/visibility 변경 반영 + * - style.load 이벤트로 배경지도 전환 후 레이어 복구 + */ +export default function useRealmLayer(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any + const map = useMapStore((s) => s.map); + const currentDataRef = useRef>(EMPTY_FC); + const isVisibleRef = useRef(true); + + useEffect(() => { + if (!map) return; + if (typeof map.getCanvas !== 'function') return; + + const mlMap = map as maplibregl.Map; + + /** 현재 상태를 맵에 적용 (초기화 + style.load 복구 공용) */ + const applyState = () => { + ensureRealmLayers(mlMap); + + const source = mlMap.getSource(REALM_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(currentDataRef.current); + } + + const visibility = isVisibleRef.current ? 'visible' : 'none'; + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) { + mlMap.setLayoutProperty(layerId, 'visibility', visibility as 'visible' | 'none'); + } + }); + }; + + // 배경지도 전환(setStyle) 후 레이어 복구 + mlMap.on('style.load', applyState); + + // 초기 데이터 로드 + const { realmList, isRealmVisible } = useFavoriteStore.getState(); + isVisibleRef.current = isRealmVisible; + if (realmList.length > 0) { + currentDataRef.current = buildRealmGeoJSON(realmList as unknown as RealmData[]); + } + + if (mlMap.isStyleLoaded()) { + applyState(); + } + + // realmList 변경 구독 + const unsubRealmList = useFavoriteStore.subscribe( + (state) => state.realmList, + (newRealmList) => { + currentDataRef.current = buildRealmGeoJSON(newRealmList as unknown as RealmData[]); + const source = mlMap.getSource(REALM_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(currentDataRef.current); + } + }, + ); + + // isRealmVisible 변경 구독 + const unsubVisible = useFavoriteStore.subscribe( + (state) => state.isRealmVisible, + (isVisible: boolean) => { + isVisibleRef.current = isVisible; + const visibility = isVisible ? 'visible' : 'none'; + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) { + mlMap.setLayoutProperty(layerId, 'visibility', visibility as 'visible' | 'none'); + } + }); + }, + ); + + return () => { + unsubRealmList(); + unsubVisible(); + mlMap.off('style.load', applyState); + + ALL_REALM_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) mlMap.removeLayer(layerId); + }); + if (mlMap.getSource(REALM_SOURCE_ID)) mlMap.removeSource(REALM_SOURCE_ID); + }; + }, [map]); +} diff --git a/src/hooks/useShipData.js b/src/hooks/useShipData.ts similarity index 76% rename from src/hooks/useShipData.js rename to src/hooks/useShipData.ts index bf1720a2..77b99f48 100644 --- a/src/hooks/useShipData.js +++ b/src/hooks/useShipData.ts @@ -16,18 +16,31 @@ const INITIAL_LOAD_MINUTES = 60; /** 증분 로드 기간 (분) */ const INCREMENT_MINUTES = 2; // 약간의 중복 허용으로 누락 방지 +/** useShipData 옵션 */ +interface UseShipDataOptions { + autoConnect?: boolean; +} + +/** useShipData 반환 타입 */ +interface UseShipDataReturn { + isConnected: boolean; + isLoading: boolean; + connect: () => Promise; + disconnect: () => void; +} + /** * 선박 데이터 관리 훅 - * @param {Object} options - 옵션 + * @param {UseShipDataOptions} options - 옵션 * @param {boolean} options.autoConnect - 자동 시작 여부 (기본값: true) - * @returns {Object} { isConnected, isLoading, connect, disconnect } + * @returns {UseShipDataReturn} { isConnected, isLoading, connect, disconnect } */ -export default function useShipData(options = {}) { +export default function useShipData(options: UseShipDataOptions = {}): UseShipDataReturn { const { autoConnect = true } = options; - const pollingRef = useRef(null); - const initialLoadDoneRef = useRef(false); - const [isLoading, setIsLoading] = useState(true); + const pollingRef = useRef | null>(null); + const initialLoadDoneRef = useRef(false); + const [isLoading, setIsLoading] = useState(true); const mergeFeatures = useShipStore((s) => s.mergeFeatures); const setConnected = useShipStore((s) => s.setConnected); const isConnected = useShipStore((s) => s.isConnected); @@ -35,7 +48,7 @@ export default function useShipData(options = {}) { /** * AIS 데이터를 shipStore feature 형식으로 변환하여 머지 */ - const loadAndMerge = useCallback(async (minutes) => { + const loadAndMerge = useCallback(async (minutes: number): Promise => { try { const aisTargets = await searchAisTargets(minutes); if (aisTargets.length > 0) { @@ -53,7 +66,7 @@ export default function useShipData(options = {}) { /** * 폴링 시작 */ - const startPolling = useCallback(() => { + const startPolling = useCallback((): void => { if (pollingRef.current) return; pollingRef.current = setInterval(() => { @@ -66,7 +79,7 @@ export default function useShipData(options = {}) { /** * 폴링 중지 */ - const stopPolling = useCallback(() => { + const stopPolling = useCallback((): void => { if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; @@ -77,7 +90,7 @@ export default function useShipData(options = {}) { /** * 연결 (초기 로드 + 폴링 시작) */ - const connect = useCallback(async () => { + const connect = useCallback(async (): Promise => { if (initialLoadDoneRef.current) { startPolling(); setConnected(true); @@ -102,7 +115,7 @@ export default function useShipData(options = {}) { /** * 연결 해제 */ - const disconnect = useCallback(() => { + const disconnect = useCallback((): void => { stopPolling(); setConnected(false); }, [stopPolling, setConnected]); diff --git a/src/hooks/useShipLayer.js b/src/hooks/useShipLayer.ts similarity index 53% rename from src/hooks/useShipLayer.js rename to src/hooks/useShipLayer.ts index 986f7729..844242c7 100644 --- a/src/hooks/useShipLayer.js +++ b/src/hooks/useShipLayer.ts @@ -1,15 +1,12 @@ /** * 선박 Deck.gl 레이어 관리 훅 - * - OpenLayers 맵과 Deck.gl 레이어 통합 + * - MapLibre GL JS + @deck.gl/mapbox MapboxOverlay 자동 통합 * - 배치 렌더러 기반 최적화된 렌더링 * - 선박 데이터 변경 시 레이어 업데이트 * - 항적 레이어: 정적(경로/포인트) 캐싱 + 동적(가상선박) 경량 갱신 - * - * 참조: mda-react-front/src/common/deck.ts */ import { useEffect, useRef, useCallback } from 'react'; -import { Deck } from '@deck.gl/core'; -import { toLonLat } from 'ol/proj'; +import { MapboxOverlay } from '@deck.gl/mapbox'; import { createShipLayers, clearClusterCache } from '../map/layers/shipLayer'; import useShipStore from '../stores/shipStore'; import { useTrackQueryStore } from '../tracking/stores/trackQueryStore'; @@ -22,105 +19,64 @@ import { getStsLayers } from '../areaSearch/utils/stsLayerRegistry'; import { shipBatchRenderer } from '../map/ShipBatchRenderer'; import useFavoriteStore from '../stores/favoriteStore'; +/** 뷰포트 바운드 */ +interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** useShipLayer 반환 타입 */ +interface UseShipLayerReturn { + deckCanvas: null; + deckRef: React.MutableRefObject; +} + /** * 선박 레이어 관리 훅 - * @param {Object} map - OpenLayers 맵 인스턴스 - * @returns {Object} { deckCanvas } + * @param map - MapLibre GL JS 맵 인스턴스 (mapStore에서 any 타입) */ -export default function useShipLayer(map) { - const deckRef = useRef(null); - const canvasRef = useRef(null); - const animationFrameRef = useRef(null); - const batchRendererInitialized = useRef(false); +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default function useShipLayer(map: any): UseShipLayerReturn { + const overlayRef = useRef(null); + const batchRendererInitialized = useRef(false); const getSelectedShips = useShipStore((s) => s.getSelectedShips); const isShipVisible = useShipStore((s) => s.isShipVisible); // 마지막 선박 레이어: 캐시용 - const lastShipLayersRef = useRef([]); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lastShipLayersRef = useRef([]); /** - * Deck.gl 인스턴스 초기화 + * 뷰포트 범위 계산 (MapLibre 네이티브) */ - const initDeck = useCallback((container) => { - if (deckRef.current) return; - - const canvas = document.createElement('canvas'); - canvas.id = 'deck-canvas'; - canvas.style.position = 'absolute'; - canvas.style.left = '0'; - canvas.style.top = '0'; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - canvas.style.pointerEvents = 'none'; - canvas.style.zIndex = '0'; - container.appendChild(canvas); - canvasRef.current = canvas; - - deckRef.current = new Deck({ - canvas, - controller: false, - layers: [], - useDevicePixels: true, - pickingRadius: 20, - onError: (error) => { - console.error('[Deck.gl] Error:', error); - }, - }); - }, []); - - /** - * Deck.gl viewState를 OpenLayers 뷰와 동기화 - */ - const syncViewState = useCallback(() => { - if (!map || !deckRef.current) return; - - const view = map.getView(); - const center = view.getCenter(); - const zoom = view.getZoom(); - const rotation = view.getRotation(); - - if (!center || zoom === undefined) return; - - const [lon, lat] = toLonLat(center); - - deckRef.current.setProps({ - viewState: { - longitude: lon, - latitude: lat, - zoom: zoom - 1, - bearing: (-rotation * 180) / Math.PI, - pitch: 0, - }, - }); - }, [map]); - - /** - * 뷰포트 범위 계산 - */ - const getViewportBounds = useCallback(() => { + const getViewportBounds = useCallback((): ViewportBounds | null => { if (!map) return null; - const view = map.getView(); - const size = map.getSize(); - if (!size) return null; - - const extent = view.calculateExtent(size); - const [minX, minY, maxX, maxY] = extent; - const [minLon, minLat] = toLonLat([minX, minY]); - const [maxLon, maxLat] = toLonLat([maxX, maxY]); - - return { minLon, maxLon, minLat, maxLat }; + try { + const bounds = map.getBounds(); + return { + minLon: bounds.getWest(), + maxLon: bounds.getEast(), + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + }; + } catch { + return null; + } }, [map]); /** * 배치 렌더러 콜백 - 선박 레이어 생성 + 캐싱된 항적 레이어 병합 */ - const handleBatchRender = useCallback((ships, trigger) => { - if (!deckRef.current || !map) return; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleBatchRender = useCallback((ships: any[], trigger: number): void => { + if (!overlayRef.current || !map) return; - const view = map.getView(); - const zoom = view.getZoom() || 7; + // mapStore.zoom은 OL 규약 (= MapLibre zoom + 1) + const zoom = useMapStore.getState().zoom; const selectedShips = getSelectedShips(); const { showLabels: currentShowLabels, labelOptions: currentLabelOptions, isIntegrate: currentIsIntegrate, darkSignalIds } = useShipStore.getState(); @@ -149,7 +105,7 @@ export default function useShipLayer(map) { const stsLayers = getStsLayers(); // 병합: 선박 + 항적 + 리플레이 + 항적분석 + STS 레이어 - deckRef.current.setProps({ + overlayRef.current.setProps({ layers: [...shipLayers, ...trackLayers, ...replayLayers, ...areaSearchLayers, ...stsLayers], }); }, [map, getSelectedShips]); @@ -157,16 +113,16 @@ export default function useShipLayer(map) { /** * 선박 레이어 업데이트 (배치 렌더러 사용) */ - const updateLayers = useCallback(() => { - if (!deckRef.current || !map) return; + const updateLayers = useCallback((): void => { + if (!overlayRef.current || !map) return; if (!isShipVisible) { - deckRef.current.setProps({ layers: [] }); + overlayRef.current.setProps({ layers: [] }); return; } - const view = map.getView(); - const zoom = view.getZoom() || 10; + // mapStore.zoom은 OL 규약 + const zoom = useMapStore.getState().zoom; const zoomIntChanged = shipBatchRenderer.setZoom(zoom); const bounds = getViewportBounds(); @@ -181,66 +137,66 @@ export default function useShipLayer(map) { shipBatchRenderer.requestRender(); }, [map, isShipVisible, getViewportBounds]); - /** - * 렌더링 루프 - */ - const render = useCallback(() => { - syncViewState(); - updateLayers(); - deckRef.current?.redraw(); - }, [syncViewState, updateLayers]); - - // 맵 초기화 및 이벤트 바인딩 + // MapboxOverlay 초기화 및 이벤트 바인딩 useEffect(() => { if (!map) return; - const viewport = map.getViewport(); - initDeck(viewport); + const initOverlay = () => { + if (overlayRef.current) return; - if (!batchRendererInitialized.current) { - shipBatchRenderer.initialize(handleBatchRender); - batchRendererInitialized.current = true; - } + const overlay = new MapboxOverlay({ + interleaved: false, // overlaid 모드 (맵 위에 렌더링) + layers: [], + }); - const handleMoveEnd = () => { render(); }; - const handlePostRender = () => { - syncViewState(); - deckRef.current?.redraw(); + map.addControl(overlay); + overlayRef.current = overlay; + + // 배치 렌더러 초기화 + if (!batchRendererInitialized.current) { + shipBatchRenderer.initialize(handleBatchRender); + batchRendererInitialized.current = true; + } + + // 초기 렌더링 + setTimeout(() => { updateLayers(); }, 100); }; - map.on('moveend', handleMoveEnd); - map.on('postrender', handlePostRender); + if (map.loaded()) { + initOverlay(); + } else { + map.on('load', initOverlay); + } - setTimeout(() => { render(); }, 100); + // MapLibre 이동 이벤트 + const handleMoveEnd = () => { updateLayers(); }; + map.on('moveend', handleMoveEnd); return () => { - map.un('moveend', handleMoveEnd); - map.un('postrender', handlePostRender); + map.off('load', initOverlay); + map.off('moveend', handleMoveEnd); - if (animationFrameRef.current) { - cancelAnimationFrame(animationFrameRef.current); - } - - if (deckRef.current) { - deckRef.current.finalize(); - deckRef.current = null; - } - - if (canvasRef.current) { - canvasRef.current.remove(); - canvasRef.current = null; + if (overlayRef.current) { + try { + map.removeControl(overlayRef.current); + } catch { + // 맵이 이미 제거된 경우 + } + overlayRef.current.finalize(); + overlayRef.current = null; } shipBatchRenderer.dispose(); batchRendererInitialized.current = false; }; - }, [map, initDeck, render, syncViewState, handleBatchRender]); + }, [map, handleBatchRender, updateLayers]); // 선박 데이터 변경 시 레이어 업데이트 useEffect(() => { const unsubscribe = useShipStore.subscribe( (state) => [state.features, state.kindVisibility, state.sourceVisibility, state.isShipVisible, state.detailModals, state.isIntegrate, state.nationalVisibility, state.showLabels, state.labelOptions, state.darkSignalVisible, state.darkSignalIds], - (current, prev) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (current: any[], prev: any[]) => { const filterChanged = current[1] !== prev[1] || current[2] !== prev[2] || @@ -261,55 +217,53 @@ export default function useShipLayer(map) { updateLayers(); }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => { unsubscribe(); }; }, [updateLayers]); - // === trackQueryStore 변경 시 선박 레이어 리렌더 === - // tracking 패키지의 TrackQueryViewer가 레이어를 전역 레지스트리에 등록하면 - // 여기서 shipBatchRenderer를 트리거하여 deck.gl에 반영 + // trackQueryStore 변경 시 선박 레이어 리렌더 useEffect(() => { const unsubscribe = useTrackQueryStore.subscribe( (state) => [state.tracks, state.currentTime, state.showPoints, state.showVirtualShip, state.showLabels, state.disabledVesselIds, state.hideLiveShips], () => { - if (deckRef.current && map) { + if (overlayRef.current && map) { shipBatchRenderer.requestRender(); } }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => unsubscribe(); }, [map]); - // === trackingModeStore 변경 시 선박 레이어 리렌더 === - // 반경 필터링 상태가 변경되면 즉시 렌더링 + // trackingModeStore 변경 시 선박 레이어 리렌더 useEffect(() => { const unsubscribe = useTrackingModeStore.subscribe( (state) => [state.mode, state.trackedShipId, state.trackedShip, state.radiusNM], () => { - if (deckRef.current && map) { - // 반경/추적 모드 변경은 필터 변경이므로 캐시 클리어 후 즉시 렌더링 + if (overlayRef.current && map) { shipBatchRenderer.clearCache(); clearClusterCache(); shipBatchRenderer.immediateRender(); } }, - { equalityFn: (a, b) => a.every((v, i) => v === b[i]) } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a.every((v: any, i: number) => v === b[i]) } ); return () => unsubscribe(); }, [map]); - // === mapStore 테마(배경지도) 변경 시 선박 레이어 리렌더 === - // 테마 변경 시 선박명/속도벡터/선박크기 색상이 변경되므로 즉시 렌더링 + // mapStore 테마(배경지도) 변경 시 선박 레이어 리렌더 useEffect(() => { const unsubscribe = useMapStore.subscribe( (state) => state.baseMapType, () => { - if (deckRef.current && map) { + if (overlayRef.current && map) { clearClusterCache(); shipBatchRenderer.immediateRender(); } @@ -319,26 +273,26 @@ export default function useShipLayer(map) { return () => unsubscribe(); }, [map]); - // === favoriteStore 변경 시 선박 레이어 리렌더 === - // 관심선박 토글/목록 변경 시 필터 캐시 초기화 + 즉시 렌더링 + // favoriteStore 변경 시 선박 레이어 리렌더 useEffect(() => { const unsubscribe = useFavoriteStore.subscribe( (state) => [state.isFavoriteEnabled, state.favoriteSet], () => { - if (deckRef.current && map) { + if (overlayRef.current && map) { shipBatchRenderer.clearCache(); clearClusterCache(); shipBatchRenderer.immediateRender(); } }, - { equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { equalityFn: (a: any[], b: any[]) => a[0] === b[0] && a[1] === b[1] } ); return () => unsubscribe(); }, [map]); return { - deckCanvas: canvasRef.current, - deckRef, + deckCanvas: null, + deckRef: overlayRef, }; } diff --git a/src/hooks/useShipSearch.js b/src/hooks/useShipSearch.ts similarity index 82% rename from src/hooks/useShipSearch.js rename to src/hooks/useShipSearch.ts index c769ad21..6a355924 100644 --- a/src/hooks/useShipSearch.js +++ b/src/hooks/useShipSearch.ts @@ -15,10 +15,10 @@ * - 조기 종료 (결과 10개 도달 시) */ import { useState, useCallback, useRef, useEffect } from 'react'; -import { fromLonLat } from 'ol/proj'; import useShipStore from '../stores/shipStore'; import { useMapStore } from '../stores/mapStore'; import useTrackingModeStore, { isWithinRadius, NM_TO_METERS } from '../stores/trackingModeStore'; +import type { ShipFeature } from '../types/ship'; // 레이더 신호원 코드 const SIGNAL_SOURCE_CODE_RADAR = '000005'; @@ -32,12 +32,47 @@ const DEBOUNCE_MS = 200; // 한글 정규식 const KOREAN_REGEX = /[가-힣ㄱ-ㅎㅏ-ㅣ]/; +/** 검색 결과 항목 */ +export interface SearchResult { + featureId: string; + targetId: string; + originalTargetId: string; + shipName: string; + signalSourceCode: string; + longitude: number; + latitude: number; + ship: ShipFeature; +} + +/** 반경 바운딩 박스 */ +interface RadiusBoundingBox { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +/** 반경 중심 좌표 */ +interface RadiusCenter { + lon: number; + lat: number; +} + +/** useShipSearch 반환 타입 */ +interface UseShipSearchReturn { + searchValue: string; + setSearchValue: (keyword: string) => void; + results: SearchResult[]; + handleClickResult: (result: SearchResult) => void; + handleSelectFirst: () => void; + clearSearch: () => void; + isIntegrate: boolean; +} + /** * 검색어가 한글을 포함하는지 확인 - * @param {string} text - * @returns {boolean} */ -function containsKorean(text) { +function containsKorean(text: string): boolean { return KOREAN_REGEX.test(text); } @@ -46,14 +81,12 @@ function containsKorean(text) { * - 공백 제거 * - 특수문자 제거 (알파벳, 숫자, 한글만 유지) * - 소문자 변환 - * @param {string} text - * @returns {string} */ -function normalizeSearchText(text) { +function normalizeSearchText(text: string): string { if (!text) return ''; return text .toLowerCase() - .replace(/[\s\-_.,:;!@#$%^&*()+=\[\]{}|\\/<>?'"]/g, '') + .replace(/[\s\-_.,:;!@#$%^&*()+=[\]{}|\\/<>?'"]/g, '') .trim(); } @@ -61,11 +94,8 @@ function normalizeSearchText(text) { * 최소 검색 길이 확인 * - 한글 포함: 최소 2자 * - 영문/숫자만: 최소 3자 - * @param {string} originalText - 원본 입력값 - * @param {string} normalizedText - 정규화된 검색어 - * @returns {boolean} */ -function meetsMinLength(originalText, normalizedText) { +function meetsMinLength(originalText: string, normalizedText: string): boolean { if (!normalizedText) return false; const hasKorean = containsKorean(originalText); @@ -76,11 +106,10 @@ function meetsMinLength(originalText, normalizedText) { /** * 선박 검색 훅 - * @returns {Object} { searchValue, setSearchValue, results, handleSearch, handleClickResult, clearSearch } */ -export default function useShipSearch() { +export default function useShipSearch(): UseShipSearchReturn { const [searchValue, setSearchValueState] = useState(''); - const [results, setResults] = useState([]); + const [results, setResults] = useState([]); const map = useMapStore((s) => s.map); const features = useShipStore((s) => s.features); @@ -95,7 +124,7 @@ export default function useShipSearch() { const radiusNM = useTrackingModeStore((s) => s.radiusNM); // 디바운스 타이머 ref - const debounceTimerRef = useRef(null); + const debounceTimerRef = useRef | null>(null); // 컴포넌트 언마운트 시 타이머 정리 useEffect(() => { @@ -108,9 +137,8 @@ export default function useShipSearch() { /** * 실제 검색 실행 (디바운스 후 호출) - * @param {string} keyword - 검색어 */ - const executeSearch = useCallback((keyword) => { + const executeSearch = useCallback((keyword: string) => { const normalizedKeyword = normalizeSearchText(keyword); // 최소 길이 미달 시 결과 초기화 @@ -121,8 +149,8 @@ export default function useShipSearch() { // 반경 필터 상태 확인 const isRadiusFilterActive = trackingMode === 'ship' && trackedShip !== null; - let radiusBoundingBox = null; - let radiusCenter = null; + let radiusBoundingBox: RadiusBoundingBox | null = null; + let radiusCenter: RadiusCenter | null = null; if (isRadiusFilterActive && trackedShip?.longitude && trackedShip?.latitude) { radiusCenter = { lon: trackedShip.longitude, lat: trackedShip.latitude }; @@ -140,7 +168,7 @@ export default function useShipSearch() { } const hasKorean = containsKorean(keyword); - const matchedShips = []; + const matchedShips: SearchResult[] = []; // Map을 배열로 변환하여 for...of로 조기 종료 가능하게 const featuresArray = Array.from(features.entries()); @@ -226,9 +254,8 @@ export default function useShipSearch() { /** * 검색어 변경 핸들러 (디바운스 적용) - * @param {string} keyword - 검색어 */ - const setSearchValue = useCallback((keyword) => { + const setSearchValue = useCallback((keyword: string) => { setSearchValueState(keyword); // 빈 입력 시 즉시 결과 초기화 @@ -257,9 +284,8 @@ export default function useShipSearch() { * - 선박 선택 (하이라이트) * - 상세 모달 열기 * - 지도 중심 이동 - * @param {Object} result - 검색 결과 항목 */ - const handleClickResult = useCallback((result) => { + const handleClickResult = useCallback((result: SearchResult) => { if (!map || !result) return; const { featureId, longitude, latitude, ship } = result; @@ -272,11 +298,10 @@ export default function useShipSearch() { openDetailModal(ship); } - // 지도 중심 이동 (애니메이션) - const position = fromLonLat([longitude, latitude]); - map.getView().animate({ - center: position, - zoom: 14, + // 지도 중심 이동 (MapLibre flyTo) + map.flyTo({ + center: [longitude, latitude], + zoom: 13, // MapLibre zoom = OL 14 - 1 duration: 500, }); diff --git a/src/hooks/useTrackingMode.js b/src/hooks/useTrackingMode.js deleted file mode 100644 index 8046cb92..00000000 --- a/src/hooks/useTrackingMode.js +++ /dev/null @@ -1,179 +0,0 @@ -/** - * 추적 모드 훅 - * - 선박 모드일 때 추적 함정 중심으로 지도 이동 - * - 반경 원 레이어 생성 및 업데이트 - */ -import { useEffect, useRef, useCallback } from 'react'; -import { fromLonLat } from 'ol/proj'; -import { circular } from 'ol/geom/Polygon'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import Feature from 'ol/Feature'; -import { Fill, Stroke, Style } from 'ol/style'; -import { useMapStore } from '../stores/mapStore'; -import useShipStore from '../stores/shipStore'; -import useTrackingModeStore, { NM_TO_METERS } from '../stores/trackingModeStore'; - -/** - * 추적 모드 훅 - * MapContainer에서 호출 - */ -export default function useTrackingMode() { - const map = useMapStore((s) => s.map); - const features = useShipStore((s) => s.features); - - const mode = useTrackingModeStore((s) => s.mode); - const trackedShipId = useTrackingModeStore((s) => s.trackedShipId); - const trackedShip = useTrackingModeStore((s) => s.trackedShip); - const radiusNM = useTrackingModeStore((s) => s.radiusNM); - const updateTrackedShip = useTrackingModeStore((s) => s.updateTrackedShip); - - // 반경 원 레이어 ref - const radiusLayerRef = useRef(null); - const radiusFeatureRef = useRef(null); - - // 이전 좌표 (중복 업데이트 방지) - const prevCoordsRef = useRef(null); - - /** - * 반경 원 레이어 생성 - */ - const createRadiusLayer = useCallback(() => { - if (!map) return; - - // 기존 레이어 제거 - if (radiusLayerRef.current) { - map.removeLayer(radiusLayerRef.current); - } - - const source = new VectorSource(); - const layer = new VectorLayer({ - source, - style: new Style({ - fill: new Fill({ - color: 'rgba(0, 150, 255, 0.08)', // 매우 투명한 파란색 - }), - stroke: new Stroke({ - color: 'rgba(0, 150, 255, 0.4)', - width: 2, - lineDash: [8, 4], - }), - }), - zIndex: 5, // 선박 레이어보다 낮게 - }); - - map.addLayer(layer); - radiusLayerRef.current = layer; - - return layer; - }, [map]); - - /** - * 반경 원 업데이트 - */ - const updateRadiusCircle = useCallback((lon, lat) => { - if (!radiusLayerRef.current) return; - - const source = radiusLayerRef.current.getSource(); - source.clear(); - - // 원형 폴리곤 생성 (WGS84 좌표에서 미터 단위 반경) - const radiusMeters = radiusNM * NM_TO_METERS; - const circle = circular([lon, lat], radiusMeters, 64); - - // EPSG:3857로 변환 - circle.transform('EPSG:4326', 'EPSG:3857'); - - const feature = new Feature({ geometry: circle }); - radiusFeatureRef.current = feature; - source.addFeature(feature); - }, [radiusNM]); - - /** - * 지도 중심 이동 (애니메이션) - */ - const centerMapOnShip = useCallback((lon, lat, animate = true) => { - if (!map) return; - - const center = fromLonLat([lon, lat]); - - if (animate) { - map.getView().animate({ - center, - duration: 300, - }); - } else { - map.getView().setCenter(center); - } - }, [map]); - - // 추적 함정 데이터 실시간 업데이트 - useEffect(() => { - if (mode !== 'ship' || !trackedShipId) return; - - // features에서 추적 함정의 최신 데이터 가져오기 - const latestShip = features.get(trackedShipId); - if (!latestShip) return; - - // 좌표가 변경되었는지 확인 - const newCoords = `${latestShip.longitude},${latestShip.latitude}`; - if (prevCoordsRef.current === newCoords) return; - - prevCoordsRef.current = newCoords; - updateTrackedShip(latestShip); - }, [mode, trackedShipId, features, updateTrackedShip]); - - // 선박 모드 활성화 시 레이어 생성 및 초기 위치 설정 - useEffect(() => { - if (mode !== 'ship' || !trackedShip || !map) { - // 선박 모드 비활성화 시 레이어 제거 - if (radiusLayerRef.current && map) { - map.removeLayer(radiusLayerRef.current); - radiusLayerRef.current = null; - radiusFeatureRef.current = null; - } - prevCoordsRef.current = null; - return; - } - - const { longitude, latitude } = trackedShip; - if (!longitude || !latitude) return; - - // 레이어가 없으면 생성 - if (!radiusLayerRef.current) { - createRadiusLayer(); - } - - // 반경 원 업데이트 - updateRadiusCircle(longitude, latitude); - - // 지도 중심 이동 - centerMapOnShip(longitude, latitude); - }, [mode, trackedShip, map, createRadiusLayer, updateRadiusCircle, centerMapOnShip]); - - // 반경 변경 시 원 업데이트 - useEffect(() => { - if (mode !== 'ship' || !trackedShip) return; - - const { longitude, latitude } = trackedShip; - if (!longitude || !latitude) return; - - updateRadiusCircle(longitude, latitude); - }, [radiusNM, mode, trackedShip, updateRadiusCircle]); - - // 컴포넌트 언마운트 시 정리 - useEffect(() => { - return () => { - if (radiusLayerRef.current && map) { - map.removeLayer(radiusLayerRef.current); - radiusLayerRef.current = null; - } - }; - }, [map]); - - return { - isTrackingActive: mode === 'ship' && trackedShip !== null, - trackedShip, - radiusNM, - }; -} diff --git a/src/hooks/useTrackingMode.ts b/src/hooks/useTrackingMode.ts new file mode 100644 index 00000000..795be514 --- /dev/null +++ b/src/hooks/useTrackingMode.ts @@ -0,0 +1,221 @@ +/** + * 추적 모드 훅 + * - 선박 모드일 때 추적 함정 중심으로 지도 이동 + * - 반경 원 레이어: MapLibre GeoJSON source + fill/line layer (@turf/circle) + */ +import { useEffect, useRef, useCallback } from 'react'; +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource } from 'maplibre-gl'; +import * as turf from '@turf/turf'; +import type { Feature, FeatureCollection, Polygon } from 'geojson'; +import { useMapStore } from '../stores/mapStore'; +import useShipStore from '../stores/shipStore'; +import useTrackingModeStore from '../stores/trackingModeStore'; +import type { ShipFeature } from '../types/ship'; + +/** MapLibre source/layer ID */ +const RADIUS_SOURCE_ID = 'tracking-radius-source'; +const RADIUS_FILL_LAYER_ID = 'tracking-radius-fill-layer'; +const RADIUS_LINE_LAYER_ID = 'tracking-radius-line-layer'; + +const ALL_RADIUS_LAYER_IDS = [RADIUS_LINE_LAYER_ID, RADIUS_FILL_LAYER_ID] as const; + +/** NM → km 변환 */ +const NM_TO_KM = 1.852; + +/** 빈 FeatureCollection */ +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +/** 반경 원 색상 (shipLayer 추적 선박 마커 색상과 통일: [0, 212, 255]) */ +const RADIUS_COLOR = '#00D4FF'; + +/** useTrackingMode 반환 타입 */ +interface UseTrackingModeReturn { + isTrackingActive: boolean; + trackedShip: ShipFeature | null; + radiusNM: number; +} + +/** + * 반경 원 GeoJSON 생성 + */ +function buildRadiusGeoJSON(lon: number, lat: number, radiusNM: number): Feature { + const radiusKm = radiusNM * NM_TO_KM; + return turf.circle([lon, lat], radiusKm, { steps: 64, units: 'kilometers' }) as Feature; +} + +/** + * 반경 원 source/layer를 맵에 추가 (멱등) + */ +function ensureRadiusLayers(map: maplibregl.Map): void { + if (!map.getSource(RADIUS_SOURCE_ID)) { + map.addSource(RADIUS_SOURCE_ID, { + type: 'geojson', + data: EMPTY_FC, + }); + } + + if (!map.getLayer(RADIUS_FILL_LAYER_ID)) { + map.addLayer({ + id: RADIUS_FILL_LAYER_ID, + type: 'fill', + source: RADIUS_SOURCE_ID, + paint: { + 'fill-color': RADIUS_COLOR, + 'fill-opacity': 0.05, + }, + }); + } + + if (!map.getLayer(RADIUS_LINE_LAYER_ID)) { + map.addLayer({ + id: RADIUS_LINE_LAYER_ID, + type: 'line', + source: RADIUS_SOURCE_ID, + paint: { + 'line-color': RADIUS_COLOR, + 'line-width': 1.5, + 'line-opacity': 0.6, + 'line-dasharray': [4, 2], + }, + }); + } +} + +/** + * 추적 모드 훅 + * MapContainer에서 호출 + */ +export default function useTrackingMode(): UseTrackingModeReturn { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any + const map = useMapStore((s) => s.map); + const features = useShipStore((s) => s.features); + + const mode = useTrackingModeStore((s) => s.mode); + const trackedShipId = useTrackingModeStore((s) => s.trackedShipId); + const trackedShip = useTrackingModeStore((s) => s.trackedShip); + const radiusNM = useTrackingModeStore((s) => s.radiusNM); + const updateTrackedShip = useTrackingModeStore((s) => s.updateTrackedShip); + + // 이전 좌표 (중복 업데이트 방지) + const prevCoordsRef = useRef(null); + + // 반경 원 데이터 (style.load 복구용) + const radiusDataRef = useRef>(EMPTY_FC); + + /** + * 지도 중심 이동 (MapLibre API) + */ + const centerMapOnShip = useCallback((lon: number, lat: number, animate = true) => { + if (!map) return; + + if (animate) { + map.flyTo({ + center: [lon, lat], + duration: 300, + }); + } else { + map.jumpTo({ + center: [lon, lat], + }); + } + }, [map]); + + // 추적 함정 데이터 실시간 업데이트 + useEffect(() => { + if (mode !== 'ship' || !trackedShipId) return; + + const latestShip = features.get(trackedShipId); + if (!latestShip) return; + + const newCoords = `${latestShip.longitude},${latestShip.latitude}`; + if (prevCoordsRef.current === newCoords) return; + + prevCoordsRef.current = newCoords; + updateTrackedShip(latestShip); + }, [mode, trackedShipId, features, updateTrackedShip]); + + // 선박 모드 활성화 시 초기 위치 설정 + useEffect(() => { + if (mode !== 'ship' || !trackedShip || !map) { + prevCoordsRef.current = null; + return; + } + + const { longitude, latitude } = trackedShip; + if (!longitude || !latitude) return; + + centerMapOnShip(longitude, latitude); + }, [mode, trackedShip, map, centerMapOnShip]); + + // 반경 원 레이어 관리 + useEffect(() => { + if (!map) return; + if (typeof map.getCanvas !== 'function') return; + + const mlMap = map as maplibregl.Map; + + /** 반경 원 상태를 맵에 적용 */ + const applyState = () => { + ensureRadiusLayers(mlMap); + const source = mlMap.getSource(RADIUS_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(radiusDataRef.current); + } + }; + + /** 추적 상태에 따라 반경 원 갱신 */ + const updateRadiusCircle = () => { + const { mode: currentMode, trackedShip: currentShip, radiusNM: currentRadius } = + useTrackingModeStore.getState(); + + if (currentMode !== 'ship' || !currentShip?.longitude || !currentShip?.latitude) { + radiusDataRef.current = EMPTY_FC; + } else { + const circle = buildRadiusGeoJSON(currentShip.longitude, currentShip.latitude, currentRadius); + radiusDataRef.current = { type: 'FeatureCollection', features: [circle] }; + } + + const source = mlMap.getSource(RADIUS_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(radiusDataRef.current); + } + }; + + // 배경지도 전환(setStyle) 후 레이어 복구 + mlMap.on('style.load', applyState); + + // 초기 설정 + if (mlMap.isStyleLoaded()) { + ensureRadiusLayers(mlMap); + } + + // trackingModeStore 구독: mode, trackedShip, radiusNM 변경 시 원 갱신 + const unsubTracking = useTrackingModeStore.subscribe( + (state) => [state.mode, state.trackedShip, state.radiusNM] as const, + () => { updateRadiusCircle(); }, + { + equalityFn: (a, b) => a[0] === b[0] && a[1] === b[1] && a[2] === b[2], + }, + ); + + // 초기 상태 반영 + updateRadiusCircle(); + + return () => { + unsubTracking(); + mlMap.off('style.load', applyState); + + ALL_RADIUS_LAYER_IDS.forEach((layerId) => { + if (mlMap.getLayer(layerId)) mlMap.removeLayer(layerId); + }); + if (mlMap.getSource(RADIUS_SOURCE_ID)) mlMap.removeSource(RADIUS_SOURCE_ID); + }; + }, [map]); + + return { + isTrackingActive: mode === 'ship' && trackedShip !== null, + trackedShip, + radiusNM, + }; +} diff --git a/src/main.jsx b/src/main.tsx similarity index 64% rename from src/main.jsx rename to src/main.tsx index cb5cfbb1..5711bb0c 100644 --- a/src/main.jsx +++ b/src/main.tsx @@ -1,10 +1,9 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import App from './App'; -// OpenLayers 스타일 -import 'ol/ol.css'; +// MapLibre GL JS 스타일 +import 'maplibre-gl/dist/maplibre-gl.css'; // 글로벌 스타일 import './scss/global.scss'; @@ -14,8 +13,11 @@ import './scss/global.scss'; // basename은 trailing slash 없이 설정 ('' 또는 '/kcgv') const basename = import.meta.env.BASE_URL.replace(/\/$/, ''); -ReactDOM.createRoot(document.getElementById('root')).render( - +ReactDOM.createRoot(document.getElementById('root')!).render( + ); diff --git a/src/map/MapContainer.scss b/src/map/MapContainer.scss index 9db4911c..fdff6316 100644 --- a/src/map/MapContainer.scss +++ b/src/map/MapContainer.scss @@ -10,6 +10,15 @@ width: 100%; height: 100%; z-index: 0; + + // MapLibre 기본 grab 커서 → default로 오버라이드 + .maplibregl-canvas-container { + cursor: default !important; + + &:active { + cursor: grabbing !important; + } + } } // OpenLayers 기본 컨트롤 숨김 (스케일바 제외) @@ -98,3 +107,27 @@ border: 2px solid rgba(255, 215, 0, 0.8); background-color: rgba(255, 215, 0, 0.1); } + +// MapLibre Ctrl+Drag 박스 선택 스타일 +.maplibre-dragbox { + position: absolute; + border: 2px solid rgba(255, 215, 0, 0.8); + background-color: rgba(255, 215, 0, 0.1); + pointer-events: none; + z-index: 10; +} + +// MapLibre 스케일 컨트롤 +.maplibregl-ctrl-scale { + position: absolute; + right: 70px; + bottom: 16px; + background: rgba(30, 35, 45, 0.92); + border-radius: 4px; + padding: 4px 8px; + color: #eee; + font-family: 'Segoe UI', sans-serif; + font-size: 11px; + border: 1px solid rgba(255, 255, 255, 0.2); + z-index: 100; +} diff --git a/src/map/MapContainer.jsx b/src/map/MapContainer.tsx similarity index 57% rename from src/map/MapContainer.jsx rename to src/map/MapContainer.tsx index ec904926..49e948cf 100644 --- a/src/map/MapContainer.jsx +++ b/src/map/MapContainer.tsx @@ -1,14 +1,11 @@ import { useEffect, useRef, useCallback } from 'react'; -import Map from 'ol/Map'; -import View from 'ol/View'; -import { fromLonLat, transformExtent } from 'ol/proj'; -import { defaults as defaultControls, ScaleLine } from 'ol/control'; -import { defaults as defaultInteractions, DragBox } from 'ol/interaction'; -import { platformModifierKeyOnly } from 'ol/events/condition'; +import maplibregl from 'maplibre-gl'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; -import { createBaseLayers } from './layers/baseLayer'; -import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore'; +import { getBaseMapStyle } from './layers/baseLayer'; +import { useMapStore } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; +import type { ShipFeature } from '../types/ship'; import useShipData from '../hooks/useShipData'; import useShipLayer from '../hooks/useShipLayer'; import ShipLegend from '../components/ship/ShipLegend'; @@ -31,12 +28,10 @@ import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQuery import useAreaSearchLayer from '../areaSearch/hooks/useAreaSearchLayer'; import useStsLayer from '../areaSearch/hooks/useStsLayer'; -import useZoneDraw from '../areaSearch/hooks/useZoneDraw'; -import useZoneEdit from '../areaSearch/hooks/useZoneEdit'; +// import useZoneDraw from '../areaSearch/hooks/useZoneDraw'; // Session G 패스 (OL 제거) +// import useZoneEdit from '../areaSearch/hooks/useZoneEdit'; // Session G 패스 (OL 제거) import { useAreaSearchStore } from '../areaSearch/stores/areaSearchStore'; import { useStsStore } from '../areaSearch/stores/stsStore'; -import { useAreaSearchAnimationStore } from '../areaSearch/stores/areaSearchAnimationStore'; -import { unregisterAreaSearchLayers } from '../areaSearch/utils/areaSearchLayerRegistry'; import { AREA_SEARCH_LAYER_IDS } from '../areaSearch/types/areaSearch.types'; import { STS_LAYER_IDS } from '../areaSearch/types/sts.types'; import AreaSearchTimeline from '../areaSearch/components/AreaSearchTimeline'; @@ -51,17 +46,24 @@ import './MapContainer.scss'; /** 호버 쓰로틀 간격 (ms) */ const HOVER_THROTTLE_MS = 50; +/** deck.pickObject 결과 타입 */ +interface DeckPickResult { + object?: ShipFeature & Record; + layer?: { id: string }; + x: number; + y: number; +} + /** * 지도 컨테이너 컴포넌트 - * - OpenLayers 맵 초기화 및 관리 - * - STOMP 선박 데이터 연결 - * - Deck.gl 선박 레이어 렌더링 + * - MapLibre GL JS 맵 초기화 및 관리 + * - AIS API 선박 데이터 연결 + * - Deck.gl 선박 레이어 렌더링 (MapboxOverlay) * - 선박 호버 툴팁 / 더블클릭 상세 모달 */ export default function MapContainer() { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const baseLayersRef = useRef(null); // 배경지도 레이어 참조 + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); const { map, setMap, setZoom, center, baseMapType } = useMapStore(); const showLegend = useShipStore((s) => s.showLegend); const hoverInfo = useShipStore((s) => s.hoverInfo); @@ -69,16 +71,16 @@ export default function MapContainer() { const replayCompleted = useReplayStore((s) => s.queryCompleted); const replayQuery = useReplayStore((s) => s.currentQuery); - // STOMP 선박 데이터 연결 + // AIS 선박 데이터 연결 useShipData({ autoConnect: true }); // 관심선박 + 관심구역 데이터 로딩 useFavoriteData(); - // 관심구역 OL 레이어 + // 관심구역 OL 레이어 (MapLibre 전환 후 별도 세션에서 수정) useRealmLayer(); - // Deck.gl 선박 레이어 + // Deck.gl 선박 레이어 (MapboxOverlay) const { deckRef } = useShipLayer(map); // 리플레이 레이어 @@ -87,48 +89,46 @@ export default function MapContainer() { // 항적분석 레이어 + STS 레이어 + 구역 그리기 + 구역 편집 useAreaSearchLayer(); useStsLayer(); - useZoneDraw(); - useZoneEdit(); + // useZoneDraw(); // Session G 패스 (OL 제거) + // useZoneEdit(); // Session G 패스 (OL 제거) const areaSearchCompleted = useAreaSearchStore((s) => s.queryCompleted); const stsCompleted = useStsStore((s) => s.queryCompleted); const analysisCompleted = areaSearchCompleted || stsCompleted; - // 측정 도구 + // 측정 도구 (MapLibre 전환 후 별도 세션에서 수정) useMeasure(); - // 추적 모드 (함정 중심 지도 이동 + 반경 원) + // 추적 모드 (지도 추적 + 반경 원 레이어) useTrackingMode(); - // 배경지도 타입 변경 시 레이어 가시성 토글 + // 배경지도 타입 변경 시 스타일 교체 useEffect(() => { - if (!baseLayersRef.current) return; - - const { worldMap, encMap, darkMap } = baseLayersRef.current; - - worldMap.setVisible(baseMapType === BASE_MAP_TYPES.NORMAL); - encMap.setVisible(baseMapType === BASE_MAP_TYPES.ENC); - darkMap.setVisible(baseMapType === BASE_MAP_TYPES.DARK); - }, [baseMapType]); + if (!map) return; + try { + map.setStyle(getBaseMapStyle(baseMapType)); + } catch { + // 맵이 아직 로드되지 않은 경우 + } + }, [baseMapType, map]); // 호버 쓰로틀 타이머 - const hoverTimerRef = useRef(null); + const hoverTimerRef = useRef | null>(null); /** * deck.pickObject 헬퍼 (라이브 선박 전용) */ - const pickShip = useCallback((pixel) => { - const deck = deckRef.current; - if (!deck) return null; - if (!deck.layerManager) return null; + const pickShip = useCallback((pixel: number[]): ShipFeature | null => { + const overlay = deckRef.current as MapboxOverlay | null; + if (!overlay) return null; try { - const result = deck.pickObject({ + const result = overlay.pickObject({ x: pixel[0], y: pixel[1], layerIds: ['ship-icon-layer'], }); - return result?.object || null; + return (result?.object as ShipFeature) || null; } catch { return null; } @@ -137,50 +137,38 @@ export default function MapContainer() { /** * deck.pickObject 헬퍼 (모든 레이어) */ - const pickAny = useCallback((pixel) => { - const deck = deckRef.current; - if (!deck) return null; - if (!deck.layerManager) return null; + const pickAny = useCallback((pixel: number[]): DeckPickResult | null => { + const overlay = deckRef.current as MapboxOverlay | null; + if (!overlay) return null; try { - // layerIds를 지정하지 않으면 모든 pickable 레이어에서 픽킹 - const result = deck.pickObject({ + const result = overlay.pickObject({ x: pixel[0], y: pixel[1], }); - return result || null; + return (result as DeckPickResult) || null; } catch { return null; } }, [deckRef]); /** - * OpenLayers pointermove → 호버 툴팁 + * mousemove -> 호버 툴팁 */ - const handlePointerMove = useCallback((evt) => { - // 드래그 중이면 무시 - if (evt.dragging) { - useShipStore.getState().setHoverInfo(null); - useTrackQueryStore.getState().setHighlightedVesselId(null); - useReplayStore.getState().setHighlightedVesselId(null); - useAreaSearchStore.getState().setAreaSearchTooltip(null); - useAreaSearchStore.getState().setHighlightedVesselId(null); - useStsStore.getState().setHighlightedGroupIndex(null); - return; - } - + const handlePointerMove = useCallback((e: maplibregl.MapMouseEvent) => { // 쓰로틀 if (hoverTimerRef.current) return; hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = null; - const pixel = evt.pixel; - const { clientX, clientY } = evt.originalEvent; + const pixel = [e.point.x, e.point.y]; + const { clientX, clientY } = e.originalEvent; const pickResult = pickAny(pixel); + const canvas = map?.getCanvas(); if (!pickResult || !pickResult.layer) { - // 아무것도 픽킹되지 않음 + if (canvas) canvas.style.cursor = ''; useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); @@ -191,6 +179,9 @@ export default function MapContainer() { return; } + // 호버 대상이 있으면 pointer 커서 + if (canvas) canvas.style.cursor = 'pointer'; + const layerId = pickResult.layer.id; const obj = pickResult.object; @@ -210,7 +201,7 @@ export default function MapContainer() { // 라이브 선박 if (layerId === 'ship-icon-layer') { - useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY }); + useShipStore.getState().setHoverInfo({ ship: obj as ShipFeature, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); @@ -220,7 +211,7 @@ export default function MapContainer() { // 항적조회 경로 (PathLayer) if (layerId === TRACK_QUERY_LAYER_IDS.PATH) { useShipStore.getState().setHoverInfo(null); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useTrackQueryStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; @@ -229,16 +220,16 @@ export default function MapContainer() { // 항적조회 포인트 (ScatterplotLayer) if (layerId === TRACK_QUERY_LAYER_IDS.POINTS) { useShipStore.getState().setHoverInfo(null); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useTrackQueryStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useReplayStore.getState().setHighlightedVesselId(null); - // 포인트 호버 정보 설정 if (obj) { + const o = obj as Record; useTrackQueryStore.getState().setHoveredPoint({ - vesselId: obj.vesselId, - position: obj.position, - timestamp: obj.timestamp, - speed: obj.speed, - index: obj.index, + vesselId: o.vesselId as string, + position: o.position as [number, number], + timestamp: o.timestamp as number, + speed: o.speed as number, + index: o.index as number, }, clientX, clientY); } else { useTrackQueryStore.getState().clearHoveredPoint(); @@ -248,15 +239,16 @@ export default function MapContainer() { // 항적조회 가상 선박 아이콘 if (layerId === TRACK_QUERY_LAYER_IDS.VIRTUAL_SHIP) { + const o = obj as Record; const tooltipShip = { - shipName: obj.shipName, - targetId: obj.vesselId?.split('_').pop() || obj.vesselId, - signalKindCode: obj.shipKindCode, - sog: obj.speed || 0, - cog: obj.heading || 0, + shipName: o.shipName as string, + targetId: ((o.vesselId as string)?.split('_').pop() || o.vesselId) as string, + signalKindCode: o.shipKindCode as string, + sog: (o.speed as number) || 0, + cog: (o.heading as number) || 0, }; - useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); - useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useShipStore.getState().setHoverInfo({ ship: tooltipShip as ShipFeature, x: clientX, y: clientY }); + useTrackQueryStore.getState().setHighlightedVesselId((o.vesselId as string) || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; @@ -268,9 +260,9 @@ export default function MapContainer() { useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useAreaSearchStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useAreaSearchStore.getState().setAreaSearchTooltip( - obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null, + obj ? { vesselId: (obj as Record).vesselId as string, x: clientX, y: clientY } : null, ); return; } @@ -281,9 +273,9 @@ export default function MapContainer() { useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - useAreaSearchStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useAreaSearchStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); useAreaSearchStore.getState().setAreaSearchTooltip( - obj ? { vesselId: obj.vesselId, x: clientX, y: clientY } : null, + obj ? { vesselId: (obj as Record).vesselId as string, x: clientX, y: clientY } : null, ); return; } @@ -297,8 +289,7 @@ export default function MapContainer() { useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); - // vesselId → 그룹 인덱스 매핑 (쌍 하이라이트) - const vesselId = obj?.vesselId; + const vesselId = (obj as Record)?.vesselId as string | undefined; if (vesselId) { const groups = useStsStore.getState().groupedContacts; const groupIdx = groups.findIndex( @@ -316,23 +307,24 @@ export default function MapContainer() { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); - useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useReplayStore.getState().setHighlightedVesselId((obj as Record)?.vesselId as string || null); return; } // 리플레이 가상 선박 아이콘 if (layerId === 'track-virtual-ship-layer') { + const o = obj as Record; const tooltipShip = { - shipName: obj.shipName, - targetId: obj.vesselId?.split('_').pop() || obj.vesselId, - signalKindCode: obj.shipKindCode, - sog: obj.speed || 0, - cog: obj.heading || 0, + shipName: o.shipName as string, + targetId: ((o.vesselId as string)?.split('_').pop() || o.vesselId) as string, + signalKindCode: o.shipKindCode as string, + sog: (o.speed as number) || 0, + cog: (o.heading as number) || 0, }; - useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); + useShipStore.getState().setHoverInfo({ ship: tooltipShip as ShipFeature, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); - useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); + useReplayStore.getState().setHighlightedVesselId((o.vesselId as string) || null); return; } @@ -343,25 +335,14 @@ export default function MapContainer() { useReplayStore.getState().setHighlightedVesselId(null); useStsStore.getState().setHighlightedGroupIndex(null); }, HOVER_THROTTLE_MS); - }, [pickAny]); + }, [pickAny, map]); /** - * OpenLayers dblclick → 상세 모달 - */ - const handleDblClick = useCallback((evt) => { - const pixel = evt.pixel; - const ship = pickShip(pixel); - - if (ship) { - evt.stopPropagation(); - useShipStore.getState().openDetailModal(ship); - } - }, [pickShip]); - - /** - * pointerout → 툴팁 숨김 + 하이라이트 클리어 + * pointerout -> 툴팁 숨김 + 하이라이트 클리어 */ const handlePointerOut = useCallback(() => { + const canvas = map?.getCanvas(); + if (canvas) canvas.style.cursor = ''; useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); @@ -369,35 +350,46 @@ export default function MapContainer() { useAreaSearchStore.getState().setAreaSearchTooltip(null); useAreaSearchStore.getState().setHighlightedVesselId(null); useStsStore.getState().setHighlightedGroupIndex(null); - }, []); + }, [map]); - /** - * singleclick → 빈 영역 클릭 시 선택/메뉴 해제 - */ - const handleSingleClick = useCallback((evt) => { - const ship = pickShip(evt.pixel); - if (!ship) { - useShipStore.getState().clearSelectedShips(); - useShipStore.getState().closeContextMenu(); - } - }, [pickShip]); - - // OL 이벤트 바인딩 + // MapLibre 이벤트 바인딩 useEffect(() => { if (!map) return; - map.on('pointermove', handlePointerMove); + // mousemove (호버 툴팁) + map.on('mousemove', handlePointerMove); + + // dblclick (상세 모달) + const handleDblClick = (e: maplibregl.MapMouseEvent) => { + if (useMapStore.getState().activeMeasureTool) return; + const pixel = [e.point.x, e.point.y]; + const ship = pickShip(pixel); + if (ship) { + e.preventDefault(); + useShipStore.getState().openDetailModal(ship); + } + }; map.on('dblclick', handleDblClick); - map.on('singleclick', handleSingleClick); - // pointerout은 뷰포트 DOM 이벤트로 처리 - const viewport = map.getViewport(); - viewport.addEventListener('pointerout', handlePointerOut); + // click (빈 영역 클릭 시 선택/메뉴 해제) + const handleClick = (e: maplibregl.MapMouseEvent) => { + if (useMapStore.getState().activeMeasureTool) return; + const ship = pickShip([e.point.x, e.point.y]); + if (!ship) { + useShipStore.getState().clearSelectedShips(); + useShipStore.getState().closeContextMenu(); + } + }; + map.on('click', handleClick); - // 우클릭 컨텍스트 메뉴 - const handleContextMenu = (e) => { + // mouseleave (툴팁 숨김) + const canvas = map.getCanvasContainer(); + canvas.addEventListener('mouseleave', handlePointerOut); + + // contextmenu (우클릭) + const handleContextMenu = (e: MouseEvent): void => { e.preventDefault(); - const pixel = map.getEventPixel(e); + const pixel = [e.offsetX, e.offsetY]; const ship = pickShip(pixel); const state = useShipStore.getState(); @@ -408,81 +400,61 @@ export default function MapContainer() { state.openContextMenu({ x: e.clientX, y: e.clientY, ships: selectedShips }); } }; - viewport.addEventListener('contextmenu', handleContextMenu); + canvas.addEventListener('contextmenu', handleContextMenu); - return () => { - map.un('pointermove', handlePointerMove); - map.un('dblclick', handleDblClick); - map.un('singleclick', handleSingleClick); - viewport.removeEventListener('pointerout', handlePointerOut); - viewport.removeEventListener('contextmenu', handleContextMenu); + // === Ctrl+Drag 박스 선택 === + let isDragging = false; + let startPoint: { x: number; y: number } | null = null; + let boxElement: HTMLDivElement | null = null; - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current); - hoverTimerRef.current = null; - } + const handleMouseDown = (e: MouseEvent) => { + if (!(e.ctrlKey || e.metaKey)) return; + isDragging = true; + startPoint = { x: e.offsetX, y: e.offsetY }; + + boxElement = document.createElement('div'); + boxElement.className = 'maplibre-dragbox'; + canvas.appendChild(boxElement); + e.preventDefault(); + map.dragPan.disable(); }; - }, [map, handlePointerMove, handleDblClick, handleSingleClick, handlePointerOut, pickShip]); - useEffect(() => { - if (!mapRef.current || mapInstanceRef.current) return; + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging || !startPoint || !boxElement) return; + const minX = Math.min(startPoint.x, e.offsetX); + const minY = Math.min(startPoint.y, e.offsetY); + const maxX = Math.max(startPoint.x, e.offsetX); + const maxY = Math.max(startPoint.y, e.offsetY); + boxElement.style.left = `${minX}px`; + boxElement.style.top = `${minY}px`; + boxElement.style.width = `${maxX - minX}px`; + boxElement.style.height = `${maxY - minY}px`; + }; - // 현재 배경지도 타입 가져오기 - const currentBaseMapType = useMapStore.getState().baseMapType; + const handleMouseUp = (e: MouseEvent) => { + if (!isDragging || !startPoint) return; + isDragging = false; + map.dragPan.enable(); - // 베이스 레이어 생성 (3가지 배경지도 + 상세지도) - const { worldMap, encMap, darkMap, eastAsiaMap, korMap } = createBaseLayers(currentBaseMapType); + // 픽셀 범위 → LngLat 범위 + const sw = map.unproject([ + Math.min(startPoint.x, e.offsetX), + Math.max(startPoint.y, e.offsetY), + ]); + const ne = map.unproject([ + Math.max(startPoint.x, e.offsetX), + Math.min(startPoint.y, e.offsetY), + ]); - // 배경지도 레이어 참조 저장 - baseLayersRef.current = { worldMap, encMap, darkMap }; - - // 스케일라인 컨트롤 (해리 단위) - const scaleLineControl = new ScaleLine({ - units: 'nautical', - bar: true, - text: true, - }); - - // 지도 인스턴스 생성 - const map = new Map({ - target: mapRef.current, - layers: [ - worldMap, - encMap, - darkMap, - eastAsiaMap, - korMap, - ], - view: new View({ - center: fromLonLat(center), - zoom: 7, - minZoom: 5, // 야간지도 타일 최소 레벨 - maxZoom: 15, // 줌 확장은 15까지 (타일은 12레벨까지만 로드) - }), - controls: defaultControls({ - attribution: false, - zoom: false, - rotate: false, - }).extend([scaleLineControl]), - interactions: defaultInteractions({ - doubleClickZoom: false, - }), - }); - - // Ctrl+Drag 박스 선택 인터랙션 - const dragBox = new DragBox({ condition: platformModifierKeyOnly }); - map.addInteraction(dragBox); - - dragBox.on('boxend', () => { - const extent3857 = dragBox.getGeometry().getExtent(); - const [minLon, minLat, maxLon, maxLat] = transformExtent(extent3857, 'EPSG:3857', 'EPSG:4326'); + const minLon = sw.lng, minLat = sw.lat; + const maxLon = ne.lng, maxLat = ne.lat; + // 선박 매칭 (기존 DragBox 로직 재사용) const state = useShipStore.getState(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible } = state; - // 국적 코드 매핑 (shipStore.js와 동일) - const mapNational = (code) => { + const mapNational = (code: string | undefined): string => { if (!code) return 'OTHER'; const c = code.toUpperCase(); if (c === 'KR' || c === 'KOR' || c === '440') return 'KR'; @@ -492,14 +464,11 @@ export default function MapContainer() { return 'OTHER'; }; - const matchedIds = []; + const matchedIds: string[] = []; features.forEach((ship, featureId) => { - // 단독 레이더 제외 if (ship.signalSourceCode === '000005' && !ship.integrate) return; - // 통합 모드 ON: isPriority만 if (isIntegrate && ship.integrate && !ship.isPriority) return; - // 다크시그널: 독립 필터 if (darkSignalIds.has(featureId)) { if (!darkSignalVisible) return; } else { @@ -509,35 +478,87 @@ export default function MapContainer() { if (!nationalVisibility[mappedNational]) return; } - const lon = parseFloat(ship.longitude); - const lat = parseFloat(ship.latitude); + const lon = parseFloat(String(ship.longitude)); + const lat = parseFloat(String(ship.latitude)); if (lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat) { matchedIds.push(featureId); } }); state.setSelectedShipIds(matchedIds); + + if (boxElement) { + boxElement.remove(); + boxElement = null; + } + startPoint = null; + }; + + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handleMouseUp); + + return () => { + map.off('mousemove', handlePointerMove); + map.off('dblclick', handleDblClick); + map.off('click', handleClick); + canvas.removeEventListener('mouseleave', handlePointerOut); + canvas.removeEventListener('contextmenu', handleContextMenu); + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseup', handleMouseUp); + + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }; + }, [map, handlePointerMove, handlePointerOut, pickShip, pickAny]); + + // MapLibre 맵 초기화 + useEffect(() => { + if (!mapRef.current || mapInstanceRef.current) return; + + const currentBaseMapType = useMapStore.getState().baseMapType; + + const mlMap = new maplibregl.Map({ + container: mapRef.current, + style: getBaseMapStyle(currentBaseMapType), + center: center, + zoom: 6, // OL 7 - 1 = MapLibre 6 + minZoom: 4, // OL 5 - 1 + maxZoom: 14, // OL 15 - 1 + attributionControl: false, + doubleClickZoom: false, }); - // 줌 변경 이벤트 - map.getView().on('change:resolution', () => { - const zoom = Math.round(map.getView().getZoom()); + // 스케일 컨트롤 (해리 단위) + mlMap.addControl(new maplibregl.ScaleControl({ + maxWidth: 200, + unit: 'nautical', + }), 'bottom-right'); + + // 줌 변경 이벤트 (OL 규약으로 변환: +1) + mlMap.on('zoomend', () => { + const zoom = Math.round(mlMap.getZoom() + 1); setZoom(zoom); }); // 스토어에 맵 인스턴스 저장 - setMap(map); - mapInstanceRef.current = map; - // TrackQueryViewer 등에서 줌 감지용 - window.__mainMap__ = map; + setMap(mlMap); + mapInstanceRef.current = mlMap; + + // TrackQueryViewer 등에서 줌 감지용 전역 참조 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__mainMap__ = mlMap; - // 클린업 return () => { if (mapInstanceRef.current) { - mapInstanceRef.current.setTarget(null); + mapInstanceRef.current.remove(); mapInstanceRef.current = null; } - window.__mainMap__ = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (window as any).__mainMap__ = null; }; }, []); @@ -565,7 +586,7 @@ export default function MapContainer() { useReplayStore.getState().reset(); useAnimationStore.getState().reset(); unregisterReplayLayers(); - showLiveShips(); // 라이브 선박 다시 표시 + showLiveShips(); shipBatchRenderer.immediateRender(); }} /> diff --git a/src/map/ShipBatchRenderer.js b/src/map/ShipBatchRenderer.ts similarity index 80% rename from src/map/ShipBatchRenderer.js rename to src/map/ShipBatchRenderer.ts index 2d3997a3..7fecc1a7 100644 --- a/src/map/ShipBatchRenderer.js +++ b/src/map/ShipBatchRenderer.ts @@ -35,13 +35,21 @@ import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY, } from '../types/constants'; +import type { ShipFeature } from '../types/ship'; import useFavoriteStore from '../stores/favoriteStore'; // ===================== // 렌더링 설정 // 위성통신망 환경 최적화: 최소 트래픽, 최소 스펙 // ===================== -const RENDER_CONFIG = { +interface RenderConfig { + defaultMinInterval: number; + maxInterval: number; + targetRenderTime: number; + maxRenderTime: number; +} + +const RENDER_CONFIG: RenderConfig = { defaultMinInterval: 1000, // 기본 최소 렌더링 간격 (1초) maxInterval: 5000, // 최대 렌더링 간격 (5초) targetRenderTime: 100, // 목표 렌더링 시간 (10fps 기준) @@ -52,7 +60,7 @@ const RENDER_CONFIG = { // 줌 레벨별 최소 렌더링 간격 // 낮은 줌 = 많은 선박 = 긴 간격 // ===================== -const ZOOM_MIN_INTERVAL = { +const ZOOM_MIN_INTERVAL: Record = { // zoom < 8: 광역 (전국/동아시아) 7: 4000, // zoom 8-9: 중광역 (해역) @@ -70,10 +78,8 @@ const ZOOM_MIN_INTERVAL = { /** * 줌 레벨에 따른 최소 렌더링 간격 반환 - * @param {number} zoom - 현재 줌 레벨 - * @returns {number} 최소 렌더링 간격 (ms) */ -function getMinIntervalByZoom(zoom) { +function getMinIntervalByZoom(zoom: number): number { const zoomInt = Math.floor(zoom); if (zoomInt <= 7) return ZOOM_MIN_INTERVAL[7]; @@ -90,7 +96,13 @@ function getMinIntervalByZoom(zoom) { // - 낮은 줌: 더 적은 개수 (밀집 지역 성능 최적화) // - 높은 줌: 더 많은 개수 (상세 보기) // ===================== -const DENSITY_LIMITS = [ +interface DensityConfig { + maxZoom: number; + maxPerCell: number; + gridSizeMultiplier: number; +} + +const DENSITY_LIMITS: DensityConfig[] = [ { maxZoom: 5, maxPerCell: 20, gridSizeMultiplier: 120 }, { maxZoom: 6, maxPerCell: 25, gridSizeMultiplier: 100 }, { maxZoom: 7, maxPerCell: 33, gridSizeMultiplier: 80 }, @@ -105,7 +117,7 @@ const DENSITY_LIMITS = [ * 밀도 제한 시 선박 우선순위 (낮을수록 높은 우선순위) * 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이 */ -const SHIP_KIND_PRIORITY = { +const SHIP_KIND_PRIORITY: Record = { [SIGNAL_KIND_CODE_KCGV]: 2, // 함정 [SIGNAL_KIND_CODE_GOV]: 3, // 관공선 [SIGNAL_KIND_CODE_PASSENGER]: 4, // 여객선 @@ -121,11 +133,8 @@ const PRIORITY_DEFAULT = 11; // 기본값 (최하위) /** * 선박 우선순위 점수 계산 - * @param {Object} ship - 선박 데이터 - * @param {Set} favoriteSet - 관심선박 ID Set - * @returns {number} 우선순위 점수 (낮을수록 높은 우선순위) */ -function getShipPriority(ship, favoriteSet) { +function getShipPriority(ship: ShipFeature, favoriteSet: Set | null): number { // 관심선박 체크 (최우선) const favoriteKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; if (favoriteSet && favoriteSet.has(favoriteKey)) { @@ -143,10 +152,8 @@ function getShipPriority(ship, favoriteSet) { /** * 줌레벨에 따른 밀도 설정 반환 - * @param {number} zoomLevel - 현재 줌 레벨 - * @returns {Object} 밀도 설정 { maxZoom, maxPerCell, gridSizeMultiplier } */ -function getDensityConfig(zoomLevel) { +function getDensityConfig(zoomLevel: number): DensityConfig { for (const config of DENSITY_LIMITS) { if (zoomLevel <= config.maxZoom) { return config; @@ -163,13 +170,8 @@ function getDensityConfig(zoomLevel) { * - 밀집 지역도 N척이 겹쳐 보여서 "빈 공간"처럼 보이지 않음 * - 줌레벨이 높아지면 제한이 완화되어 더 많은 선박 표시 * - 우선순위: 관심선박 > 함정 > 관공선 > 여객선 > 위험물 > 유조선 > 화물선 > 어선 > 기타 > 어망/부이 - * - * @param {Array} ships - 필터링된 선박 데이터 - * @param {number} zoomLevel - 현재 줌레벨 - * @param {Set} favoriteSet - 관심선박 ID Set (optional) - * @returns {Array} 밀도 제한이 적용된 선박 데이터 */ -function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { +function applyDensityLimit(ships: ShipFeature[], zoomLevel: number, favoriteSet: Set | null = null): ShipFeature[] { const config = getDensityConfig(zoomLevel); // 제한 없음 설정이면 원본 반환 @@ -186,8 +188,8 @@ function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; // 그리드별 선박 수 카운트 - const gridCounts = new Map(); - const result = []; + const gridCounts = new Map(); + const result: ShipFeature[] = []; const len = sortedShips.length; for (let i = 0; i < len; i++) { @@ -216,17 +218,29 @@ function applyDensityLimit(ships, zoomLevel, favoriteSet = null) { // 참조: mda-react-front/src/common/deck.ts (52-108) // ===================== +interface FilterCache { + enabledKinds: Set; + enabledSources: Set; + enabledNationals: Set; + isShipVisible: boolean; + isIntegrate: boolean; + darkSignalVisible: boolean; + darkSignalIds: Set; + dynamicPrioritySet: Set | null; + isFavoriteEnabled: boolean; + favoriteSet: Set; + favoriteTargetIds: Set | null; +} + /** * 필터 캐시 생성 * 렌더링 시작 시 1회 호출하여 Set 기반 O(1) lookup 가능하게 함 - * - * @returns {Object} 필터 캐시 객체 */ -function buildFilterCache() { +function buildFilterCache(): FilterCache { const { kindVisibility, sourceVisibility, nationalVisibility, isShipVisible, isIntegrate, darkSignalVisible, darkSignalIds, features } = useShipStore.getState(); // 활성화된 선종 코드 Set - const enabledKinds = new Set(); + const enabledKinds = new Set(); Object.entries(kindVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledKinds.add(code); @@ -234,7 +248,7 @@ function buildFilterCache() { }); // 활성화된 신호원 코드 Set - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledSources.add(code); @@ -242,7 +256,7 @@ function buildFilterCache() { }); // 활성화된 국적 코드 Set - const enabledNationals = new Set(); + const enabledNationals = new Set(); Object.entries(nationalVisibility).forEach(([code, isChecked]) => { if (isChecked) { enabledNationals.add(code); @@ -251,7 +265,7 @@ function buildFilterCache() { // 동적 우선순위 Set (통합 모드에서만 생성) // 참조: mda-react-front/docs/dynamic-priority.md §5 - let dynamicPrioritySet = null; + let dynamicPrioritySet: Set | null = null; if (isIntegrate) { dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); } @@ -260,9 +274,9 @@ function buildFilterCache() { const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); // 통합모드용: 관심선박이 포함된 통합그룹의 targetId Set - let favoriteTargetIds = null; + let favoriteTargetIds: Set | null = null; if (isFavoriteEnabled && favoriteSet.size > 0) { - favoriteTargetIds = new Set(); + favoriteTargetIds = new Set(); for (const ship of features.values()) { const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; if (favoriteSet.has(favKey)) { @@ -288,10 +302,8 @@ function buildFilterCache() { /** * 국적 코드 매핑 (실제 국적 코드 -> 필터 코드) - * @param {string} nationalCode - 선박의 국적 코드 - * @returns {string} 필터용 국적 코드 */ -function mapNationalCode(nationalCode) { +function mapNationalCode(nationalCode: string | undefined): string { if (!nationalCode) return 'OTHER'; const code = nationalCode.toUpperCase(); if (code === 'KR' || code === 'KOR' || code === '440') return 'KR'; @@ -304,16 +316,12 @@ function mapNationalCode(nationalCode) { /** * 캐시된 필터를 사용한 필터링 (O(1) lookup) * 참조: mda-react-front/src/common/deck.ts - applyFilterWithCache() - * - * @param {Object} ship - 선박 데이터 - * @param {Object} cache - 필터 캐시 - * @returns {boolean} 필터 통과 여부 */ -function applyFilterWithCache(ship, cache) { +function applyFilterWithCache(ship: ShipFeature, cache: FilterCache): boolean { // 전체 선박 표시 Off if (!cache.isShipVisible) return false; - // ⓪ 관심선박: 다른 모든 필터보다 우선 + // 0. 관심선박: 다른 모든 필터보다 우선 if (cache.isFavoriteEnabled && cache.favoriteTargetIds) { if (cache.isIntegrate) { // 통합모드: 이 통합그룹에 관심선박이 포함되어 있는가? @@ -331,11 +339,11 @@ function applyFilterWithCache(ship, cache) { } } - // ① 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조) + // 1. 다크시그널: 독립 필터 (선종/신호원/국적 무시, darkSignalVisible만 참조) // 통합모드 체크보다 먼저 실행해야 통합 다크시그널 선박도 렌더링됨 if (cache.darkSignalIds.has(ship.featureId)) return cache.darkSignalVisible; - // ② 통합 모드 필터: 동적 우선순위 기반 대표 선박만 표시 + // 2. 통합 모드 필터: 동적 우선순위 기반 대표 선박만 표시 // 참조: mda-react-front/docs/dynamic-priority.md §5 if (cache.isIntegrate && cache.dynamicPrioritySet) { const targetId = ship.targetId; @@ -346,7 +354,7 @@ function applyFilterWithCache(ship, cache) { // 단독선박: 동적 우선순위 체크 스킵 } - // ③ 선종 필터 (Set.has = O(1)) + // 3. 선종 필터 (Set.has = O(1)) if (!cache.enabledKinds.has(ship.signalKindCode)) return false; // 신호원 필터 (Set.has = O(1)) @@ -359,15 +367,19 @@ function applyFilterWithCache(ship, cache) { return true; } +/** 뷰포트 범위 */ +interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + /** * 뷰포트 범위 내 선박 필터링 * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - filterFeaturesWithBounds() - * - * @param {Array} ships - 선박 배열 - * @param {Object} bounds - 뷰포트 범위 { minLon, maxLon, minLat, maxLat } - * @returns {Array} 범위 내 선박 배열 */ -function filterByViewport(ships, bounds) { +function filterByViewport(ships: ShipFeature[], bounds: ViewportBounds | null): ShipFeature[] { if (!bounds) return ships; const { minLon, maxLon, minLat, maxLat } = bounds; @@ -385,11 +397,18 @@ function filterByViewport(ships, bounds) { const LON_DEGREE_METERS = 91000; // 중위도(35도) 기준 경도 1도당 약 91km const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km +/** 반경 필터 상태 */ +interface RadiusFilterState { + isActive: boolean; + center: { lon: number; lat: number } | null; + radiusNM: number; + boundingBox: ViewportBounds | null; +} + /** * 반경 필터 상태 가져오기 - * @returns {Object} { isActive, center, radiusNM, boundingBox } */ -function getRadiusFilterState() { +function getRadiusFilterState(): RadiusFilterState { const state = useTrackingModeStore.getState(); const { mode, trackedShip, radiusNM } = state; @@ -406,7 +425,7 @@ function getRadiusFilterState() { const lonDelta = radiusMeters / LON_DEGREE_METERS; const latDelta = radiusMeters / LAT_DEGREE_METERS; - const boundingBox = { + const boundingBox: ViewportBounds = { minLon: center.lon - lonDelta, maxLon: center.lon + lonDelta, minLat: center.lat - latDelta, @@ -420,12 +439,8 @@ function getRadiusFilterState() { * 반경 필터링 적용 * - 1단계: Bounding Box 체크 (빠른 사각형 체크) * - 2단계: Haversine 거리 계산 (정확한 원형 체크) - * - * @param {Array} ships - 선박 배열 - * @param {Object} radiusState - 반경 필터 상태 - * @returns {Array} 반경 내 선박 배열 */ -function filterByRadius(ships, radiusState) { +function filterByRadius(ships: ShipFeature[], radiusState: RadiusFilterState): ShipFeature[] { const { isActive, center, radiusNM, boundingBox } = radiusState; if (!isActive || !center || !boundingBox) { @@ -456,7 +471,12 @@ function filterByRadius(ships, radiusState) { // ===================== const LIVE_COUNT_THROTTLE_MS = 5000; -let liveCountCache = { +interface LiveCountCache { + lastCalcTime: number; + lastFilterHash: string; +} + +const liveCountCache: LiveCountCache = { lastCalcTime: 0, lastFilterHash: '', }; @@ -465,7 +485,7 @@ let liveCountCache = { * 필터 해시 생성 (필터 변경 감지용) * 참조: mda-react-front/src/common/deck.ts (488-511) */ -function generateFilterHash() { +function generateFilterHash(): string { const { kindVisibility, sourceVisibility, nationalVisibility, isIntegrate, darkSignalVisible } = useShipStore.getState(); const kinds = Object.entries(kindVisibility).filter(([,v]) => v).map(([k]) => k).join(','); @@ -475,13 +495,20 @@ function generateFilterHash() { return `${kinds}|${sources}|${nationals}|${isIntegrate?1:0}|${darkSignalVisible?1:0}|${isFavoriteEnabled?1:0}|${favoriteSet.size}`; } +/** cleanup 결과 */ +interface CleanupResult { + kindCounts: Record; + darkSignalCount: number; + totalCount: number; + deleteIds: string[]; + darkSignalConvertIds: string[]; +} + /** * 단일 패스로 타임아웃 cleanup + 카운트를 동시 수행 * 참조: mda-react-front/src/common/deck.ts - calculateAndCleanupLiveShips - * - * @returns {{ kindCounts, darkSignalCount, totalCount, deleteIds, darkSignalConvertIds }} */ -function calculateAndCleanupLiveShips() { +function calculateAndCleanupLiveShips(): CleanupResult { const state = useShipStore.getState(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility } = state; @@ -489,30 +516,30 @@ function calculateAndCleanupLiveShips() { // 반경 필터 상태 const radiusState = getRadiusFilterState(); - const kindCounts = { ...initialKindCounts }; + const kindCounts: Record = { ...initialKindCounts }; let darkSignalCount = 0; - const deleteIds = []; - const darkSignalConvertIds = []; + const deleteIds: string[] = []; + const darkSignalConvertIds: string[] = []; const now = Date.now(); // enabledSources Set - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, on]) => { if (on) enabledSources.add(code); }); - const seenTargetIds = new Set(); - const bestByTargetId = new Map(); // 통합모드용 + const seenTargetIds = new Set(); + const bestByTargetId = new Map(); features.forEach((ship, featureId) => { - // ① 이미 다크시그널 → 카운트만, 즉시 리턴 + // 1. 이미 다크시그널 -> 카운트만, 즉시 리턴 if (darkSignalIds.has(featureId)) { // 반경 필터가 활성화된 경우, 반경 내에 있을 때만 카운트 if (radiusState.isActive) { if (!ship.longitude || !ship.latitude) return; - const inBounds = ship.longitude >= radiusState.boundingBox.minLon && - ship.longitude <= radiusState.boundingBox.maxLon && - ship.latitude >= radiusState.boundingBox.minLat && - ship.latitude <= radiusState.boundingBox.maxLat; - if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) { + const inBounds = ship.longitude >= radiusState.boundingBox!.minLon && + ship.longitude <= radiusState.boundingBox!.maxLon && + ship.latitude >= radiusState.boundingBox!.minLat && + ship.latitude <= radiusState.boundingBox!.maxLat; + if (!inBounds || !isWithinRadius(ship, radiusState.center!.lon, radiusState.center!.lat, radiusState.radiusNM)) { return; } } @@ -521,28 +548,28 @@ function calculateAndCleanupLiveShips() { } const sourceCode = ship.signalSourceCode; - const elapsed = now - ship.receivedTimestamp; + const elapsed = now - (ship.receivedTimestamp ?? 0); - // ② 레이더: 만료 시 삭제, 유효 시 fall-through + // 2. 레이더: 만료 시 삭제, 유효 시 fall-through if (sourceCode === SIGNAL_SOURCE_RADAR) { if (elapsed > RADAR_TIMEOUT_MS) { deleteIds.push(featureId); return; } - // 유효 레이더 → fall-through + // 유효 레이더 -> fall-through } else { - // ③ LOST=0 + INSHORE 타임아웃 → 삭제 + // 3. LOST=0 + INSHORE 타임아웃 -> 삭제 if (!ship.lost && elapsed > INSHORE_TIMEOUT_MS) { deleteIds.push(featureId); return; } - // ④ LOST=1 + OFFSHORE 타임아웃 → 다크시그널 전환 + // 4. LOST=1 + OFFSHORE 타임아웃 -> 다크시그널 전환 if (ship.lost && elapsed > OFFSHORE_TIMEOUT_MS) { darkSignalConvertIds.push(featureId); darkSignalCount++; return; } - // ⑤ 장비 전체 비활성 → 다크시그널 전환 + // 5. 장비 전체 비활성 -> 다크시그널 전환 if (!isAnyEquipmentActive(ship)) { darkSignalConvertIds.push(featureId); darkSignalCount++; @@ -550,26 +577,26 @@ function calculateAndCleanupLiveShips() { } } - // ⑥ 반경 필터 체크 (카운트 전) + // 6. 반경 필터 체크 (카운트 전) if (radiusState.isActive) { if (!ship.longitude || !ship.latitude) return; - const inBounds = ship.longitude >= radiusState.boundingBox.minLon && - ship.longitude <= radiusState.boundingBox.maxLon && - ship.latitude >= radiusState.boundingBox.minLat && - ship.latitude <= radiusState.boundingBox.maxLat; - if (!inBounds || !isWithinRadius(ship, radiusState.center.lon, radiusState.center.lat, radiusState.radiusNM)) { + const inBounds = ship.longitude >= radiusState.boundingBox!.minLon && + ship.longitude <= radiusState.boundingBox!.maxLon && + ship.latitude >= radiusState.boundingBox!.minLat && + ship.latitude <= radiusState.boundingBox!.maxLat; + if (!inBounds || !isWithinRadius(ship, radiusState.center!.lon, radiusState.center!.lat, radiusState.radiusNM)) { return; } } - // ⑦ 카운트 대상 + // 7. 카운트 대상 const targetId = ship.targetId; const isIntegratedShip = targetId && (targetId.includes('_') || ship.integrate); if (isIntegrate && isIntegratedShip) { - // 통합모드 + 통합선박 (언더스코어 또는 integrate 플래그) → 후보 수집 + // 통합모드 + 통합선박 (언더스코어 또는 integrate 플래그) -> 후보 수집 const activeKey = SOURCE_TO_ACTIVE_KEY[sourceCode]; - if (!activeKey || ship[activeKey] !== '1') return; + if (!activeKey || (ship[activeKey] as string) !== '1') return; if (!enabledSources.has(sourceCode)) return; const rank = SOURCE_PRIORITY_RANK[sourceCode] ?? 99; @@ -611,11 +638,54 @@ function calculateAndCleanupLiveShips() { return { kindCounts, darkSignalCount, totalCount, deleteIds, darkSignalConvertIds }; } +/** 렌더링 콜백 타입 */ +type RenderCallback = (ships: ShipFeature[], trigger: number) => void; + +/** 렌더링 상태 */ +interface RenderState { + animationFrameId: ReturnType | number | null; + pendingRender: boolean; + isRendering: boolean; + lastRenderTime: number; + currentInterval: number; + currentZoom: number; +} + +/** 배치 렌더러 캐시 */ +interface BatchRendererCache { + filterCache: FilterCache | null; + lastFilterHash: string; + lastShipsData: ShipFeature[]; + lastFilteredCount: number; + lastRenderTrigger: number; + favoriteSet: Set | null; +} + +/** 렌더링 통계 */ +interface RenderStats { + currentZoom: number; + minInterval: number; + currentInterval: number; + lastRenderTime: number; + filteredCount: number; + renderedCount: number; + densityConfig: { + maxPerCell: number; + gridSizeMultiplier: number; + }; + renderTrigger: number; +} + /** * 선박 배치 렌더러 클래스 * 참조: mda-react-front/src/tracking/utils/ReplayBatchRenderer.ts */ class ShipBatchRenderer { + renderState: RenderState; + cache: BatchRendererCache; + onRenderCallback: RenderCallback | null; + viewportBounds: ViewportBounds | null; + constructor() { // 렌더링 상태 this.renderState = { @@ -634,7 +704,7 @@ class ShipBatchRenderer { lastShipsData: [], // 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) lastFilteredCount: 0, // 필터링된 선박 수 (밀도 제한 전) lastRenderTrigger: 0, - favoriteSet: null, // (사용 안 함 — useFavoriteStore.getState() 직접 참조) + favoriteSet: null, // (사용 안 함 -- useFavoriteStore.getState() 직접 참조) }; // 외부 콜백 @@ -646,31 +716,24 @@ class ShipBatchRenderer { /** * 배치 렌더러 초기화 - * @param {Function} renderCallback - 레이어 렌더링 콜백 - * (ships, trigger) => void - * - ships: 밀도 제한 적용된 선박 (아이콘 + 라벨 공통) - * - trigger: 렌더링 트리거 (주기적 갱신용) */ - initialize(renderCallback) { + initialize(renderCallback: RenderCallback): void { this.onRenderCallback = renderCallback; console.log('[ShipBatchRenderer] Initialized'); } /** * 뷰포트 범위 업데이트 - * @param {Object} bounds - { minLon, maxLon, minLat, maxLat } */ - setViewportBounds(bounds) { + setViewportBounds(bounds: ViewportBounds | null): void { this.viewportBounds = bounds; } /** * 줌 레벨 업데이트 * 줌 변경 시 최소 렌더링 간격도 재조정 - * @param {number} zoom - 현재 줌 레벨 - * @returns {boolean} 정수 줌 레벨이 변경되었는지 여부 */ - setZoom(zoom) { + setZoom(zoom: number): boolean { const prevZoom = this.renderState.currentZoom; const prevZoomInt = Math.floor(prevZoom); const newZoomInt = Math.floor(zoom); @@ -700,7 +763,7 @@ class ShipBatchRenderer { * 렌더링 요청 * 다음 렌더링 사이클에 처리됨 */ - requestRender() { + requestRender(): void { if (this.renderState.pendingRender) return; this.renderState.pendingRender = true; @@ -714,7 +777,7 @@ class ShipBatchRenderer { /** * 렌더링 스케줄링 */ - scheduleRender() { + scheduleRender(): void { const now = Date.now(); const elapsed = now - this.renderState.lastRenderTime; const delay = Math.max(0, this.renderState.currentInterval - elapsed); @@ -729,7 +792,7 @@ class ShipBatchRenderer { /** * 실제 렌더링 실행 */ - executeRender() { + executeRender(): void { if (this.renderState.isRendering || !this.onRenderCallback) { this.renderState.animationFrameId = null; return; @@ -762,12 +825,12 @@ class ShipBatchRenderer { // 4. 필터 적용 (캐시된 필터 사용 - O(1) lookup) const filteredShips = radiusFilteredShips.filter((ship) => - applyFilterWithCache(ship, this.cache.filterCache) + applyFilterWithCache(ship, this.cache.filterCache!) ); // 5. 밀도 제한 적용 (선박 아이콘 클러스터링, 우선순위 기반) - // 관심선박 토글 ON → favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선) - // 관심선박 토글 OFF → null 전달 (일반 선종 우선순위 적용) + // 관심선박 토글 ON -> favoriteSet 전달 (PRIORITY_FAVORITE=0, 최우선) + // 관심선박 토글 OFF -> null 전달 (일반 선종 우선순위 적용) const zoom = this.renderState.currentZoom; const { isFavoriteEnabled, favoriteSet: currentFavoriteSet } = useFavoriteStore.getState(); const densityFavoriteSet = isFavoriteEnabled ? currentFavoriteSet : null; @@ -805,10 +868,8 @@ class ShipBatchRenderer { /** * 적응형 렌더링 간격 조정 * 줌 레벨에 따른 최소 간격 + 성능 기반 적응형 조정 - * - * @param {number} renderTime - 렌더링 소요 시간 (ms) */ - adjustRenderInterval(renderTime) { + adjustRenderInterval(renderTime: number): void { const { targetRenderTime, maxRenderTime, maxInterval } = RENDER_CONFIG; const minInterval = getMinIntervalByZoom(this.renderState.currentZoom); @@ -831,7 +892,7 @@ class ShipBatchRenderer { * 카운트 + 타임아웃 cleanup (5초 쓰로틀, 필터 변경 시 즉시) * 참조: mda-react-front/src/common/deck.ts - updateLayerData 내 카운트 로직 */ - updateLiveShipCountsThrottled() { + updateLiveShipCountsThrottled(): void { const now = Date.now(); const currentFilterHash = generateFilterHash(); const filterChanged = currentFilterHash !== liveCountCache.lastFilterHash; @@ -849,7 +910,7 @@ class ShipBatchRenderer { useShipStore.getState().applyCleanup(result.deleteIds, result.darkSignalConvertIds); } - // 카운트 업데이트 (값 비교 가드 — 불필요한 리렌더 방지) + // 카운트 업데이트 (값 비교 가드 -- 불필요한 리렌더 방지) const state = useShipStore.getState(); const prevKindCounts = state.kindCounts; const countsChanged = result.totalCount !== state.totalCount @@ -869,7 +930,7 @@ class ShipBatchRenderer { * 강제 렌더링 (필터 변경 등) * 일반 렌더링 주기에 따름 */ - forceRender() { + forceRender(): void { this.cache.filterCache = null; this.requestRender(); } @@ -878,11 +939,11 @@ class ShipBatchRenderer { * 즉시 렌더링 (필터/선명표시 토글 등 사용자 인터랙션) * 렌더링 주기를 무시하고 즉시 실행 */ - immediateRender() { + immediateRender(): void { // 기존 스케줄 취소 if (this.renderState.animationFrameId) { - clearTimeout(this.renderState.animationFrameId); - cancelAnimationFrame(this.renderState.animationFrameId); + clearTimeout(this.renderState.animationFrameId as ReturnType); + cancelAnimationFrame(this.renderState.animationFrameId as number); this.renderState.animationFrameId = null; } @@ -899,7 +960,7 @@ class ShipBatchRenderer { /** * 캐시 클리어 */ - clearCache() { + clearCache(): void { this.cache.filterCache = null; this.cache.lastFilterHash = ''; this.cache.lastShipsData = []; @@ -910,10 +971,10 @@ class ShipBatchRenderer { /** * 배치 렌더러 정리 */ - dispose() { + dispose(): void { if (this.renderState.animationFrameId) { - cancelAnimationFrame(this.renderState.animationFrameId); - clearTimeout(this.renderState.animationFrameId); + cancelAnimationFrame(this.renderState.animationFrameId as number); + clearTimeout(this.renderState.animationFrameId as ReturnType); this.renderState.animationFrameId = null; } this.clearCache(); @@ -923,17 +984,15 @@ class ShipBatchRenderer { /** * 마지막 필터링된 선박 데이터 반환 - * @returns {Array} 필터링된 선박 배열 */ - getFilteredShips() { + getFilteredShips(): ShipFeature[] { return this.cache.lastShipsData; } /** * 현재 렌더링 통계 반환 - * @returns {Object} 렌더링 통계 */ - getStats() { + getStats(): RenderStats { const zoom = this.renderState.currentZoom; const densityConfig = getDensityConfig(zoom); return { @@ -957,5 +1016,6 @@ export const shipBatchRenderer = new ShipBatchRenderer(); // 유틸리티 함수 export export { buildFilterCache, applyFilterWithCache, filterByViewport, applyDensityLimit, getDensityConfig }; +export type { FilterCache, ViewportBounds, DensityConfig, RenderStats }; export default ShipBatchRenderer; diff --git a/src/map/layers/baseLayer.js b/src/map/layers/baseLayer.js deleted file mode 100644 index 5d90c50c..00000000 --- a/src/map/layers/baseLayer.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * 베이스맵 레이어 설정 - * - 메인 프로젝트(mda-react-front)의 mapLayer.ts 참조 - */ -import { XYZ } from 'ol/source'; -import { transformExtent } from 'ol/proj'; -import WebGLTileLayer from 'ol/layer/WebGLTile'; - -// 좌표계 상수 -const EPSG_3857 = 'EPSG:3857'; -const EPSG_4326 = 'EPSG:4326'; - -// 1x1 투명 PNG (타일 로드 실패 시 대체용) -const TRANSPARENT_PIXEL = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; - -/** - * 타일 로드 함수 (fetch 사용으로 콘솔 에러 완전 방지) - * - 404/네트워크 에러 시 투명 이미지로 대체 - * - 브라우저 네트워크 탭에는 표시되지만 콘솔 에러는 없음 - */ -function silentTileLoadFunction(imageTile, src) { - const img = imageTile.getImage(); - - fetch(src) - .then(response => { - if (!response.ok) { - throw new Error('Tile not found'); - } - return response.blob(); - }) - .then(blob => { - img.src = URL.createObjectURL(blob); - }) - .catch(() => { - img.src = TRANSPARENT_PIXEL; - }); -} - -/** - * 레이어 설정 - */ -export const mapLayerConfig = { - // 일반지도 (줌 0-11) - worldLayer: { - source: new XYZ({ - url: '/MAPS/WORLD_webp/{z}/{x}/{y}.webp', - minZoom: 0, - maxZoom: 11, - attributions: 'ⓒ OpenStreetMap', - tileLoadFunction: silentTileLoadFunction, - }), - preload: Infinity, - }, - - // 전자해도 (줌 0-11) - URL은 임시로 worldLayer와 동일 - encLayer: { - source: new XYZ({ - url: '/MAPS/WORLD_webp/{z}/{x}/{y}.webp', - minZoom: 0, - maxZoom: 11, - attributions: 'ⓒ OpenStreetMap', - tileLoadFunction: silentTileLoadFunction, - }), - preload: Infinity, - }, - - // 야간지도 (줌 5-15, 타일은 6-11레벨까지만 로드 → 12+ 확대 표시) - darkLayer: { - source: new XYZ({ - url: '/MAPS/SIMPLE_B_webp/{z}/{x}/{y}.webp', - minZoom: 6, - maxZoom: 11, // 타일은 11레벨까지만 로드 (12+ 는 11레벨 타일 확대) - tileLoadFunction: silentTileLoadFunction, - }), - preload: Infinity, - }, - - // 동아시아 상세 (줌 12-15, 타일은 12레벨까지만 로드 → 13+ 확대 표시) - eastAsiaLayer: { - source: new XYZ({ - url: '/MAPS/EAST_ASIA_webp/{z}/{x}/{y}.webp', - minZoom: 12, - maxZoom: 12, // 타일은 12레벨까지만 로드 (13+ 는 12레벨 타일 확대) - tileLoadFunction: silentTileLoadFunction, - }), - preload: 0, - minZoom: 12, - zIndex: 1, - extent: transformExtent([110, 20, 140, 45], EPSG_4326, EPSG_3857), - }, - - // 한국 상세 (줌 16-17) - korLayer: { - source: new XYZ({ - url: '/MAPS/KOR_webp/{z}/{x}/{y}.webp', - minZoom: 16, - maxZoom: 17, - tileLoadFunction: silentTileLoadFunction, - }), - preload: Infinity, - minZoom: 16, - zIndex: 1, - extent: transformExtent([124, 32, 133, 39], EPSG_4326, EPSG_3857), - }, -}; - -/** - * 베이스맵 레이어 생성 - * @param {string} baseMapType - 배경지도 타입 ('normal' | 'enc' | 'dark') - */ -export const createBaseLayers = (baseMapType = 'dark') => { - // 3가지 배경지도 레이어 생성 - const worldMap = new WebGLTileLayer({ - ...mapLayerConfig.worldLayer, - visible: baseMapType === 'normal', - }); - const encMap = new WebGLTileLayer({ - ...mapLayerConfig.encLayer, - visible: baseMapType === 'enc', - }); - const darkMap = new WebGLTileLayer({ - ...mapLayerConfig.darkLayer, - visible: baseMapType === 'dark', - }); - - // 상세 지도 레이어 (일반지도/전자해도 전용, 야간지도에서는 숨김) - const isDarkMode = baseMapType === 'dark'; - const eastAsiaMap = new WebGLTileLayer({ - ...mapLayerConfig.eastAsiaLayer, - visible: !isDarkMode, - }); - const korMap = new WebGLTileLayer({ - ...mapLayerConfig.korLayer, - visible: !isDarkMode, - }); - - return { - worldMap, - encMap, - darkMap, - eastAsiaMap, - korMap, - }; -}; - -export default createBaseLayers; diff --git a/src/map/layers/baseLayer.ts b/src/map/layers/baseLayer.ts new file mode 100644 index 00000000..66fab033 --- /dev/null +++ b/src/map/layers/baseLayer.ts @@ -0,0 +1,61 @@ +/** + * 베이스맵 스타일 설정 (MapLibre GL JS) + * + * 래스터 타일 기반 스타일: + * - normal/enc: OpenStreetMap + * - dark: CartoDB Dark Matter + */ +import type { StyleSpecification } from 'maplibre-gl'; + +type BaseMapType = 'normal' | 'enc' | 'dark'; + +const OSM_RASTER_STYLE: StyleSpecification = { + version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap', + maxzoom: 19, + }, + }, + layers: [{ id: 'osm-tiles', type: 'raster', source: 'osm' }], +}; + +const DARK_RASTER_STYLE: StyleSpecification = { + version: 8, + glyphs: 'https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf', + sources: { + 'carto-dark': { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + 'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + 'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + 'https://d.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap © CARTO', + maxzoom: 19, + }, + }, + layers: [{ id: 'carto-dark-tiles', type: 'raster', source: 'carto-dark' }], +}; + +const BASE_MAP_STYLES: Record = { + normal: OSM_RASTER_STYLE, + enc: OSM_RASTER_STYLE, // ENC는 일반지도와 동일 (임시) + dark: DARK_RASTER_STYLE, +}; + +/** CartoDB Dark 타일 URL (미니맵 등 OL 기반 컴포넌트용) */ +export const DARK_TILE_URL = 'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png'; + +/** + * 배경지도 타입에 해당하는 MapLibre StyleSpecification 반환 + */ +export function getBaseMapStyle(type: BaseMapType): StyleSpecification { + return BASE_MAP_STYLES[type] || DARK_RASTER_STYLE; +} diff --git a/src/map/layers/shipLayer.js b/src/map/layers/shipLayer.ts similarity index 76% rename from src/map/layers/shipLayer.js rename to src/map/layers/shipLayer.ts index 3ed64d27..d15d665e 100644 --- a/src/map/layers/shipLayer.js +++ b/src/map/layers/shipLayer.ts @@ -4,7 +4,8 @@ * 참조: mda-react-front/src/util/realTimeLayerUtil.ts */ import { IconLayer, TextLayer, ScatterplotLayer, LineLayer, PathLayer } from '@deck.gl/layers'; -import { fromLonLat, toLonLat } from 'ol/proj'; +import type { Layer } from '@deck.gl/core'; +import { lngLatToMercator, mercatorToLngLat } from '../../utils/projection'; import { ICON_ATLAS_MAPPING, ICON_MAPPING_KIND_MOVING, @@ -13,6 +14,7 @@ import { SIGNAL_KIND_CODE_BUOY, SIGNAL_FLAG_CONFIGS, } from '../../types/constants'; +import type { ShipFeature } from '../../types/ship'; import useShipStore from '../../stores/shipStore'; import useFavoriteStore from '../../stores/favoriteStore'; import useTrackingModeStore from '../../stores/trackingModeStore'; @@ -23,9 +25,54 @@ import atlasImg from '../../assets/img/icon/atlas.png'; // 관심선박 강조 아이콘 import favShipIcon from '../../assets/images/ico_favship.svg'; +/** 속도벡터 라인 데이터 */ +interface SpeedVectorData { + sourcePosition: number[]; + targetPosition: number[]; + color: number[]; +} + +/** DIM 폴리곤 데이터 */ +interface DimPolygonData { + path: number[][]; + featureId: string; +} + +/** 신호 플래그 IconLayer 데이터 */ +interface SignalFlagData { + longitude: number; + latitude: number; + url: string; +} + +/** 신호 플래그 배열 항목 */ +interface FlagItem { + name: string; + color: string; + flag: string; +} + +/** buildFlagStateArray 반환 타입 */ +interface FlagStateResult { + key: string; + flagArray: FlagItem[]; +} + +/** 라벨 옵션 */ +interface LabelOptions { + showShipName?: boolean; + showSpeedVector?: boolean; + showShipSize?: boolean; + showSignalStatus?: boolean; +} + +/** 라벨 데이터 (ShipFeature + labelText) */ +interface LabelData extends ShipFeature { + labelText: string; +} + /** * 현재 테마 색상 가져오기 - * @returns {Object} 테마 색상 객체 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -33,15 +80,13 @@ function getCurrentThemeColors() { return THEME_COLORS[theme] || THEME_COLORS[THEME_TYPES.LIGHT]; } -// 추적 선박 아이콘 (인라인 SVG data URL) -const TRACKED_SHIP_SVG = ` - - - - -`; -const TRACKED_SHIP_ICON_URL = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(TRACKED_SHIP_SVG)}`; - +// 추적 선박 아이콘 (인라인 SVG data URL) — 향후 추적 선박 시각화에 사용 예정 +// const TRACKED_SHIP_SVG = ` +// +// +// +// +// `; // ===================== // 도 → 라디안 변환 상수 // ===================== @@ -52,25 +97,37 @@ const DEG_TO_RAD = 0.0174533; // Math.PI / 180 // 줌 레벨 + isIntegrate 상태 + 렌더 트리거 기반 갱신 // 참조: mda-react-front/src/util/realTimeLayerUtil.ts (라인 327-363) // ===================== -const clusterCache = { +interface ClusterCache { + lastZoom: number | null; + lastDataLength: number; + lastIsIntegrate: boolean | null; + lastRenderTrigger: number; + clusteredData: ShipFeature[]; + positionHash: string; +} + +interface SignalClusterCache extends ClusterCache { + lastShipsLength: number; + lastShipsHash: string; +} + +const clusterCache: ClusterCache = { lastZoom: null, lastDataLength: 0, lastIsIntegrate: null, lastRenderTrigger: 0, clusteredData: [], - // 선박 위치 샘플 해시 (변경 감지용) positionHash: '', }; // 신호상태 레이어용 별도 캐시 -const signalClusterCache = { +const signalClusterCache: SignalClusterCache = { lastZoom: null, lastDataLength: 0, lastIsIntegrate: null, lastRenderTrigger: 0, clusteredData: [], positionHash: '', - // 밀도 제한된 ships 배열 변경 감지용 lastShipsLength: 0, lastShipsHash: '', }; @@ -86,16 +143,11 @@ const CLUSTER_GRID_SIZE_SIGNAL = 35; // 신호상태용 (더 조밀하게 표시 * 그리드 기반 클러스터링 (개선된 버전) * - 줌 레벨이 높을수록 더 많은 선박 표시 (점진적 증가) * - 낮은 줌에서 표시된 선박은 높은 줌에서도 반드시 표시 - * - * @param {Array} data - 선박 데이터 배열 - * @param {number} zoomLevel - 현재 줌 레벨 - * @param {number} gridSizeMultiplier - 그리드 크기 배율 (기본: 50) - * @returns {Array} 클러스터링된 선박 배열 */ -function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_LABEL) { +function clusterPoints(data: ShipFeature[], zoomLevel: number, gridSizeMultiplier: number = CLUSTER_GRID_SIZE_LABEL): ShipFeature[] { // 그리드 크기: 줌 레벨이 높을수록 작은 그리드 const gridSize = Math.pow(2, -zoomLevel) * gridSizeMultiplier; - const clusters = {}; + const clusters: Record = {}; // 1단계: 그리드별 선박 그룹화 const len = data.length; @@ -114,7 +166,7 @@ function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_L // 2단계: 각 그리드에서 대표 선박 선택 // 우선순위: 신호상태 있음 > 선박명 있음 > 첫 번째 const clusterKeys = Object.keys(clusters); - const result = []; + const result: ShipFeature[] = []; for (let i = 0; i < clusterKeys.length; i++) { const clusterShips = clusters[clusterKeys[i]]; @@ -147,10 +199,8 @@ function clusterPoints(data, zoomLevel, gridSizeMultiplier = CLUSTER_GRID_SIZE_L /** * 선박 위치 해시 생성 (샘플링) * 처음/중간/마지막 선박 위치를 해시로 변환 - * @param {Array} ships - 선박 배열 - * @returns {string} 위치 해시 */ -function computePositionHash(ships) { +function computePositionHash(ships: ShipFeature[]): string { if (ships.length === 0) return ''; // 샘플링: 처음, 중간, 마지막 선박 위치 @@ -168,14 +218,8 @@ function computePositionHash(ships) { * 캐시된 클러스터링 결과 가져오기 * - 줌 레벨, isIntegrate 변경 시 재계산 * - 렌더 트리거 주기에 따라 갱신 (선박 위치 변경 반영) - * - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 (배치 렌더러에서 증가) - * @returns {Array} 클러스터링된 선박 배열 */ -function getClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { +function getClusteredShips(ships: ShipFeature[], zoom: number, isIntegrate: boolean, renderTrigger: number = 0): ShipFeature[] { const zoomInt = Math.floor(zoom); const positionHash = computePositionHash(ships); @@ -211,7 +255,7 @@ function getClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { /** * 클러스터 캐시 초기화 */ -export function clearClusterCache() { +export function clearClusterCache(): void { clusterCache.lastZoom = null; clusterCache.lastDataLength = 0; clusterCache.lastIsIntegrate = null; @@ -231,11 +275,8 @@ export function clearClusterCache() { /** * 실제로 신호상태 SVG가 생성 가능한지 체크 - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {boolean} SVG 생성 가능 여부 */ -function canGenerateSignalSVG(ship, isIntegrate) { +function canGenerateSignalSVG(ship: ShipFeature, isIntegrate: boolean): boolean { if (isIntegrate && isIntegratedShip(ship)) { // 통합선박 + 선박통합 ON: 장비 값이 '0' 또는 '1'인 것이 하나라도 있어야 함 return ship.ais === '0' || ship.ais === '1' || @@ -252,10 +293,8 @@ function canGenerateSignalSVG(ship, isIntegrate) { /** * ships 배열의 featureId 해시 생성 (배열 변경 감지용) - * @param {Array} ships - 선박 배열 - * @returns {string} featureId 기반 해시 */ -function computeShipsHash(ships) { +function computeShipsHash(ships: ShipFeature[]): string { if (ships.length === 0) return ''; // 처음, 1/4, 중간, 3/4, 마지막 선박의 featureId를 샘플링 const indices = [ @@ -271,14 +310,8 @@ function computeShipsHash(ships) { /** * 신호상태용 클러스터링 결과 가져오기 * 실제로 SVG가 생성 가능한 선박만 대상으로 클러스터링 - * - * @param {Array} ships - 필터링된 선박 데이터 배열 (지도에 그려지는 선박) - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 - * @returns {Array} 클러스터링된 선박 배열 */ -function getSignalClusteredShips(ships, zoom, isIntegrate, renderTrigger = 0) { +function getSignalClusteredShips(ships: ShipFeature[], zoom: number, isIntegrate: boolean, renderTrigger: number = 0): ShipFeature[] { const zoomInt = Math.floor(zoom); // ships 배열 변경 감지용 해시 (밀도 제한 결과가 달라지면 캐시 무효화) @@ -334,10 +367,8 @@ const VECTOR_INCREMENT_SCALE = 40; // 속도 1kn 증가당 추가 길이 /** * 선박 아이콘 결정 - * @param {Object} ship - 선박 데이터 - * @returns {string} 아이콘 이름 (ICON_ATLAS_MAPPING 키) */ -export function getShipIcon(ship, darkSignalIds) { +export function getShipIcon(ship: ShipFeature, darkSignalIds: Set | null): string { // 다크시그널(소실신호): darkSignalIds Set으로 판단 if (darkSignalIds && darkSignalIds.has(ship.featureId)) { return 'lostShipImg'; @@ -356,11 +387,8 @@ export function getShipIcon(ship, darkSignalIds) { /** * 선박 회전 각도 계산 (COG 기준) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - targetAngle() - * - * @param {Object} ship - 선박 데이터 - * @returns {number} 회전 각도 (degrees) */ -export function getShipAngle(ship) { +export function getShipAngle(ship: ShipFeature): number { // 부이는 회전하지 않음 if (ship.signalKindCode === SIGNAL_KIND_CODE_BUOY) { return 0; @@ -374,12 +402,8 @@ export function getShipAngle(ship) { /** * 선박 크기 결정 (운항/정박 상태 + 줌 레벨) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - targetScale() - * - * @param {Object} ship - 선박 데이터 - * @param {number} zoom - 현재 줌 레벨 - * @returns {number} 아이콘 크기 (px) */ -export function getShipSize(ship, zoom) { +export function getShipSize(ship: ShipFeature, zoom: number): number { // 부이: 항상 16px if (ship.signalKindCode === SIGNAL_KIND_CODE_BUOY) { return 16; @@ -405,12 +429,9 @@ export function getShipSize(ship, zoom) { /** * 선박 IconLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {IconLayer} Deck.gl IconLayer */ -export function createShipIconLayer(ships, zoom = 10, darkSignalIds = null) { - return new IconLayer({ +export function createShipIconLayer(ships: ShipFeature[], zoom: number = 10, darkSignalIds: Set | null = null): IconLayer { + return new IconLayer({ id: 'ship-icon-layer', data: ships, pickable: true, @@ -439,11 +460,8 @@ export function createShipIconLayer(ships, zoom = 10, darkSignalIds = null) { * 벡터 길이 계산: * - 최소 길이(VECTOR_MIN_LENGTH) + 속도 초과분 * 증가율(VECTOR_INCREMENT_SCALE) * - SPEED_THRESHOLD(1kn) 기준으로 항해중 아이콘과 함께 표시 - * - * @param {Array} ships - 선박 데이터 배열 - * @returns {Array} 라인 데이터 [{ sourcePosition, targetPosition, color }] */ -function buildSpeedVectorData(ships) { +function buildSpeedVectorData(ships: ShipFeature[]): SpeedVectorData[] { // 테마 기반 색상 const themeColors = getCurrentThemeColors(); const vectorColor = themeColors.speedVector; @@ -457,7 +475,7 @@ function buildSpeedVectorData(ships) { const sog = Number(ship.sog) || 0; // 투영좌표로 변환 (EPSG:3857 - Web Mercator) - const coordinate = fromLonLat([lng, lat]); + const coordinate = lngLatToMercator([lng, lat]); const projX = coordinate[0]; const projY = coordinate[1]; @@ -473,20 +491,17 @@ function buildSpeedVectorData(ships) { // 경위도로 변환하여 반환 return { - sourcePosition: toLonLat([projX, projY]), - targetPosition: toLonLat([projX + xAdd, projY + yAdd]), - color: vectorColor, + sourcePosition: mercatorToLngLat([projX, projY]), + targetPosition: mercatorToLngLat([projX + xAdd, projY + yAdd]), + color: vectorColor as number[], }; }); } /** * 속도벡터 LineLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {LineLayer|null} Deck.gl LineLayer */ -export function createSpeedVectorLayer(ships, zoom) { +export function createSpeedVectorLayer(ships: ShipFeature[], zoom: number): LineLayer | null { // 줌 9 이상에서만 표시 if (zoom < 9) { return null; @@ -497,13 +512,13 @@ export function createSpeedVectorLayer(ships, zoom) { return null; } - return new LineLayer({ + return new LineLayer({ id: 'speed-vector-layer', data: vectorData, pickable: false, - getSourcePosition: (d) => d.sourcePosition, - getTargetPosition: (d) => d.targetPosition, - getColor: (d) => d.color, + getSourcePosition: (d) => d.sourcePosition as [number, number], + getTargetPosition: (d) => d.targetPosition as [number, number], + getColor: (d) => d.color as [number, number, number, number], getWidth: 2, widthMinPixels: 1, widthMaxPixels: 3, @@ -521,23 +536,12 @@ const DIM_POLYGON_MIN_ZOOM = 14; /** * 선박 크기 폴리곤 계산 (dimABCD 기준) * 참조점(안테나 위치)을 기준으로 선박 형태 폴리곤 생성 - * - * dimA: 참조점 → 뱃머리(bow) 거리 - * dimB: 참조점 → 선미(stern) 거리 - * dimC: 참조점 → 좌현(port) 거리 - * dimD: 참조점 → 우현(starboard) 거리 - * - * @param {number} projX - 투영좌표 X (선박 위치) - * @param {number} projY - 투영좌표 Y (선박 위치) - * @param {number} dimA - 뱃머리 거리 (m) - * @param {number} dimB - 선미 거리 (m) - * @param {number} dimC - 좌현 거리 (m) - * @param {number} dimD - 우현 거리 (m) - * @param {number} angleS - sin(360 - COG) - * @param {number} angleC - cos(360 - COG) - * @returns {Array} 경위도 좌표 배열 (6점 폴리곤) */ -function calDimension(projX, projY, dimA, dimB, dimC, dimD, angleS, angleC) { +function calDimension( + projX: number, projY: number, + dimA: number, dimB: number, dimC: number, dimD: number, + angleS: number, angleC: number, +): number[][] { // 좌상단 (좌현, 뱃머리 3/4) let leftTopX = -1 * dimC; let leftTopY = (dimA * 3) / 4; @@ -580,28 +584,24 @@ function calDimension(projX, projY, dimA, dimB, dimC, dimD, angleS, angleC) { // 6점 폴리곤 (시작점으로 복귀) return [ - toLonLat([leftTopX, leftTopY]), - toLonLat([leftBottomX, leftBottomY]), - toLonLat([rightBottomX, rightBottomY]), - toLonLat([rightTopX, rightTopY]), - toLonLat([centerTopX, centerTopY]), - toLonLat([leftTopX, leftTopY]), + mercatorToLngLat([leftTopX, leftTopY]), + mercatorToLngLat([leftBottomX, leftBottomY]), + mercatorToLngLat([rightBottomX, rightBottomY]), + mercatorToLngLat([rightTopX, rightTopY]), + mercatorToLngLat([centerTopX, centerTopY]), + mercatorToLngLat([leftTopX, leftTopY]), ]; } /** * 선박 크기 폴리곤 계산 (길이/너비만 있는 경우) * 중심점 기준으로 사각형 + 선수 형태 생성 - * - * @param {number} projX - 투영좌표 X (선박 중심) - * @param {number} projY - 투영좌표 Y (선박 중심) - * @param {number} length - 선박 총 길이 (m) - * @param {number} width - 선박 총 너비 (m) - * @param {number} angleS - sin(360 - COG) - * @param {number} angleC - cos(360 - COG) - * @returns {Array} 경위도 좌표 배열 (6점 폴리곤) */ -function calDimensionCentered(projX, projY, length, width, angleS, angleC) { +function calDimensionCentered( + projX: number, projY: number, + length: number, width: number, + angleS: number, angleC: number, +): number[][] { // 중심 기준이므로 dimA = dimB = length/2, dimC = dimD = width/2 const dimA = length / 2; // 뱃머리 const dimB = length / 2; // 선미 @@ -614,12 +614,9 @@ function calDimensionCentered(projX, projY, length, width, angleS, angleC) { /** * 선박 DIM 폴리곤 데이터 생성 * dimABCD 우선, 없으면 길이/너비로 중심 기준 폴리곤 생성 - * - * @param {Array} ships - 뷰포트 내 선박 배열 - * @returns {Array} 폴리곤 경로 데이터 */ -function buildDimPolygonData(ships) { - const result = []; +function buildDimPolygonData(ships: ShipFeature[]): DimPolygonData[] { + const result: DimPolygonData[] = []; for (const ship of ships) { const dimA = Number(ship.dimA) || 0; @@ -628,7 +625,7 @@ function buildDimPolygonData(ships) { const dimD = Number(ship.dimD) || 0; // 투영좌표 변환 - const coordinate = fromLonLat([Number(ship.longitude), Number(ship.latitude)]); + const coordinate = lngLatToMercator([Number(ship.longitude), Number(ship.latitude)]); const projX = coordinate[0]; const projY = coordinate[1]; @@ -637,7 +634,7 @@ function buildDimPolygonData(ships) { const angleS = Math.sin(((360 - cog) * Math.PI) / 180.0); const angleC = Math.cos(((360 - cog) * Math.PI) / 180.0); - let path = null; + let path: number[][] | null = null; // 케이스 1: dimABCD가 모두 있는 경우 (참조점 기준) if (dimA > 0) { @@ -672,12 +669,8 @@ function buildDimPolygonData(ships) { /** * 선박크기 PathLayer 생성 * 줌 레벨 11 이상에서만 렌더링 - * - * @param {Array} ships - 뷰포트 내 선박 배열 - * @param {number} zoom - 현재 줌 레벨 - * @returns {PathLayer|null} Deck.gl PathLayer */ -export function createShipDimLayer(ships, zoom) { +export function createShipDimLayer(ships: ShipFeature[], zoom: number): PathLayer | null { // 줌 레벨 11 미만이면 null 반환 if (zoom < DIM_POLYGON_MIN_ZOOM) { return null; @@ -692,15 +685,16 @@ export function createShipDimLayer(ships, zoom) { // 테마 기반 색상 const themeColors = getCurrentThemeColors(); - return new PathLayer({ + return new PathLayer({ id: 'ship-dim-layer', data: dimData, pickable: false, widthScale: 1, widthMinPixels: 1, widthMaxPixels: 3, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry getPath: (d) => d.path, - getColor: themeColors.shipDim, + getColor: themeColors.shipDim as [number, number, number, number], getWidth: 2, jointRounded: true, capRounded: true, @@ -713,15 +707,13 @@ export function createShipDimLayer(ships, zoom) { // ===================== // SVG 캐시 맵 -const flagSvgCache = new Map(); +const flagSvgCache = new Map(); /** * 통합선박 여부 판별 - * @param {Object} ship - 선박 객체 - * @returns {boolean} 통합선박 여부 */ -function isIntegratedShip(ship) { - return ship.targetId && (ship.targetId.includes('_') || ship.integrate); +function isIntegratedShip(ship: ShipFeature): boolean { + return !!ship.targetId && (ship.targetId.includes('_') || ship.integrate); } /** @@ -729,11 +721,10 @@ function isIntegratedShip(ship) { * 동일 targetId를 공유하는 모든 feature의 장비 플래그를 합산 * 예: 레이더 feature(vtsRadar='1') + AIS feature(ais='1') → { ais:'1', vtsRadar:'1' } * '1'(활성) > '0'(비활성) > ''(없음) 우선순위로 병합 - * @returns {Map} targetId → 병합된 장비 플래그 */ -function buildMergedEquipmentFlags() { +function buildMergedEquipmentFlags(): Map> { const { features } = useShipStore.getState(); - const map = new Map(); + const map = new Map>(); features.forEach((ship) => { const targetId = ship.targetId; @@ -742,7 +733,7 @@ function buildMergedEquipmentFlags() { const existing = map.get(targetId) || {}; for (const config of SIGNAL_FLAG_CONFIGS) { const key = config.dataKey; - const val = ship[key]; + const val = ship[key] as string | undefined; // '1'이면 무조건 설정, '0'은 기존이 '1'이 아닐 때만 if (val === '1') { existing[key] = '1'; @@ -759,22 +750,10 @@ function buildMergedEquipmentFlags() { /** * 신호 상태 배열 생성 (캐시 키 + SVG 생성용) * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - * - * 선박통합 ON + 통합선박 (TARGET_ID에 '_' 포함): - * - '1' = 장비 존재 + 활성 (activeColor) - * - '0' = 장비 존재 + 비활성 (inactiveColor/회색) - * - '' = 장비 없음 (표시 안함) - * - * 선박통합 OFF 또는 단독선박: - * - 현재 signalSourceCode에 해당하는 장비만 표시 (항상 활성 색상) - * - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {Object} { key, flagArray } */ -function buildFlagStateArray(ship, isIntegrate) { - const keyParts = []; - const flagArray = []; +function buildFlagStateArray(ship: ShipFeature, isIntegrate: boolean): FlagStateResult { + const keyParts: string[] = []; + const flagArray: FlagItem[] = []; // 선박통합 ON이고 통합선박인 경우에만 통합 모드로 처리 const useIntegratedMode = isIntegrate && isIntegratedShip(ship); @@ -786,11 +765,8 @@ function buildFlagStateArray(ship, isIntegrate) { if (useIntegratedMode) { // 통합선박 + 선박통합 ON: 장비 값 확인 - const dataValue = ship[config.dataKey]; + const dataValue = ship[config.dataKey] as string | undefined; - // '' 또는 undefined → 장비 없음 (표시 안함) - // '0' → 장비 존재, 비활성 (회색) - // '1' → 장비 존재, 활성 (색상) if (dataValue === '1') { isVisible = true; isActive = true; @@ -800,7 +776,6 @@ function buildFlagStateArray(ship, isIntegrate) { isActive = false; color = config.inactiveColor; } - // dataValue가 '' 또는 undefined면 isVisible = false 유지 } else { // 선박통합 OFF 또는 단독선박: 현재 신호원만 표시 (항상 활성 색상) if (config.signalSourceCode === ship.signalSourceCode) { @@ -830,10 +805,8 @@ function buildFlagStateArray(ship, isIntegrate) { /** * 신호 플래그 SVG 생성 * 참조: mda-react-front/src/util/realTimeLayerUtil.ts - createFlagLabelSVG() - * @param {Array} arr - 플래그 배열 - * @returns {string} SVG 문자열 */ -function createFlagLabelSVG(arr) { +function createFlagLabelSVG(arr: FlagItem[]): string { const filteredArr = arr.filter((v) => v.flag !== ''); if (filteredArr.length === 0) { return ''; @@ -863,20 +836,15 @@ function createFlagLabelSVG(arr) { /** * SVG를 Data URI로 변환 - * @param {string} svgStr - SVG 문자열 - * @returns {string} Data URI */ -function svgToDataURI(svgStr) { +function svgToDataURI(svgStr: string): string { return `data:image/svg+xml;charset=utf8,${encodeURIComponent(svgStr)}`; } /** * 캐시된 신호 플래그 SVG 가져오기 - * @param {Object} ship - 선박 데이터 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {string} SVG 문자열 */ -function getCachedFlagSVG(ship, isIntegrate) { +function getCachedFlagSVG(ship: ShipFeature, isIntegrate: boolean): string { const { key, flagArray } = buildFlagStateArray(ship, isIntegrate); let svg = flagSvgCache.get(key); @@ -893,33 +861,27 @@ function getCachedFlagSVG(ship, isIntegrate) { /** * SVG 캐시 초기화 */ -export function clearFlagSvgCache() { +export function clearFlagSvgCache(): void { flagSvgCache.clear(); } /** * 신호상태 IconLayer 생성 (SVG 캐싱 사용) * 참조: mda-react-front/src/common/deck.ts (라인 757-771) - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @returns {IconLayer|null} Deck.gl IconLayer */ -export function createSignalStatusLayer(ships, zoom, isIntegrate) { +export function createSignalStatusLayer(ships: ShipFeature[], zoom: number, isIntegrate: boolean): IconLayer | null { if (zoom < 9) { return null; } // 통합모드: 동일 targetId의 모든 feature에서 장비 플래그 병합 - // 대표 feature(예: 레이더)에는 자기 장비 플래그만 있으므로, - // 같은 targetId를 공유하는 다른 feature(AIS 등)의 플래그를 합쳐야 함 - let mergedFlagsMap = null; + let mergedFlagsMap: Map> | null = null; if (isIntegrate) { mergedFlagsMap = buildMergedEquipmentFlags(); } // 신호 플래그 데이터 생성 (SVG 캐싱 적용) - const flagData = ships + const flagData: SignalFlagData[] = ships .map((ship) => { // 통합선박이면 병합된 장비 플래그 적용 let effectiveShip = ship; @@ -939,13 +901,13 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) { url: svg, }; }) - .filter((d) => d !== null); + .filter((d): d is SignalFlagData => d !== null); if (flagData.length === 0) { return null; } - return new IconLayer({ + return new IconLayer({ id: 'signal-status-layer', data: flagData, pickable: false, @@ -968,12 +930,9 @@ export function createSignalStatusLayer(ships, zoom, isIntegrate) { /** * 선박 라벨 텍스트 생성 - * @param {Object} ship - 선박 데이터 - * @param {Object} labelOptions - 라벨 옵션 - * @returns {string} 라벨 텍스트 */ -function buildLabelText(ship, labelOptions) { - const parts = []; +function buildLabelText(ship: ShipFeature, labelOptions: LabelOptions): string { + const parts: string[] = []; // 선박명 if (labelOptions.showShipName && ship.shipName) { @@ -994,17 +953,13 @@ function buildLabelText(ship, labelOptions) { /** * 선박명 TextLayer 생성 - * @param {Array} ships - 선박 데이터 배열 - * @param {number} zoom - 현재 줌 레벨 - * @param {Object} labelOptions - 라벨 옵션 - * @returns {TextLayer|null} Deck.gl TextLayer */ -export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: true }) { +export function createShipLabelLayer(ships: ShipFeature[], zoom: number, labelOptions: LabelOptions = { showShipName: true }): TextLayer | null { if (zoom < 9) { return null; } - const labelData = ships + const labelData: LabelData[] = ships .map((ship) => ({ ...ship, labelText: buildLabelText(ship, labelOptions), @@ -1020,7 +975,7 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: // 테마 기반 색상 const themeColors = getCurrentThemeColors(); - return new TextLayer({ + return new TextLayer({ id: 'ship-label-layer', data: labelData, pickable: false, @@ -1043,13 +998,11 @@ export function createShipLabelLayer(ships, zoom, labelOptions = { showShipName: /** * 선택된 선박 하이라이트 레이어 (다중 선박 지원) - * @param {Array} selectedShips - 선택된 선박 데이터 배열 - * @returns {ScatterplotLayer|null} Deck.gl 레이어 */ -export function createSelectedShipLayer(selectedShips) { +export function createSelectedShipLayer(selectedShips: ShipFeature[] | null): ScatterplotLayer | null { if (!selectedShips || selectedShips.length === 0) return null; - return new ScatterplotLayer({ + return new ScatterplotLayer({ id: 'selected-ship-layer', data: selectedShips, pickable: false, @@ -1070,10 +1023,8 @@ export function createSelectedShipLayer(selectedShips) { * 추적 선박 레이어 (최상단 표시) * 선박 모드에서 추적 중인 함정을 특별 아이콘으로 표시 * ScatterplotLayer로 원형 마커 + IconLayer로 아이콘 표시 - * @param {number} zoom - 현재 줌 레벨 - * @returns {Array} Deck.gl 레이어 배열 */ -export function createTrackedShipLayers(zoom) { +export function createTrackedShipLayers(_zoom: number): Layer[] { const { mode, trackedShip } = useTrackingModeStore.getState(); // 선박 모드가 아니거나 추적 중인 함정이 없으면 빈 배열 @@ -1081,13 +1032,13 @@ export function createTrackedShipLayers(zoom) { return []; } - const layers = []; + const layers: Layer[] = []; // 위치 기반 업데이트 트리거 (좌표 변경 시 레이어 갱신) const positionKey = `${trackedShip.longitude.toFixed(6)}_${trackedShip.latitude.toFixed(6)}`; // 1. 외곽 원형 마커 (강조 효과) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-outer-ring', data: [trackedShip], pickable: false, @@ -1107,7 +1058,7 @@ export function createTrackedShipLayers(zoom) { })); // 2. 내부 원형 마커 (반투명) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-inner-circle', data: [trackedShip], pickable: false, @@ -1125,7 +1076,7 @@ export function createTrackedShipLayers(zoom) { })); // 3. 중심점 (흰색) - layers.push(new ScatterplotLayer({ + layers.push(new ScatterplotLayer({ id: 'tracked-ship-center-dot', data: [trackedShip], pickable: true, @@ -1147,18 +1098,15 @@ export function createTrackedShipLayers(zoom) { /** * 관심선박 매칭 데이터 추출 - * @param {Array} ships - 밀도 제한된 선박 배열 - * @param {boolean} isIntegrate - 통합모드 여부 - * @returns {Array} 관심선박 배열 */ -function getFavoriteShips(ships, isIntegrate) { +function getFavoriteShips(ships: ShipFeature[], isIntegrate: boolean): ShipFeature[] { const { isFavoriteEnabled, favoriteSet } = useFavoriteStore.getState(); if (!isFavoriteEnabled || favoriteSet.size === 0) return []; // 통합모드: 관심선박이 속한 통합그룹의 targetId Set - let favoriteTargetIds = null; + let favoriteTargetIds: Set | null = null; if (isIntegrate) { - favoriteTargetIds = new Set(); + favoriteTargetIds = new Set(); const { features } = useShipStore.getState(); for (const ship of features.values()) { const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; @@ -1170,7 +1118,7 @@ function getFavoriteShips(ships, isIntegrate) { return ships.filter((ship) => { if (isIntegrate) { - return favoriteTargetIds.has(ship.targetId) && (!ship.integrate || ship.isPriority); + return favoriteTargetIds!.has(ship.targetId) && (!ship.integrate || ship.isPriority); } const favKey = `${ship.signalSourceCode}_${ship.originalTargetId}`; return favoriteSet.has(favKey); @@ -1180,17 +1128,13 @@ function getFavoriteShips(ships, isIntegrate) { /** * 관심선박 강조 레이어 생성 (배경원 + 아이콘) * 참조: mda-react-front/src/common/targetLayer.ts - deckFavoriteLayer - * - * @param {Array} ships - 밀도 제한된 선박 배열 - * @param {boolean} isIntegrate - 통합모드 여부 - * @returns {Array} [ScatterplotLayer, IconLayer] 또는 빈 배열 */ -function createFavoriteHighlightLayers(ships, isIntegrate) { +function createFavoriteHighlightLayers(ships: ShipFeature[], isIntegrate: boolean): Layer[] { const favoriteShips = getFavoriteShips(ships, isIntegrate); if (favoriteShips.length === 0) return []; // 배경 원 (반투명 노란색 — 선박 아이콘 뒤에서 강조) - const bgLayer = new ScatterplotLayer({ + const bgLayer = new ScatterplotLayer({ id: 'favorite-bg-layer', data: favoriteShips, pickable: false, @@ -1208,7 +1152,7 @@ function createFavoriteHighlightLayers(ships, isIntegrate) { }); // 별+선박 아이콘 (우상단 오프셋) - const iconLayer = new IconLayer({ + const iconLayer = new IconLayer({ id: 'favorite-icon-layer', data: favoriteShips, pickable: false, @@ -1238,18 +1182,18 @@ function createFavoriteHighlightLayers(ships, isIntegrate) { * 2. 아이콘 레이어 생성 (밀도 제한된 ships 사용) * 3. 라벨 클러스터링 (밀도 제한된 ships 대상) → 라벨 표시 대상 결정 * 4. 라벨/신호상태 레이어 생성 - * - * @param {Array} ships - 선박 데이터 (밀도 제한 적용됨, 아이콘 + 라벨 공통) - * @param {Object|null} selectedShip - 선택된 선박 - * @param {number} zoom - 현재 줌 레벨 - * @param {boolean} showLabels - 선명표시 여부 - * @param {Object} labelOptions - 선명표시 옵션 - * @param {boolean} isIntegrate - 선박통합 모드 여부 - * @param {number} renderTrigger - 렌더링 트리거 (배치 렌더러에서 증가) - * @returns {Array} Deck.gl 레이어 배열 */ -export function createShipLayers(ships, selectedShips, zoom, showLabels = false, labelOptions = { showShipName: true }, isIntegrate = true, renderTrigger = 0, darkSignalIds = null) { - const layers = []; +export function createShipLayers( + ships: ShipFeature[], + selectedShips: ShipFeature[] | null, + zoom: number, + showLabels: boolean = false, + labelOptions: LabelOptions = { showShipName: true }, + isIntegrate: boolean = true, + renderTrigger: number = 0, + darkSignalIds: Set | null = null, +): Layer[] { + const layers: Layer[] = []; // 1. 선택된 선박 하이라이트 (다중) const selectedLayer = createSelectedShipLayer(selectedShips); diff --git a/src/map/layers/trackLayer.js b/src/map/layers/trackLayer.ts similarity index 72% rename from src/map/layers/trackLayer.js rename to src/map/layers/trackLayer.ts index af408e1f..6d698375 100644 --- a/src/map/layers/trackLayer.js +++ b/src/map/layers/trackLayer.ts @@ -11,14 +11,68 @@ * - TextLayer: 선명 라벨 */ import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; +import type { Layer, PickingInfo } from '@deck.gl/core'; import { getShipKindTrackColor } from '../../stores/trackStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; import { ICON_ATLAS_MAPPING, ICON_MAPPING_KIND_MOVING, } from '../../types/constants'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; import atlasImg from '../../assets/img/icon/atlas.png'; +/** PathLayer 데이터 */ +interface PathData { + path: number[][]; + color: number[]; + vesselId: string; +} + +/** ScatterplotLayer 포인트 데이터 */ +interface PointData { + position: number[]; + color: number[]; +} + +/** 가상선박 현재 위치 데이터 */ +interface CurrentPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipName: string; + shipKindCode: string; +} + +/** 레이어 ID 오버라이드 */ +interface LayerIds { + path?: string; + point?: string; + icon?: string; + label?: string; +} + +/** 정적 항적 레이어 파라미터 */ +interface StaticTrackLayerParams { + tracks: ProcessedTrack[]; + showPoints: boolean; + highlightedVesselId?: string | null; + highlightedVesselIds?: Set | null; + onPathHover?: (vesselId: string | null) => void; + layerIds?: LayerIds; +} + +/** 동적 가상선박 레이어 파라미터 */ +interface VirtualShipLayerParams { + currentPositions: CurrentPosition[]; + showVirtualShip: boolean; + showLabels: boolean; + onIconHover?: (obj: CurrentPosition | null, x: number, y: number) => void; + onPathHover?: (vesselId: string | null) => void; + layerIds?: LayerIds; +} + /** 현재 테마 색상 가져오기 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -33,24 +87,17 @@ const MAX_POINTS_PER_TRACK = 800; * 정적 항적 레이어 생성 * tracks, showPoints, disabledVesselIds가 변경될 때만 호출 * currentTime과 무관 - 전체 항적 데이터를 항상 표시 - * - * @param {Object} params - * @param {Array} params.tracks - 항적 데이터 배열 - * @param {boolean} params.showPoints - 포인트 표시 여부 - * @param {string} [params.highlightedVesselId] - 하이라이트할 선박 ID (단일) - * @param {Set} [params.highlightedVesselIds] - 하이라이트할 선박 ID 집합 (복수) - * @param {Function} [params.onPathHover] - 항적 호버 콜백 */ -export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, highlightedVesselIds, onPathHover, layerIds }) { - const layers = []; +export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselId, highlightedVesselIds, onPathHover, layerIds }: StaticTrackLayerParams): Layer[] { + const layers: Layer[] = []; if (!tracks || tracks.length === 0) return layers; const pathId = layerIds?.path || 'track-path-layer'; const pointId = layerIds?.point || 'track-point-layer'; - const isHighlighted = (vesselId) => - highlightedVesselIds?.has(vesselId) || - (highlightedVesselId && highlightedVesselId === vesselId); + const isHighlighted = (vesselId: string): boolean => + (highlightedVesselIds?.has(vesselId) ?? false) || + (!!highlightedVesselId && highlightedVesselId === vesselId); // Set을 직렬화하여 Deck.gl updateTriggers가 변경을 감지하도록 const highlightKey = highlightedVesselIds @@ -58,17 +105,19 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI : null; // 1. PathLayer - 전체 경로 (시간 무관) - const pathData = tracks.map((track) => ({ + const pathData: PathData[] = tracks.map((track) => ({ path: track.geometry, color: getShipKindTrackColor(track.shipKindCode), vesselId: track.vesselId, })); layers.push( - new PathLayer({ + new PathLayer({ id: pathId, data: pathData, + // @ts-expect-error Deck.gl runtime accepts number[][] for PathGeometry getPath: (d) => d.path, + // @ts-expect-error Deck.gl runtime accepts number[] for Color getColor: (d) => isHighlighted(d.vesselId) ? [255, 255, 0, 255] : d.color, getWidth: (d) => isHighlighted(d.vesselId) ? 4 : 2, widthUnits: 'pixels', @@ -79,7 +128,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI pickable: true, autoHighlight: true, highlightColor: [255, 255, 0, 220], - onHover: (info) => { + onHover: (info: PickingInfo) => { if (onPathHover) { onPathHover(info.object?.vesselId ?? null); } @@ -93,7 +142,7 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI // 2. ScatterplotLayer - 포인트 (간인 적용) if (showPoints) { - const pointData = []; + const pointData: PointData[] = []; tracks.forEach((track) => { const color = getShipKindTrackColor(track.shipKindCode); @@ -118,11 +167,11 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI }); layers.push( - new ScatterplotLayer({ + new ScatterplotLayer({ id: pointId, data: pointData, - getPosition: (d) => d.position, - getFillColor: (d) => d.color, + getPosition: (d) => d.position as [number, number], + getFillColor: (d) => d.color as [number, number, number, number], getRadius: 3, radiusUnits: 'pixels', radiusMinPixels: 2, @@ -138,17 +187,9 @@ export function createStaticTrackLayers({ tracks, showPoints, highlightedVesselI /** * 동적 가상선박 레이어 생성 * currentTime 변경 시마다 호출 (경량 - 데이터 수 = 선박 수) - * - * @param {Object} params - * @param {Array} params.currentPositions - 보간된 현재 위치 배열 - * @param {boolean} params.showVirtualShip - 아이콘 표시 - * @param {boolean} params.showLabels - 선명 라벨 표시 - * @param {Function} [params.onIconHover] - 아이콘 호버 콜백 - * @param {Function} [params.onPathHover] - 하이라이트 설정용 콜백 - * @returns {Array} Deck.gl Layer 배열 */ -export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }) { - const layers = []; +export function createVirtualShipLayers({ currentPositions, showVirtualShip, showLabels, onIconHover, onPathHover, layerIds }: VirtualShipLayerParams): Layer[] { + const layers: Layer[] = []; if (!currentPositions || currentPositions.length === 0) return layers; @@ -158,7 +199,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho // 1. IconLayer - 가상 선박 아이콘 if (showVirtualShip) { layers.push( - new IconLayer({ + new IconLayer({ id: iconId, data: currentPositions, iconAtlas: atlasImg, @@ -169,7 +210,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho sizeUnits: 'pixels', getAngle: (d) => -(d.heading || 0), pickable: true, - onHover: (info) => { + onHover: (info: PickingInfo) => { if (info.object) { // 하이라이트 설정 if (onPathHover) { @@ -200,7 +241,7 @@ export function createVirtualShipLayers({ currentPositions, showVirtualShip, sho const themeColors = getCurrentThemeColors(); layers.push( - new TextLayer({ + new TextLayer({ id: labelId, data: labelData, getPosition: (d) => [d.lon, d.lat], diff --git a/src/map/measure/measure.js b/src/map/measure/measure.js deleted file mode 100644 index 8c6ebae1..00000000 --- a/src/map/measure/measure.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * 측정 도구 핵심 로직 - * - MeasureSession: OL 객체 생명주기 관리 - * - 거리/면적/거리환 설정 함수 - * - 포맷 유틸리티 - * - * 참조: mda-react-front/src/components/nav/rightNav/measure.ts - */ -import VectorSource from 'ol/source/Vector'; -import VectorLayer from 'ol/layer/Vector'; -import { Draw } from 'ol/interaction'; -import { Overlay } from 'ol'; -import { createBox } from 'ol/interaction/Draw'; -import { unByKey } from 'ol/Observable'; -import { getArea, getLength } from 'ol/sphere'; -import { LineString } from 'ol/geom'; - -// ===================== -// MeasureSession 클래스 -// ===================== - -/** - * 측정 세션: 생성한 OL 객체(레이어, 인터랙션, 오버레이, 리스너)를 - * 직접 추적하고, dispose() 한 번으로 일괄 정리. - */ -export class MeasureSession { - constructor(map) { - this.map = map; - this._layer = null; - this._interactions = []; - this._overlays = []; - this._listeners = []; - } - - /** VectorLayer 생성+등록, source 반환 */ - createLayer() { - const source = new VectorSource({ wrapX: false }); - this._layer = new VectorLayer({ source, zIndex: 54 }); - this.map.addLayer(this._layer); - return source; - } - - /** Draw 인터랙션 등록+추적 */ - addInteraction(draw) { - this.map.addInteraction(draw); - this._interactions.push(draw); - return draw; - } - - /** 측정 툴팁 Overlay 생성+등록+추적 */ - createTooltip() { - const el = document.createElement('div'); - el.className = 'ol-tooltip ol-tooltip-measure'; - const overlay = new Overlay({ - element: el, - offset: [0, -15], - positioning: 'bottom-center', - }); - this.map.addOverlay(overlay); - this._overlays.push(overlay); - return overlay; - } - - /** 리스너 키 추적 (dispose 시 일괄 해제) */ - addListener(key) { - if (key) this._listeners.push(key); - return key; - } - - /** 모든 추적 객체 일괄 제거 */ - dispose() { - this._listeners.forEach((key) => unByKey(key)); - this._listeners = []; - - this._interactions.forEach((i) => this.map.removeInteraction(i)); - this._interactions = []; - - this._overlays.forEach((o) => this.map.removeOverlay(o)); - this._overlays = []; - - if (this._layer) { - this.map.removeLayer(this._layer); - this._layer = null; - } - } -} - -// ===================== -// 포맷 유틸리티 -// ===================== - -/** - * 거리 포맷: NM (km) - * @param {number} meters - * @returns {string} e.g. "5.2 NM (9.63 km)" - */ -export function formatDistance(meters) { - const nm = ((meters / 1000) * 0.5399568035).toFixed(1); - let sub; - if (meters > 1000) { - sub = (Math.round((meters / 1000) * 100) / 100) + ' km'; - } else { - sub = (Math.round(meters * 100) / 100) + ' m'; - } - return `${nm} NM (${sub})`; -} - -/** - * 면적 포맷: km² 또는 m² - * @param {number} sqMeters - * @returns {string} - */ -export function formatArea(sqMeters) { - if (sqMeters > 10000) { - return (Math.round((sqMeters / 1000000) * 100) / 100) + ' km\u00B2'; - } - return (Math.round(sqMeters * 100) / 100) + ' m\u00B2'; -} - -/** - * 각도 계산 (북쪽 기준 시계방향) - * @param {number[]} start - [x, y] 맵 좌표 - * @param {number[]} end - [x, y] 맵 좌표 - * @param {number} [cog=0] - 선박 COG (도) - * @returns {string} 각도 (0-360, 소수점 1자리) - */ -export function getCircleDegree(start, end, cog = 0) { - const x = Number(end[0]) - Number(start[0]); - const y = Number(end[1]) - Number(start[1]); - - const radian = Math.atan2(y, x) * (180 / Math.PI); - let angle = 360 - (radian - 90); - angle = (angle - cog) % 360; - if (angle < 0) angle += 360; - - return angle.toFixed(1); -} - -/** - * 선분별 거리 툴팁 관리자 - * 좌표 배열이 변경될 때마다 선분 개수에 맞춰 툴팁을 생성/업데이트/제거 - */ -class SegmentTooltips { - constructor(session) { - this.session = session; - this.tooltips = []; // Overlay 배열 - } - - /** - * 좌표 배열을 받아 각 선분 중점에 거리 툴팁 배치 - * @param {Array} coords - 좌표 배열 - */ - update(coords) { - const segCount = coords.length - 1; - - // 부족하면 툴팁 추가 생성 - while (this.tooltips.length < segCount) { - const el = document.createElement('div'); - el.className = 'ol-tooltip ol-tooltip-segment'; - const overlay = new Overlay({ - element: el, - offset: [0, -10], - positioning: 'bottom-center', - }); - this.session.map.addOverlay(overlay); - this.session._overlays.push(overlay); - this.tooltips.push(overlay); - } - - // 남으면 숨기기 - for (let i = segCount; i < this.tooltips.length; i++) { - this.tooltips[i].setPosition(undefined); - } - - // 각 선분 거리 계산 및 표시 - for (let i = 0; i < segCount; i++) { - const segLine = new LineString([coords[i], coords[i + 1]]); - const length = getLength(segLine); - const mid = [ - (coords[i][0] + coords[i + 1][0]) / 2, - (coords[i][1] + coords[i + 1][1]) / 2, - ]; - this.tooltips[i].getElement().innerHTML = formatDistance(length); - this.tooltips[i].setPosition(mid); - } - } - - /** 모든 선분 툴팁을 static 스타일로 고정 */ - finalize() { - this.tooltips.forEach((overlay) => { - const el = overlay.getElement(); - if (el && overlay.getPosition()) { - el.className = 'ol-tooltip ol-tooltip-segment-static'; - } - }); - } -} - -// ===================== -// 도구 설정 함수 -// ===================== - -/** - * 거리 측정 설정 (LineString) - * @param {MeasureSession} session - * @param {VectorSource} source - */ -export function setupDistanceMeasure(session, source) { - const draw = new Draw({ source, type: 'LineString' }); - session.addInteraction(draw); - - let currentTooltip = null; - let segTooltips = null; - - draw.on('drawstart', (evt) => { - const tooltip = session.createTooltip(); - currentTooltip = tooltip; - segTooltips = new SegmentTooltips(session); - const geom = evt.feature.getGeometry(); - - const key = geom.on('change', (e) => { - const coords = e.target.getCoordinates(); - const length = getLength(e.target); - tooltip.getElement().innerHTML = formatDistance(length); - tooltip.setPosition(e.target.getLastCoordinate()); - - // 선분별 거리 표시 (2개 이상 좌표일 때) - if (coords.length >= 2) { - segTooltips.update(coords); - } - }); - session.addListener(key); - }); - - draw.on('drawend', () => { - if (currentTooltip) { - const el = currentTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; - currentTooltip.setOffset([0, -7]); - } - if (segTooltips) { - segTooltips.finalize(); - } - }); -} - -/** - * 면적 측정 설정 (Polygon / Box / Circle) - * @param {MeasureSession} session - * @param {VectorSource} source - * @param {'Polygon'|'Box'|'Circle'} shape - */ -export function setupAreaMeasure(session, source, shape) { - // 메인 Draw 생성 - let draw; - if (shape === 'Box') { - draw = new Draw({ source, type: 'Circle', geometryFunction: createBox() }); - } else if (shape === 'Circle') { - draw = new Draw({ source, type: 'Circle' }); - } else { - draw = new Draw({ source, type: 'Polygon' }); - } - session.addInteraction(draw); - - // Circle인 경우 반경 표시용 Line Draw 추가 - let lineDraw = null; - let lineTooltip = null; - if (shape === 'Circle') { - lineTooltip = session.createTooltip(); - lineDraw = new Draw({ source, type: 'LineString' }); - session.addInteraction(lineDraw); - - lineDraw.on('drawstart', (evt) => { - session.map.addOverlay(lineTooltip); - const geom = evt.feature.getGeometry(); - - const key = geom.on('change', (e) => { - const length = getLength(e.target); - const area = length * length * Math.PI; - lineTooltip.getElement().innerHTML = formatArea(area); - lineTooltip.setPosition(e.target.getFirstCoordinate()); - }); - session.addListener(key); - }); - } - - let currentTooltip = null; - let segTooltips = null; - - draw.on('drawstart', (evt) => { - if (shape === 'Polygon' || shape === 'Box') { - currentTooltip = session.createTooltip(); - segTooltips = new SegmentTooltips(session); - } - - const geom = evt.feature.getGeometry(); - const key = geom.on('change', (e) => { - if (shape === 'Polygon' || shape === 'Box') { - const areaValue = getArea(e.target); - currentTooltip.getElement().innerHTML = formatArea(areaValue); - currentTooltip.setPosition(e.target.getInteriorPoint().getCoordinates()); - - // 선분별 거리 표시 - const coords = e.target.getCoordinates()[0]; // 외부 링 - if (coords && coords.length >= 2) { - segTooltips.update(coords); - } - } - }); - session.addListener(key); - }); - - draw.on('drawend', () => { - if (shape === 'Polygon' || shape === 'Box') { - if (currentTooltip) { - const el = currentTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; - currentTooltip.setOffset([0, -7]); - } - if (segTooltips) { - segTooltips.finalize(); - } - } - if (shape === 'Circle' && lineDraw) { - lineDraw.finishDrawing(); - } - }); -} - -/** - * 거리환 측정 설정 (Circle + Line 이중 Draw) - * 참조: mda-react-front measure.ts getCircleMeasureInteraction - * - * @param {MeasureSession} session - * @param {VectorSource} source - */ -export function setupRangeRingMeasure(session, source) { - // Line Draw (반경 거리 표시) - const lineTooltip = session.createTooltip(); - const lineDraw = new Draw({ source, type: 'LineString' }); - - lineDraw.on('drawstart', (evt) => { - session.map.addOverlay(lineTooltip); - const geom = evt.feature.getGeometry(); - - const key = geom.on('change', (e) => { - const length = getLength(e.target); - lineTooltip.getElement().innerHTML = formatDistance(length); - lineTooltip.setPosition(e.target.getLastCoordinate()); - }); - session.addListener(key); - }); - - // Circle Draw (각도 표시) - const circleDraw = new Draw({ source, type: 'Circle' }); - let circleTooltip = null; - let degree = '0.0'; - - circleDraw.on('drawstart', (evt) => { - circleTooltip = session.createTooltip(); - - const geom = evt.feature.getGeometry(); - const key = geom.on('change', () => { - // sketchCoords_: [center, edge] — OL Draw 내부 좌표 - const coords = evt.target.sketchCoords_; - if (coords && coords[0] && coords[1]) { - degree = getCircleDegree(coords[0], coords[1]); - } - circleTooltip.getElement().innerHTML = `각도: ${degree}°`; - circleTooltip.setPosition(geom.getCenter()); - }); - session.addListener(key); - }); - - circleDraw.on('drawend', () => { - lineDraw.finishDrawing(); - - if (circleTooltip) { - const el = circleTooltip.getElement(); - el.className = 'ol-tooltip ol-tooltip-static'; - // 최종 툴팁: 거리 + 각도 - el.innerHTML = lineTooltip.getElement().innerHTML + ` 각도: ${degree}°`; - circleTooltip.setOffset([0, -7]); - } - - session.map.removeOverlay(lineTooltip); - }); - - // circle → line 순서로 인터랙션 등록 (OL 이벤트 처리 순서) - session.addInteraction(circleDraw); - session.addInteraction(lineDraw); -} diff --git a/src/map/measure/measure.scss b/src/map/measure/measure.scss index f6c280ee..2f687593 100644 --- a/src/map/measure/measure.scss +++ b/src/map/measure/measure.scss @@ -1,8 +1,10 @@ /* 측정 툴팁 스타일 */ -/* 참조: mda-react-front/src/map/control.css */ -.ol-tooltip { - position: relative; +/* MapLibre Marker wrapper(.maplibregl-marker)가 width 미지정이므로 + 자식 element에 display: inline-block + width: max-content로 크기 제한 */ +.measure-tooltip { + display: inline-block; + width: max-content; padding: 0.4rem 0.8rem; border-radius: 0.4rem; font-size: 1.2rem; @@ -10,21 +12,21 @@ pointer-events: none; } -.ol-tooltip-measure { +.measure-tooltip-active { background: rgba(255, 237, 169, 0.85); color: #333; font-weight: 600; border: 0.1rem solid rgba(200, 180, 100, 0.5); } -.ol-tooltip-static { +.measure-tooltip-static { background: rgba(255, 237, 169, 0.85); color: #333; font-weight: 600; border: 0.1rem solid rgba(200, 180, 100, 0.5); } -.ol-tooltip-segment { +.measure-tooltip-segment { background: rgba(255, 255, 255, 0.8); color: #555; font-size: 1.1rem; @@ -32,7 +34,7 @@ border: 0.1rem solid rgba(180, 180, 180, 0.6); } -.ol-tooltip-segment-static { +.measure-tooltip-segment-static { background: rgba(255, 255, 255, 0.75); color: #666; font-size: 1.1rem; diff --git a/src/map/measure/measure.ts b/src/map/measure/measure.ts new file mode 100644 index 00000000..7af44d3d --- /dev/null +++ b/src/map/measure/measure.ts @@ -0,0 +1,798 @@ +/** + * 측정 도구 핵심 로직 (MapLibre GL JS) + * - MeasureSession: MapLibre 객체 생명주기 관리 + * - 거리/면적/거리환 설정 함수 + * - 포맷 유틸리티 + * + * 참조: mda-react-front/src/components/nav/rightNav/measure.ts + */ +import maplibregl from 'maplibre-gl'; +import type { GeoJSONSource, MapMouseEvent } from 'maplibre-gl'; +import * as turf from '@turf/turf'; +import type { Feature, FeatureCollection, LineString, Polygon } from 'geojson'; + +/** 면적 측정 도형 타입 */ +export type AreaMeasureShape = 'Polygon' | 'Box' | 'Circle'; + +/** MapLibre source/layer ID */ +const MEASURE_SOURCE_ID = 'measure-source'; +const MEASURE_FILL_LAYER_ID = 'measure-fill-layer'; +const MEASURE_LINE_LAYER_ID = 'measure-line-layer'; + +const ALL_MEASURE_LAYER_IDS = [ + MEASURE_LINE_LAYER_ID, + MEASURE_FILL_LAYER_ID, +] as const; + +const EMPTY_FC: FeatureCollection = { type: 'FeatureCollection', features: [] }; + +/** 이벤트 핸들러 추적 정보 */ +interface TrackedHandler { + event: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 이벤트 핸들러 범용 타입 + handler: (...args: any[]) => void; + target: 'map' | 'document'; +} + +// ===================== +// MeasureSession 클래스 +// ===================== + +/** + * 측정 세션: MapLibre source/layer, Marker 툴팁, 이벤트 핸들러를 + * 추적하고 dispose() 한 번으로 일괄 정리. + */ +export class MeasureSession { + map: maplibregl.Map; + _markers: maplibregl.Marker[]; + _handlers: TrackedHandler[]; + _currentData: FeatureCollection; + _completedFeatures: Feature[]; + _prevCursor: string; + + constructor(map: maplibregl.Map) { + this.map = map; + this._markers = []; + this._handlers = []; + this._currentData = EMPTY_FC; + this._completedFeatures = []; + this._prevCursor = map.getCanvas().style.cursor; + + // 커서 변경 + map.getCanvas().style.cursor = 'crosshair'; + + // source/layer 생성 + this.ensureLayers(); + + // style.load 복구 + this._onStyleLoad = this._onStyleLoad.bind(this); + map.on('style.load', this._onStyleLoad); + } + + /** source/layer 생성 (멱등, style.load 복구용) */ + ensureLayers(): void { + const map = this.map; + + if (!map.getSource(MEASURE_SOURCE_ID)) { + map.addSource(MEASURE_SOURCE_ID, { type: 'geojson', data: EMPTY_FC }); + } + + if (!map.getLayer(MEASURE_FILL_LAYER_ID)) { + map.addLayer({ + id: MEASURE_FILL_LAYER_ID, + type: 'fill', + source: MEASURE_SOURCE_ID, + filter: ['==', '$type', 'Polygon'], + paint: { + 'fill-color': 'rgba(255, 237, 169, 0.3)', + }, + }); + } + + if (!map.getLayer(MEASURE_LINE_LAYER_ID)) { + map.addLayer({ + id: MEASURE_LINE_LAYER_ID, + type: 'line', + source: MEASURE_SOURCE_ID, + paint: { + 'line-color': '#f0c040', + 'line-width': 2, + }, + }); + } + } + + /** style.load 복구 핸들러 */ + _onStyleLoad(): void { + this.ensureLayers(); + const source = this.map.getSource(MEASURE_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(this._currentData); + } + } + + /** 확정된 피처를 누적 저장 */ + completeFeatures(features: Feature[]): void { + this._completedFeatures.push(...features); + } + + /** GeoJSON 데이터 갱신 (누적 확정 피처 + 현재 드로잉 피처) */ + setData(fc: FeatureCollection): void { + const merged: FeatureCollection = { + type: 'FeatureCollection', + features: [...this._completedFeatures, ...fc.features], + }; + this._currentData = merged; + const source = this.map.getSource(MEASURE_SOURCE_ID) as GeoJSONSource | undefined; + if (source) { + source.setData(merged); + } + } + + /** 툴팁 Marker 생성 */ + createTooltipMarker(lngLat: [number, number], html: string, className: string): maplibregl.Marker { + const el = document.createElement('div'); + el.className = className; + el.innerHTML = html; + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -10] }) + .setLngLat(lngLat) + .addTo(this.map); + this._markers.push(marker); + return marker; + } + + /** 활성 마커를 제거하고 동일 위치에 static 마커를 새로 생성 (확정용) */ + replaceWithStatic(marker: maplibregl.Marker, html: string, className: string): maplibregl.Marker { + const lngLat = marker.getLngLat(); + marker.remove(); + this._markers = this._markers.filter((m) => m !== marker); + return this.createTooltipMarker([lngLat.lng, lngLat.lat], html, className); + } + + /** 맵 이벤트 등록 + 추적 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- MapLibre 이벤트 핸들러 범용 + onMap(event: string, handler: (e: any) => void): void { + this.map.on(event as keyof maplibregl.MapEventType, handler); + this._handlers.push({ event, handler, target: 'map' }); + } + + /** document 이벤트 등록 + 추적 */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- DOM 이벤트 핸들러 범용 + onDocument(event: string, handler: (e: any) => void): void { + document.addEventListener(event, handler); + this._handlers.push({ event, handler, target: 'document' }); + } + + /** 이벤트 핸들러만 제거 + 진행 중 드로잉 데이터 초기화 (세션·레이어·확정 마커 유지) */ + clearHandlers(): void { + this._handlers.forEach(({ event, handler, target }) => { + if (target === 'map') { + this.map.off(event as keyof maplibregl.MapEventType, handler); + } else { + document.removeEventListener(event, handler); + } + }); + this._handlers = []; + // 진행 중 드로잉 피처 제거 (확정 피처만 남김) + this.setData(EMPTY_FC); + } + + /** 확정되지 않은 (active) 마커만 제거 — static 클래스가 없는 마커 */ + removeActiveMarkers(): void { + this._markers = this._markers.filter((m) => { + const cls = m.getElement().className; + if (cls.includes('-static')) return true; + m.remove(); + return false; + }); + } + + /** 모든 추적 객체 일괄 제거 */ + dispose(): void { + this.clearHandlers(); + + // 모든 Marker 제거 (확정 static 포함) + this._markers.forEach((m) => m.remove()); + this._markers = []; + + // style.load 해제 + this.map.off('style.load', this._onStyleLoad); + + // 레이어/소스 제거 + ALL_MEASURE_LAYER_IDS.forEach((id) => { + if (this.map.getLayer(id)) this.map.removeLayer(id); + }); + if (this.map.getSource(MEASURE_SOURCE_ID)) { + this.map.removeSource(MEASURE_SOURCE_ID); + } + + // 누적 피처 초기화 + this._completedFeatures = []; + + // 커서 복원 + this.map.getCanvas().style.cursor = this._prevCursor; + } +} + +// ===================== +// 포맷 유틸리티 +// ===================== + +/** 거리 포맷: NM (km) */ +export function formatDistance(meters: number): string { + const nm = ((meters / 1000) * 0.5399568035).toFixed(1); + let sub: string; + if (meters > 1000) { + sub = (Math.round((meters / 1000) * 100) / 100) + ' km'; + } else { + sub = (Math.round(meters * 100) / 100) + ' m'; + } + return `${nm} NM (${sub})`; +} + +/** 면적 포맷: km² 또는 m² */ +export function formatArea(sqMeters: number): string { + if (sqMeters > 10000) { + return (Math.round((sqMeters / 1000000) * 100) / 100) + ' km\u00B2'; + } + return (Math.round(sqMeters * 100) / 100) + ' m\u00B2'; +} + +/** 방위각 계산 (북쪽 기준 0-360°) */ +function calculateBearing(center: [number, number], edge: [number, number]): string { + let bearing = turf.bearing(turf.point(center), turf.point(edge)); + if (bearing < 0) bearing += 360; + return bearing.toFixed(1); +} + +// ===================== +// GeoJSON 빌더 +// ===================== + +/** 좌표 배열 → LineString GeoJSON */ +function buildLineFC(coords: [number, number][]): FeatureCollection { + const features: Feature[] = []; + if (coords.length >= 2) { + features.push(turf.lineString([...coords]) as Feature); + } + return { type: 'FeatureCollection', features }; +} + +/** 좌표 배열 → 닫힌 Polygon GeoJSON */ +function buildPolygonFC(coords: [number, number][]): FeatureCollection { + const features: Feature[] = []; + if (coords.length >= 3) { + const closed = [...coords, coords[0]]; + features.push(turf.polygon([closed]) as Feature); + } else if (coords.length >= 2) { + features.push(turf.lineString([...coords]) as Feature); + } + return { type: 'FeatureCollection', features }; +} + +/** 두 코너 → 사각형 Polygon GeoJSON */ +function buildBoxFC(corner1: [number, number], corner2: [number, number]): FeatureCollection { + const minLon = Math.min(corner1[0], corner2[0]); + const maxLon = Math.max(corner1[0], corner2[0]); + const minLat = Math.min(corner1[1], corner2[1]); + const maxLat = Math.max(corner1[1], corner2[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + return { type: 'FeatureCollection', features: [poly as Feature] }; +} + +/** 중심 + 반경 → 원 + 반경선 GeoJSON */ +function buildCircleFC(center: [number, number], edge: [number, number]): FeatureCollection { + const radiusKm = turf.distance(turf.point(center), turf.point(edge), { units: 'kilometers' }); + const circle = turf.circle(center, radiusKm, { steps: 64, units: 'kilometers' }); + const line = turf.lineString([center, edge]); + return { type: 'FeatureCollection', features: [circle as Feature, line as Feature] }; +} + +// ===================== +// SegmentTooltips +// ===================== + +/** 선분별 거리 툴팁 관리 (Marker 기반) */ +class SegmentTooltips { + session: MeasureSession; + markers: maplibregl.Marker[]; + + constructor(session: MeasureSession) { + this.session = session; + this.markers = []; + } + + /** 좌표 배열을 받아 각 선분 중점에 거리 Marker 배치 */ + update(coords: [number, number][]): void { + const segCount = coords.length - 1; + + // 남으면 숨기기 + for (let i = segCount; i < this.markers.length; i++) { + this.markers[i].getElement().style.display = 'none'; + } + + // 각 선분 처리 (부족하면 해당 위치에 Marker 즉시 생성) + for (let i = 0; i < segCount; i++) { + const dist = turf.distance(turf.point(coords[i]), turf.point(coords[i + 1]), { units: 'meters' }); + const midCoord: [number, number] = [ + (coords[i][0] + coords[i + 1][0]) / 2, + (coords[i][1] + coords[i + 1][1]) / 2, + ]; + + if (i >= this.markers.length) { + const el = document.createElement('div'); + el.className = 'measure-tooltip measure-tooltip-segment'; + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -6] }) + .setLngLat(midCoord) + .addTo(this.session.map); + this.session._markers.push(marker); + this.markers.push(marker); + } else { + this.markers[i].setLngLat(midCoord); + this.markers[i].getElement().style.display = ''; + } + + this.markers[i].getElement().innerHTML = formatDistance(dist); + } + } + + /** 드로잉 마커를 모두 제거하고 확정 위치에 static 마커를 새로 생성 */ + finalize(finalCoords: [number, number][]): void { + // 기존 드로잉 마커 제거 + const markerSet = new Set(this.markers); + this.markers.forEach((m) => m.remove()); + this.session._markers = this.session._markers.filter((m) => !markerSet.has(m)); + this.markers = []; + + // 확정 마커 새로 생성 (static 클래스 + 내용 포함) + const segCount = finalCoords.length - 1; + for (let i = 0; i < segCount; i++) { + const dist = turf.distance(turf.point(finalCoords[i]), turf.point(finalCoords[i + 1]), { units: 'meters' }); + const midCoord: [number, number] = [ + (finalCoords[i][0] + finalCoords[i + 1][0]) / 2, + (finalCoords[i][1] + finalCoords[i + 1][1]) / 2, + ]; + const el = document.createElement('div'); + el.className = 'measure-tooltip measure-tooltip-segment-static'; + el.innerHTML = formatDistance(dist); + const marker = new maplibregl.Marker({ element: el, anchor: 'bottom', offset: [0, -6] }) + .setLngLat(midCoord) + .addTo(this.session.map); + this.session._markers.push(marker); + } + } +} + +// ===================== +// 도구 설정 함수 +// ===================== + +/** + * 거리 측정 설정 (LineString) + */ +export function setupDistanceMeasure(session: MeasureSession): void { + const points: [number, number][] = []; + let mousePos: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + let segTooltips: SegmentTooltips | null = null; + let isFinalized = false; + + const updateGeometry = () => { + const allCoords = [...points]; + if (mousePos && !isFinalized) allCoords.push(mousePos); + session.setData(buildLineFC(allCoords)); + + // 총 거리 계산 + if (allCoords.length >= 2) { + const line = turf.lineString(allCoords); + const totalMeters = turf.length(line, { units: 'meters' }); + const lastCoord = allCoords[allCoords.length - 1]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(lastCoord, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(lastCoord); + mainTooltip.getElement().innerHTML = formatDistance(totalMeters); + } + }; + + session.onMap('click', (e: MapMouseEvent) => { + if (isFinalized) return; + points.push([e.lngLat.lng, e.lngLat.lat]); + + if (!segTooltips) { + segTooltips = new SegmentTooltips(session); + } + if (points.length >= 2) { + segTooltips.update(points); + } + updateGeometry(); + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (isFinalized) return; + mousePos = [e.lngLat.lng, e.lngLat.lat]; + if (points.length >= 1) { + updateGeometry(); + } + }); + + session.onMap('dblclick', (e: MapMouseEvent) => { + if (isFinalized) return; + e.preventDefault(); + isFinalized = true; + + // dblclick 전 click이 2번 발생해서 중복 포인트 제거 (픽셀 근접 판정) + if (points.length >= 2) { + points.pop(); + if (points.length >= 2) { + const lastPx = session.map.project(points[points.length - 1]); + const prevPx = session.map.project(points[points.length - 2]); + const dx = lastPx.x - prevPx.x; + const dy = lastPx.y - prevPx.y; + if (dx * dx + dy * dy < 25) points.pop(); + } + } + + mousePos = null; + updateGeometry(); + + // 툴팁: 활성 마커 → static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); + } + if (segTooltips) { + segTooltips.finalize(points); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildLineFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; + }); + + // 우클릭: 해당 위치까지 그리고 확정 + session.onMap('contextmenu', (e: MapMouseEvent) => { + if (isFinalized || points.length < 1) return; + e.preventDefault(); + + points.push([e.lngLat.lng, e.lngLat.lat]); + isFinalized = true; + mousePos = null; + updateGeometry(); + + if (!segTooltips && points.length >= 2) { + segTooltips = new SegmentTooltips(session); + } + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); + } + if (segTooltips) { + segTooltips.finalize(points); + } + + session.completeFeatures(buildLineFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; + }); +} + +/** + * 면적 측정 설정 (Polygon / Box / Circle) + */ +export function setupAreaMeasure(session: MeasureSession, shape: AreaMeasureShape): void { + if (shape === 'Box') { + setupBoxMeasure(session); + } else if (shape === 'Circle') { + setupCircleMeasure(session); + } else { + setupPolygonMeasure(session); + } +} + +/** 첫 점 근처 클릭 시 폴리곤 닫기 판정 픽셀 임계값 */ +const SNAP_CLOSE_PX = 10; + +/** 다각형 면적 측정 */ +function setupPolygonMeasure(session: MeasureSession): void { + const points: [number, number][] = []; + let mousePos: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + let segTooltips: SegmentTooltips | null = null; + let isFinalized = false; + + /** 첫 점과의 픽셀 거리가 SNAP_CLOSE_PX 이내인지 */ + const isNearFirstPoint = (lngLat: [number, number]): boolean => { + if (points.length < 3) return false; + const firstPx = session.map.project(points[0]); + const curPx = session.map.project(lngLat); + const dx = firstPx.x - curPx.x; + const dy = firstPx.y - curPx.y; + return Math.sqrt(dx * dx + dy * dy) <= SNAP_CLOSE_PX; + }; + + /** 폴리곤 확정 */ + const finalize = () => { + isFinalized = true; + mousePos = null; + updateGeometry(); + + if (mainTooltip) { + session.replaceWithStatic( + mainTooltip, + mainTooltip.getElement().innerHTML, + 'measure-tooltip measure-tooltip-static', + ); + } + if (segTooltips) { + const closed = [...points, points[0]]; + segTooltips.finalize(closed); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildPolygonFC(points).features); + points.length = 0; + mainTooltip = null; + segTooltips = null; + isFinalized = false; + }; + + const updateGeometry = () => { + const allCoords = [...points]; + if (mousePos && !isFinalized) allCoords.push(mousePos); + session.setData(buildPolygonFC(allCoords)); + + // 면적 계산 (3개 이상일 때) + if (allCoords.length >= 3) { + const closed = [...allCoords, allCoords[0]]; + const poly = turf.polygon([closed]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(centCoord, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(centCoord); + mainTooltip.getElement().innerHTML = formatArea(areaM2); + } + }; + + session.onMap('click', (e: MapMouseEvent) => { + if (isFinalized) return; + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + // 첫 점 근처 클릭 → 폴리곤 닫기 + if (isNearFirstPoint(coord)) { + finalize(); + return; + } + + points.push(coord); + + if (!segTooltips) { + segTooltips = new SegmentTooltips(session); + } + if (points.length >= 2) { + segTooltips.update(points); + } + updateGeometry(); + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (isFinalized) return; + mousePos = [e.lngLat.lng, e.lngLat.lat]; + if (points.length >= 1) { + updateGeometry(); + } + }); + + session.onMap('dblclick', (e: MapMouseEvent) => { + if (isFinalized) return; + e.preventDefault(); + + // dblclick 전 click 2번으로 인한 중복 제거 (픽셀 근접 판정) + if (points.length >= 2) { + points.pop(); + if (points.length >= 2) { + const lastPx = session.map.project(points[points.length - 1]); + const prevPx = session.map.project(points[points.length - 2]); + const dx = lastPx.x - prevPx.x; + const dy = lastPx.y - prevPx.y; + if (dx * dx + dy * dy < 25) points.pop(); + } + } + + finalize(); + }); + + // 우클릭: 해당 위치를 마지막 점으로 추가 후 폴리곤 확정 + session.onMap('contextmenu', (e: MapMouseEvent) => { + if (isFinalized || points.length < 2) return; + e.preventDefault(); + points.push([e.lngLat.lng, e.lngLat.lat]); + finalize(); + }); +} + +/** 사각형 면적 측정 (2-클릭: 대각선 꼭짓점 2개) */ +function setupBoxMeasure(session: MeasureSession): void { + let corner1: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + let segTooltips: SegmentTooltips | null = null; + + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + if (!corner1) { + corner1 = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildBoxFC(corner1, coord)); + + const minLon = Math.min(corner1[0], coord[0]); + const maxLon = Math.max(corner1[0], coord[0]); + const minLat = Math.min(corner1[1], coord[1]); + const maxLat = Math.max(corner1[1], coord[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + const ring = poly.geometry.coordinates[0] as [number, number][]; + + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, formatArea(areaM2), 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(centCoord, formatArea(areaM2), 'measure-tooltip measure-tooltip-static'); + } + if (!segTooltips) { + segTooltips = new SegmentTooltips(session); + } + segTooltips.finalize(ring); + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildBoxFC(corner1, coord).features); + corner1 = null; + mainTooltip = null; + segTooltips = null; + } + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!corner1) return; + const corner2: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + session.setData(buildBoxFC(corner1, corner2)); + + const minLon = Math.min(corner1[0], corner2[0]); + const maxLon = Math.max(corner1[0], corner2[0]); + const minLat = Math.min(corner1[1], corner2[1]); + const maxLat = Math.max(corner1[1], corner2[1]); + const poly = turf.bboxPolygon([minLon, minLat, maxLon, maxLat]); + const areaM2 = turf.area(poly); + const centroid = turf.centroid(poly); + const centCoord = centroid.geometry.coordinates as [number, number]; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(centCoord, '', 'measure-tooltip measure-tooltip-active'); + segTooltips = new SegmentTooltips(session); + } + mainTooltip.setLngLat(centCoord); + mainTooltip.getElement().innerHTML = formatArea(areaM2); + + const ring = poly.geometry.coordinates[0] as [number, number][]; + segTooltips!.update(ring); + }); +} + +/** 원 면적 측정 (클릭 중심 → 클릭 반경) */ +function setupCircleMeasure(session: MeasureSession): void { + let center: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + if (!center) { + center = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildCircleFC(center, coord)); + + const radiusM = turf.distance(turf.point(center), turf.point(coord), { units: 'meters' }); + const areaM2 = Math.PI * radiusM * radiusM; + + const html = `${formatArea(areaM2)}
        ${formatDistance(radiusM)} (반경)`; + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, html, 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(center, html, 'measure-tooltip measure-tooltip-static'); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildCircleFC(center, coord).features); + center = null; + mainTooltip = null; + } + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!center) return; + const edge: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + session.setData(buildCircleFC(center, edge)); + + const radiusM = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' }); + const areaM2 = Math.PI * radiusM * radiusM; + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(center, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(center); + mainTooltip.getElement().innerHTML = `${formatArea(areaM2)}
        ${formatDistance(radiusM)} (반경)`; + }); +} + +/** + * 거리환 측정 설정 (Circle + Line + 방위각) + */ +export function setupRangeRingMeasure(session: MeasureSession): void { + let center: [number, number] | null = null; + let mainTooltip: maplibregl.Marker | null = null; + + session.onMap('click', (e: MapMouseEvent) => { + const coord: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + + if (!center) { + center = coord; + } else { + // 두 번째 클릭 → 확정 + session.setData(buildCircleFC(center, coord)); + + const distM = turf.distance(turf.point(center), turf.point(coord), { units: 'meters' }); + const bearing = calculateBearing(center, coord); + + const html = `${formatDistance(distM)} 각도: ${bearing}°`; + // 활성 마커 제거 후 static 마커로 교체 + if (mainTooltip) { + session.replaceWithStatic(mainTooltip, html, 'measure-tooltip measure-tooltip-static'); + } else { + session.createTooltipMarker(center, html, 'measure-tooltip measure-tooltip-static'); + } + + // 확정 피처 누적 후 새 드로잉 준비 + session.completeFeatures(buildCircleFC(center, coord).features); + center = null; + mainTooltip = null; + } + }); + + session.onMap('mousemove', (e: MapMouseEvent) => { + if (!center) return; + const edge: [number, number] = [e.lngLat.lng, e.lngLat.lat]; + session.setData(buildCircleFC(center, edge)); + + const distM = turf.distance(turf.point(center), turf.point(edge), { units: 'meters' }); + const bearing = calculateBearing(center, edge); + + if (!mainTooltip) { + mainTooltip = session.createTooltipMarker(center, '', 'measure-tooltip measure-tooltip-active'); + } + mainTooltip.setLngLat(center); + mainTooltip.getElement().innerHTML = `${formatDistance(distM)} 각도: ${bearing}°`; + }); +} diff --git a/src/map/measure/useMeasure.js b/src/map/measure/useMeasure.js deleted file mode 100644 index 197812cc..00000000 --- a/src/map/measure/useMeasure.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 측정 도구 React 훅 - * - Zustand 상태(activeMeasureTool, areaShape) ↔ OL 인터랙션 연결 - * - ESC 키로 측정 취소 - * - 도구 전환 시 이전 세션 자동 정리 - */ -import { useEffect, useRef } from 'react'; -import { useMapStore } from '../../stores/mapStore'; -import { - MeasureSession, - setupDistanceMeasure, - setupAreaMeasure, - setupRangeRingMeasure, -} from './measure'; - -export default function useMeasure() { - const map = useMapStore((s) => s.map); - const activeTool = useMapStore((s) => s.activeMeasureTool); - const areaShape = useMapStore((s) => s.areaShape); - const clearMeasure = useMapStore((s) => s.clearMeasure); - const sessionRef = useRef(null); - - // ESC 키로 측정 취소 - useEffect(() => { - const handleKeyDown = (e) => { - if (e.key === 'Escape' && activeTool) { - clearMeasure(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [activeTool, clearMeasure]); - - // 도구 활성화/비활성화 - useEffect(() => { - // 이전 세션 정리 - if (sessionRef.current) { - sessionRef.current.dispose(); - sessionRef.current = null; - } - - if (!map || !activeTool) return; - - // 면적 도구: 도형 선택 전까지 대기 - if (activeTool === 'area' && !areaShape) return; - - const session = new MeasureSession(map); - const source = session.createLayer(); - sessionRef.current = session; - - switch (activeTool) { - case 'distance': - setupDistanceMeasure(session, source); - break; - case 'area': - setupAreaMeasure(session, source, areaShape); - break; - case 'rangeRing': - setupRangeRingMeasure(session, source); - break; - default: - break; - } - - return () => { - if (sessionRef.current) { - sessionRef.current.dispose(); - sessionRef.current = null; - } - }; - }, [map, activeTool, areaShape]); -} diff --git a/src/map/measure/useMeasure.ts b/src/map/measure/useMeasure.ts new file mode 100644 index 00000000..80c80cda --- /dev/null +++ b/src/map/measure/useMeasure.ts @@ -0,0 +1,93 @@ +/** + * 측정 도구 React 훅 + * - Zustand 상태(activeMeasureTool, areaShape) ↔ MapLibre 측정 세션 연결 + * - ESC 키로 측정 취소 + * - 도구 전환 시 세션 유지 (핸들러만 교체), ESC/초기화 시 전체 정리 + */ +import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import { useMapStore } from '../../stores/mapStore'; +import { + MeasureSession, + setupDistanceMeasure, + setupAreaMeasure, + setupRangeRingMeasure, + type AreaMeasureShape, +} from './measure'; + +export default function useMeasure(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- 마이그레이션 기간 mapStore.map: any + const map = useMapStore((s) => s.map); + const activeTool = useMapStore((s) => s.activeMeasureTool); + const areaShape = useMapStore((s) => s.areaShape); + const clearMeasure = useMapStore((s) => s.clearMeasure); + const sessionRef = useRef(null); + + // ESC 키로 측정 취소 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent): void => { + if (e.key === 'Escape' && activeTool) { + clearMeasure(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [activeTool, clearMeasure]); + + // 컴포넌트 언마운트 시 세션 정리 + useEffect(() => { + return () => { + if (sessionRef.current) { + sessionRef.current.dispose(); + sessionRef.current = null; + } + }; + }, []); + + // 도구 활성화 / 전환 / 비활성화 + useEffect(() => { + // 이전 도구의 핸들러 + 진행 중 마커 제거 (세션·확정 마커 유지) + if (sessionRef.current) { + sessionRef.current.clearHandlers(); + sessionRef.current.removeActiveMarkers(); + } + + // 비활성화 (ESC / 초기화) → 세션 전체 dispose + if (!activeTool) { + if (sessionRef.current) { + sessionRef.current.dispose(); + sessionRef.current = null; + } + return; + } + + if (!map || typeof map.getCanvas !== 'function') return; + + // 면적 도구: 도형 선택 전까지 대기 + if (activeTool === 'area' && !areaShape) return; + + // 세션 생성 또는 재사용 + if (!sessionRef.current) { + sessionRef.current = new MeasureSession(map as maplibregl.Map); + } else { + (map as maplibregl.Map).getCanvas().style.cursor = 'crosshair'; + } + + const session = sessionRef.current; + + switch (activeTool) { + case 'distance': + setupDistanceMeasure(session); + break; + case 'area': + setupAreaMeasure(session, areaShape as AreaMeasureShape); + break; + case 'rangeRing': + setupRangeRingMeasure(session); + break; + default: + break; + } + // cleanup 없음 — 다음 실행 시작부에서 clearHandlers로 처리 + }, [map, activeTool, areaShape]); +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.tsx similarity index 100% rename from src/pages/HomePage.jsx rename to src/pages/HomePage.tsx diff --git a/src/pages/ReplayPage.jsx b/src/pages/ReplayPage.tsx similarity index 89% rename from src/pages/ReplayPage.jsx rename to src/pages/ReplayPage.tsx index f32ce75b..e9744572 100644 --- a/src/pages/ReplayPage.jsx +++ b/src/pages/ReplayPage.tsx @@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import './ReplayPage.scss'; import { getReplayWebSocketService } from '../replay/services/ReplayWebSocketService'; import useReplayStore from '../replay/stores/replayStore'; -import useMergedTrackStore from '../replay/stores/mergedTrackStore'; import useAnimationStore from '../replay/stores/animationStore'; import { ConnectionState, VesselState } from '../replay/types/replay.types'; +import type { VesselStateType } from '../replay/types/replay.types'; import VesselListManager from '../replay/components/VesselListManager'; import ReplayControlV2 from '../replay/components/ReplayControlV2'; import { TRACK_QUERY_MAX_DAYS, TRACK_QUERY_DEFAULT_DAYS } from '../types/constants'; @@ -14,16 +14,21 @@ import { showToast } from '../components/common/Toast'; const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** KST 기준 로컬 ISO 문자열 생성 (toISOString()은 UTC이므로 사용하지 않음) */ -function toKstISOString(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toKstISOString(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } +interface ReplayPageProps { + isOpen: boolean; + onToggle: () => void; +} + /** * 리플레이 페이지 * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx */ -export default function ReplayPage({ isOpen, onToggle }) { +export default function ReplayPage({ isOpen, onToggle }: ReplayPageProps) { // 조회 기간 const [startDate, setStartDate] = useState(''); const [startTime, setStartTime] = useState('00:00'); @@ -39,19 +44,17 @@ export default function ReplayPage({ isOpen, onToggle }) { const queryCompleted = useReplayStore((s) => s.queryCompleted); const progress = useReplayStore((s) => s.progress); const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId); - const deletedVesselIds = useReplayStore((s) => s.deletedVesselIds); - const selectedVesselIds = useReplayStore((s) => s.selectedVesselIds); const setTimeRange = useAnimationStore((s) => s.setTimeRange); /** * 선박 상태 전환 핸들러 - * DELETE: 일반/선택 → 삭제, 삭제 → 일반 - * INSERT: 일반 → 선택, 삭제 → 선택, 선택 → 일반 + * DELETE: 일반/선택 -> 삭제, 삭제 -> 일반 + * INSERT: 일반 -> 선택, 삭제 -> 선택, 선택 -> 일반 */ const handleVesselStateTransition = useCallback( - (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => { - let targetState; + (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => { + let targetState: VesselStateType; if (action === 'DELETE') { if (isCurrentlyDeleted) { @@ -77,7 +80,7 @@ export default function ReplayPage({ isOpen, onToggle }) { // 키보드 이벤트 리스너 (Delete/Insert 키로 항적 상태 변경) useEffect(() => { - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { if (!highlightedVesselId) return; const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState(); @@ -103,7 +106,7 @@ export default function ReplayPage({ isOpen, onToggle }) { const defaultDaysAgo = new Date(now); defaultDaysAgo.setDate(defaultDaysAgo.getDate() - TRACK_QUERY_DEFAULT_DAYS); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); setStartDate(defaultDaysAgo.toISOString().split('T')[0]); setStartTime('00:00'); setEndDate(now.toISOString().split('T')[0]); @@ -111,16 +114,16 @@ export default function ReplayPage({ isOpen, onToggle }) { }, []); // 시작일 변경 핸들러 - const handleStartDateChange = useCallback((newStartDate) => { + const handleStartDateChange = useCallback((newStartDate: string) => { setStartDate(newStartDate); // 현재 종료일/시간과 비교 const start = new Date(`${newStartDate}T${startTime}:00`); const end = new Date(`${endDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); - // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 + // 시작일이 종료일보다 뒤인 경우 -> 종료일을 시작일 + 기본조회기간으로 조정 if (diffDays < 0) { const adjustedEnd = new Date(start.getTime() + TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); setEndDate(adjustedEnd.toISOString().split('T')[0]); @@ -137,16 +140,16 @@ export default function ReplayPage({ isOpen, onToggle }) { }, [startTime, endDate, endTime]); // 종료일 변경 핸들러 - const handleEndDateChange = useCallback((newEndDate) => { + const handleEndDateChange = useCallback((newEndDate: string) => { setEndDate(newEndDate); // 현재 시작일/시간과 비교 const start = new Date(`${startDate}T${startTime}:00`); const end = new Date(`${newEndDate}T${endTime}:00`); - const diffDays = (end - start) / DAYS_TO_MS; - const pad = (n) => String(n).padStart(2, '0'); + const diffDays = (end.getTime() - start.getTime()) / DAYS_TO_MS; + const pad = (n: number) => String(n).padStart(2, '0'); - // 종료일이 시작일보다 앞인 경우 → 시작일을 종료일 - 기본조회기간으로 조정 + // 종료일이 시작일보다 앞인 경우 -> 시작일을 종료일 - 기본조회기간으로 조정 if (diffDays < 0) { const adjustedStart = new Date(end.getTime() - TRACK_QUERY_DEFAULT_DAYS * DAYS_TO_MS); setStartDate(adjustedStart.toISOString().split('T')[0]); @@ -223,7 +226,7 @@ export default function ReplayPage({ isOpen, onToggle }) { } catch (error) { console.error('[ReplayPage] 쿼리 실패:', error); setIsQuerying(false); - setErrorMessage(`조회 실패: ${error.message}`); + setErrorMessage(`조회 실패: ${(error as Error).message}`); } }, [startDate, startTime, endDate, endTime, setTimeRange]); diff --git a/src/replay/components/ReplayControlV2.jsx b/src/replay/components/ReplayControlV2.tsx similarity index 95% rename from src/replay/components/ReplayControlV2.jsx rename to src/replay/components/ReplayControlV2.tsx index cc53f845..7a05c8bc 100644 --- a/src/replay/components/ReplayControlV2.jsx +++ b/src/replay/components/ReplayControlV2.tsx @@ -7,10 +7,11 @@ import { useState, useCallback } from 'react'; import useReplayStore from '../stores/replayStore'; import useMergedTrackStore from '../stores/mergedTrackStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; +import type { FilterModuleConfig } from '../types/replay.types'; import './ReplayControlV2.scss'; // 리플레이 필터 옵션 -const CUSTOM_FILTER_OPTIONS = [ +const CUSTOM_FILTER_OPTIONS: { key: keyof FilterModuleConfig; label: string }[] = [ { key: 'showNormal', label: '기본' }, { key: 'showSelected', label: '선택' }, { key: 'showDeleted', label: '삭제' }, @@ -50,19 +51,19 @@ const ReplayControlV2 = () => { const toggleShipKindCode = useReplayStore(state => state.toggleShipKindCode); // 커스텀 필터 토글 핸들러 - const handleCustomFilterToggle = (key) => { + const handleCustomFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.custom[key]; updateFilterModule('custom', { [key]: newValue }); }; // 항적 필터 토글 핸들러 - const handlePathFilterToggle = (key) => { + const handlePathFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.path[key]; updateFilterModule('path', { [key]: newValue }); }; // 라벨 필터 토글 핸들러 - const handleLabelFilterToggle = (key) => { + const handleLabelFilterToggle = (key: keyof FilterModuleConfig) => { const newValue = !filterModules.label[key]; updateFilterModule('label', { [key]: newValue }); }; @@ -77,7 +78,7 @@ const ReplayControlV2 = () => { }; // 카운트 표시 헬퍼 - const getCountLabel = (key) => { + const getCountLabel = (key: keyof FilterModuleConfig): string => { if (key === 'showNormal') return ` (${normalVesselCount})`; if (key === 'showSelected') return ` (${selectedVesselIds.size})`; if (key === 'showDeleted') return ` (${deletedVesselIds.size})`; diff --git a/src/replay/components/ReplayLoadingOverlay.jsx b/src/replay/components/ReplayLoadingOverlay.tsx similarity index 100% rename from src/replay/components/ReplayLoadingOverlay.jsx rename to src/replay/components/ReplayLoadingOverlay.tsx diff --git a/src/replay/components/ReplayTimeline.jsx b/src/replay/components/ReplayTimeline.tsx similarity index 90% rename from src/replay/components/ReplayTimeline.jsx rename to src/replay/components/ReplayTimeline.tsx index bc1bbe4a..76bdaf45 100644 --- a/src/replay/components/ReplayTimeline.jsx +++ b/src/replay/components/ReplayTimeline.tsx @@ -9,7 +9,7 @@ * - 드래그 가능한 헤더 * - 항적표시 토글 */ -import { useCallback, useEffect, useRef, useState, useMemo } from 'react'; +import { useCallback, useEffect, useRef, useState, useMemo, ChangeEvent } from 'react'; import useAnimationStore, { PlaybackState } from '../stores/animationStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; import './ReplayTimeline.scss'; @@ -20,7 +20,7 @@ const PLAYBACK_SPEED_OPTIONS = [1, 10, 50, 100, 500, 1000]; /** * 날짜 포맷팅 (YYYY-MM-DD HH:mm 형식) */ -function formatDateRange(dateStr) { +function formatDateRange(dateStr: string): string { if (!dateStr) return ''; try { const date = new Date(dateStr); @@ -36,19 +36,25 @@ function formatDateRange(dateStr) { } /** - * ms → 날짜시간 문자열 (YYYY-MM-DD HH:mm:ss) + * ms -> 날짜시간 문자열 (YYYY-MM-DD HH:mm:ss) */ -function formatDateTime(ms) { +function formatDateTime(ms: number): string { if (!ms || ms <= 0) return '--:--:--'; const d = new Date(ms); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } +interface ReplayTimelineProps { + fromDate?: string; + toDate?: string; + onClose?: () => void; +} + /** * 리플레이 타임라인 컨트롤 컴포넌트 */ -export default function ReplayTimeline({ fromDate, toDate, onClose }) { +export default function ReplayTimeline({ fromDate, toDate, onClose }: ReplayTimelineProps) { // animationStore 상태 const playbackState = useAnimationStore((s) => s.playbackState); const currentTime = useAnimationStore((s) => s.currentTime); @@ -73,15 +79,15 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 배속 드롭다운 상태 const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); - const sliderContainerRef = useRef(null); + const speedMenuRef = useRef(null); + const sliderContainerRef = useRef(null); // 드래그 상태 const [isDragging, setIsDragging] = useState(false); const [hasDragged, setHasDragged] = useState(false); const [position, setPosition] = useState({ x: 0, y: 0 }); const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); - const containerRef = useRef(null); + const containerRef = useRef(null); // 항적표시 상태 (playbackTrailStore와 동기화) const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled); @@ -89,8 +95,8 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 외부 클릭 시 드롭다운 닫기 useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -107,7 +113,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { // 드래그 핸들러 // CSS 센터링(left:50% + translateX(-50%))에서 절대좌표(left/top)로 전환하여 // transform 충돌로 인한 위치 이탈/가로스크롤 방지 - const handleMouseDown = useCallback((e) => { + const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const parent = containerRef.current.parentElement; @@ -126,7 +132,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, [hasDragged]); useEffect(() => { - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { if (!isDragging || !containerRef.current) return; const parent = containerRef.current.parentElement; if (!parent) return; @@ -170,7 +176,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, [stop]); // 배속 변경 - const handleSpeedChange = useCallback((speed) => { + const handleSpeedChange = useCallback((speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, [setPlaybackSpeed]); @@ -198,7 +204,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { }, []); // 슬라이더로 시간 변경 - const handleSliderChange = useCallback((e) => { + const handleSliderChange = useCallback((e: ChangeEvent) => { const newTime = parseFloat(e.target.value); seekTo(newTime); }, [seekTo]); @@ -270,7 +276,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { disabled={!hasData} title={isPlaying ? '일시정지' : '재생'} > - {isPlaying ? '❚❚' : '▶'} + {isPlaying ? '\u275A\u275A' : '\u25B6'} {/* 정지 버튼 */} @@ -281,7 +287,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { disabled={!hasData} title="정지" > - ■ + \u25A0 {/* 슬라이더 */} @@ -296,7 +302,7 @@ export default function ReplayTimeline({ fromDate, toDate, onClose }) { onPointerDown={handleSliderPointerDown} onChange={handleSliderChange} disabled={!hasData} - style={{ '--progress': `${progress}%` }} + style={{ '--progress': `${progress}%` } as React.CSSProperties} />
        diff --git a/src/replay/components/VesselListManager/VesselContextMenu.jsx b/src/replay/components/VesselListManager/VesselContextMenu.tsx similarity index 82% rename from src/replay/components/VesselListManager/VesselContextMenu.jsx rename to src/replay/components/VesselListManager/VesselContextMenu.tsx index 11cb3200..47db1581 100644 --- a/src/replay/components/VesselListManager/VesselContextMenu.jsx +++ b/src/replay/components/VesselListManager/VesselContextMenu.tsx @@ -2,24 +2,32 @@ * 선박 아이템 우클릭 컨텍스트 메뉴 컴포넌트 */ import React, { useCallback, useEffect } from 'react'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselContextMenu.scss'; +interface VesselContextMenuProps { + vessel: ClassifiedVesselItem; + position: { x: number; y: number }; + onClose: () => void; + onShowDetail: (vesselId: string) => void; +} + const VesselContextMenu = ({ vessel, position, onClose, onShowDetail, -}) => { +}: VesselContextMenuProps) => { // 메뉴 외부 클릭 시 닫기 useEffect(() => { - const handleClickOutside = (event) => { - const target = event.target; + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; if (!target.closest('.vessel-context-menu')) { onClose(); } }; - const handleEscKey = (event) => { + const handleEscKey = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose(); } diff --git a/src/replay/components/VesselListManager/VesselItem.jsx b/src/replay/components/VesselListManager/VesselItem.tsx similarity index 83% rename from src/replay/components/VesselListManager/VesselItem.jsx rename to src/replay/components/VesselListManager/VesselItem.tsx index d70718cf..dac53462 100644 --- a/src/replay/components/VesselListManager/VesselItem.jsx +++ b/src/replay/components/VesselListManager/VesselItem.tsx @@ -5,6 +5,8 @@ */ import React, { useCallback, useState } from 'react'; import { getCountryNameFromCode } from './utils/countryCodeUtils'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; +import type { VesselStateType } from '../../types/replay.types'; import './VesselItem.scss'; // 선종 아이콘 import (dark 프로젝트 assets) @@ -29,7 +31,7 @@ import { } from '../../../types/constants'; // 선종코드 → 아이콘 매핑 -const SHIP_KIND_ICONS = { +const SHIP_KIND_ICONS: Record = { [SIGNAL_KIND_CODE_FISHING]: fishingIcon, // 000020: 어선 [SIGNAL_KIND_CODE_KCGV]: kcgvIcon, // 000021: 함정 [SIGNAL_KIND_CODE_PASSENGER]: passengerIcon, // 000022: 여객선 @@ -41,7 +43,7 @@ const SHIP_KIND_ICONS = { }; // 선종 코드별 표시명 -const SHIP_KIND_NAMES = { +const SHIP_KIND_NAMES: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -53,7 +55,7 @@ const SHIP_KIND_NAMES = { }; // 신호원 코드별 표시명 -const SIGNAL_SOURCE_NAMES = { +const SIGNAL_SOURCE_NAMES: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', @@ -63,23 +65,35 @@ const SIGNAL_SOURCE_NAMES = { /** * 선종 아이콘 반환 */ -const getShipKindIcon = (shipKindCode) => { +const getShipKindIcon = (shipKindCode: string): string => { return SHIP_KIND_ICONS[shipKindCode] || etcIcon; }; /** * 국기 이미지 URL 생성 (서버 API) * 개발 환경에서는 Vite 프록시를 통해 API 서버로 전달됨 - * @param {string} nationalCode - MID 숫자코드 (예: '440', '412') - * @returns {string} 국기 이미지 URL */ -const getNationalFlagUrl = (nationalCode) => { +const getNationalFlagUrl = (nationalCode: string): string => { // 국적 코드가 없으면 기본값 '000' 사용 const code = nationalCode || '000'; // 상대 경로 사용 (Vite 프록시가 /ship/image를 API 서버로 전달) return `/ship/image/small/${code}.svg`; }; +interface VesselItemProps { + vessel: ClassifiedVesselItem; + index: number; + isDragDisabled?: boolean; + isSelected?: boolean; + onDragStart?: (vesselId: string, sourceState: VesselStateType) => void; + onDragEnd?: () => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; +} + const VesselItem = ({ vessel, index, @@ -90,11 +104,10 @@ const VesselItem = ({ onMouseEnterVessel, onMouseLeaveVessel, onToggleSelection, - onShowVesselDetail, onContextMenu, -}) => { - const [showTooltip, setShowTooltip] = useState(false); - const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); +}: VesselItemProps) => { + const [, setShowTooltip] = useState(false); + const [, setTooltipPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); // 아이콘 및 정보 @@ -106,7 +119,7 @@ const VesselItem = ({ // 마우스 호버 이벤트 핸들러 const handleMouseEnter = useCallback( - (event) => { + (event: React.MouseEvent) => { if (isDragDisabled || isDragging) return; const rect = event.currentTarget.getBoundingClientRect(); @@ -131,7 +144,7 @@ const VesselItem = ({ // HTML5 드래그앤드롭 이벤트 핸들러 const handleDragStart = useCallback( - (event) => { + (event: React.DragEvent) => { if (isDragDisabled) { event.preventDefault(); return; @@ -152,7 +165,7 @@ const VesselItem = ({ event.dataTransfer.effectAllowed = 'move'; // 커스텀 드래그 이미지 설정 (선택적) - const dragImage = event.currentTarget.cloneNode(true); + const dragImage = event.currentTarget.cloneNode(true) as HTMLElement; dragImage.style.transform = 'rotate(5deg)'; dragImage.style.opacity = '0.8'; event.dataTransfer.setDragImage(dragImage, 50, 25); @@ -163,7 +176,7 @@ const VesselItem = ({ ); const handleDragEnd = useCallback( - (event) => { + (_event: React.DragEvent) => { setIsDragging(false); onDragEnd?.(); }, @@ -172,7 +185,7 @@ const VesselItem = ({ // 체크박스 토글 핸들러 const handleToggleSelection = useCallback( - (event) => { + (event: React.MouseEvent) => { event.stopPropagation(); onToggleSelection?.(vessel.vesselId, !isSelected); }, @@ -181,7 +194,7 @@ const VesselItem = ({ // 우클릭 컨텍스트 메뉴 핸들러 const handleRightClick = useCallback( - (event) => { + (event: React.MouseEvent) => { event.preventDefault(); event.stopPropagation(); onContextMenu?.(vessel.vesselId, event); @@ -227,13 +240,13 @@ const VesselItem = ({ src={shipKindIcon} alt={shipKindName} className="ship-kind-icon" - onError={(e) => { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> {countryName} { e.target.style.display = 'none'; }} + onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }} /> ({signalSourceName})
        diff --git a/src/replay/components/VesselListManager/VesselListManager.jsx b/src/replay/components/VesselListManager/VesselListManager.tsx similarity index 86% rename from src/replay/components/VesselListManager/VesselListManager.jsx rename to src/replay/components/VesselListManager/VesselListManager.tsx index 79ff002e..a618b453 100644 --- a/src/replay/components/VesselListManager/VesselListManager.jsx +++ b/src/replay/components/VesselListManager/VesselListManager.tsx @@ -3,9 +3,11 @@ * HTML5 드래그앤드롭을 통한 선박 상태 전환 인터페이스 * dark 프로젝트 스타일 적용 */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import { useVesselClassification } from './hooks/useVesselClassification'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import { useVesselActions } from './hooks/useVesselActions'; import VesselListPanel from './VesselListPanel'; import VesselSearchFilter from './VesselSearchFilter'; @@ -13,20 +15,29 @@ import VesselContextMenu from './VesselContextMenu'; import useReplayStore from '../../stores/replayStore'; import './VesselListManager.scss'; -export const VesselListManager = ({ className = '' }) => { - const { vesselsByState, totalCount, hasVessels } = useVesselClassification(); +interface VesselListManagerProps { + className?: string; +} + +interface ContextMenuState { + vessel: ClassifiedVesselItem; + position: { x: number; y: number }; +} + +export const VesselListManager = ({ className = '' }: VesselListManagerProps) => { + const { vesselsByState, totalCount } = useVesselClassification(); const { handleDragDrop } = useVesselActions(); // 필터링 및 선택 상태 - const [filteredVessels, setFilteredVessels] = useState([]); - const [selectedVesselIds, setSelectedVesselIds] = useState(new Set()); + const [filteredVessels, setFilteredVessels] = useState([]); + const [selectedVesselIds, setSelectedVesselIds] = useState>(new Set()); // 패널 상태 (기본 열림) const [isOpen, setIsOpen] = useState(true); const [tap, setTap] = useState(1); // 컨텍스트 메뉴 상태 - const [contextMenu, setContextMenu] = useState(null); + const [contextMenu, setContextMenu] = useState(null); // 전체 선박 목록 (필터링 전) const allVessels = useMemo(() => { @@ -45,12 +56,12 @@ export const VesselListManager = ({ className = '' }) => { /** * 지도 상의 항적 하이라이트 핸들러 */ - const handleMouseEnterVessel = useCallback((vesselId) => { + const handleMouseEnterVessel = useCallback((vesselId: string) => { // replayStore의 하이라이트 시스템 사용 useReplayStore.getState().setHighlightedVesselId(vesselId); }, []); - const handleMouseLeaveVessel = useCallback((vesselId) => { + const handleMouseLeaveVessel = useCallback((_vesselId: string) => { // 하이라이트 제거 useReplayStore.getState().setHighlightedVesselId(null); }, []); @@ -59,7 +70,7 @@ export const VesselListManager = ({ className = '' }) => { * HTML5 드래그앤드롭 완료 핸들러 */ const onDrop = useCallback( - (vesselId, sourceState, targetState) => { + (vesselId: string, sourceState: VesselStateType, targetState: VesselStateType) => { // 드래그앤드롭 결과를 상태 전환으로 변환 handleDragDrop({ vesselId, @@ -73,7 +84,7 @@ export const VesselListManager = ({ className = '' }) => { /** * 선박 선택/해제 핸들러 */ - const handleToggleSelection = useCallback((vesselId, isSelected) => { + const handleToggleSelection = useCallback((vesselId: string, isSelected: boolean) => { setSelectedVesselIds(prev => { const newSet = new Set(prev); if (isSelected) { @@ -93,7 +104,7 @@ export const VesselListManager = ({ className = '' }) => { * 선박 상세보기 핸들러 (컨텍스트 메뉴에서 호출) */ const handleShowVesselDetail = useCallback( - (vesselId) => { + (vesselId: string) => { // 선박 정보 찾기 const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel) { @@ -108,7 +119,7 @@ export const VesselListManager = ({ className = '' }) => { * 컨텍스트 메뉴 열기 */ const handleContextMenu = useCallback( - (vesselId, event) => { + (vesselId: string, event: React.MouseEvent) => { const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel) { setContextMenu({ @@ -131,9 +142,9 @@ export const VesselListManager = ({ className = '' }) => { * 선박 일괄 상태 변경 핸들러 */ const handleBulkStateChange = useCallback( - (selectedVesselIds, targetState) => { + (bulkVesselIds: string[], targetState: VesselStateType) => { // 각 선박에 대해 상태 변경 실행 - selectedVesselIds.forEach(vesselId => { + bulkVesselIds.forEach(vesselId => { const vessel = allVessels.find(v => v.vesselId === vesselId); if (vessel && vessel.state !== targetState) { handleDragDrop({ @@ -150,7 +161,7 @@ export const VesselListManager = ({ className = '' }) => { /** * 패널별 전체선택/해제 핸들러 */ - const handleSelectAllInPanel = useCallback((vesselIds, isSelected) => { + const handleSelectAllInPanel = useCallback((vesselIds: string[], isSelected: boolean) => { setSelectedVesselIds(prev => { const newSet = new Set(prev); @@ -166,22 +177,6 @@ export const VesselListManager = ({ className = '' }) => { }); }, []); - // 탭 스타일 헬퍼 - const getTabStyle = (tabIndex) => ({ - flex: 1, - height: '100%', - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - fontSize: '1.2rem', - fontWeight: '600', - color: tap === tabIndex ? '#fff' : 'rgba(255, 255, 255, 0.6)', - backgroundColor: tap === tabIndex ? 'rgba(74, 158, 255, 0.3)' : 'transparent', - borderBottom: tap === tabIndex ? '2px solid #4a9eff' : '2px solid transparent', - cursor: 'pointer', - transition: 'all 0.2s ease', - }); - return (
        {/* 헤더 */} diff --git a/src/replay/components/VesselListManager/VesselListPanel.jsx b/src/replay/components/VesselListManager/VesselListPanel.tsx similarity index 73% rename from src/replay/components/VesselListManager/VesselListPanel.jsx rename to src/replay/components/VesselListManager/VesselListPanel.tsx index 53c4aa09..f8418bcd 100644 --- a/src/replay/components/VesselListManager/VesselListPanel.jsx +++ b/src/replay/components/VesselListManager/VesselListPanel.tsx @@ -2,14 +2,16 @@ * 개별 선박 목록 패널 컴포넌트 * HTML5 드래그앤드롭 API를 사용하여 상태별(일반/선택/삭제) 선박 목록을 드롭 영역으로 표시 */ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import VesselItem from './VesselItem'; import VirtualVesselList from './VirtualVesselList'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselListPanel.scss'; // 상태별 설정 -const STATE_CONFIG = { +const STATE_CONFIG: Record = { [VesselState.NORMAL]: { title: '기본 선박', color: '#28a745', @@ -27,11 +29,27 @@ const STATE_CONFIG = { }, }; +interface VesselListPanelProps { + state: VesselStateType; + vessels: ClassifiedVesselItem[]; + title?: string; + color?: string; + emptyMessage?: string; + selectedVesselIds?: Set; + onDrop?: (vesselId: string, sourceState: VesselStateType, targetState: VesselStateType) => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; + onSelectAllInPanel?: (vesselIds: string[], isSelected: boolean) => void; +} + const VesselListPanel = ({ state, vessels, - title, - color, + title: _title, + color: _color, emptyMessage, selectedVesselIds = new Set(), onDrop, @@ -40,29 +58,27 @@ const VesselListPanel = ({ onToggleSelection, onShowVesselDetail, onContextMenu, - onSelectAllInPanel, -}) => { + onSelectAllInPanel: _onSelectAllInPanel, +}: VesselListPanelProps) => { const [isDragOver, setIsDragOver] = useState(false); - const [dragData, setDragData] = useState(null); + const [, setDragData] = useState<{ vesselId: string; sourceState: VesselStateType } | null>(null); const config = STATE_CONFIG[state]; - const panelTitle = title || config.title; - const panelColor = color || config.color; const panelEmptyMessage = emptyMessage || config.emptyMessage; // HTML5 드롭 이벤트 핸들러 - const handleDragOver = useCallback((event) => { + const handleDragOver = useCallback((event: React.DragEvent) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; setIsDragOver(true); }, []); - const handleDragEnter = useCallback((event) => { + const handleDragEnter = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOver(true); }, []); - const handleDragLeave = useCallback((event) => { + const handleDragLeave = useCallback((event: React.DragEvent) => { // 실제로 컨테이너를 벗어날 때만 drag over 상태 해제 const rect = event.currentTarget.getBoundingClientRect(); const x = event.clientX; @@ -74,25 +90,28 @@ const VesselListPanel = ({ }, []); const handleDrop = useCallback( - (event) => { + (event: React.DragEvent) => { event.preventDefault(); setIsDragOver(false); try { - const data = JSON.parse(event.dataTransfer.getData('text/plain')); + const data = JSON.parse(event.dataTransfer.getData('text/plain')) as { + vesselId: string; + sourceState: VesselStateType; + }; const { vesselId, sourceState } = data; if (vesselId && sourceState && sourceState !== state) { onDrop?.(vesselId, sourceState, state); } - } catch (error) { + } catch { // 드롭 데이터 파싱 실패 시 무시 (외부 드래그 소스일 수 있음) } }, [state, onDrop], ); - const handleVesselDragStart = useCallback((vesselId, sourceState) => { + const handleVesselDragStart = useCallback((vesselId: string, sourceState: VesselStateType) => { setDragData({ vesselId, sourceState }); }, []); @@ -100,28 +119,6 @@ const VesselListPanel = ({ setDragData(null); }, []); - // 패널 내 선택 상태 계산 - const vesselIds = useMemo(() => vessels.map(v => v.vesselId), [vessels]); - const selectedInPanel = useMemo( - () => vesselIds.filter(id => selectedVesselIds.has(id)).length, - [vesselIds, selectedVesselIds], - ); - const isAllSelected = vesselIds.length > 0 && selectedInPanel === vesselIds.length; - const isPartiallySelected = selectedInPanel > 0 && selectedInPanel < vesselIds.length; - - // 패널별 전체선택/해제 - const handleSelectAllInPanel = useCallback( - (event) => { - event.preventDefault(); - event.stopPropagation(); - - if (!onSelectAllInPanel || vesselIds.length === 0) return; - - onSelectAllInPanel(vesselIds, !isAllSelected); - }, - [vesselIds, isAllSelected, onSelectAllInPanel], - ); - return (
        {/* 드롭 영역 */} diff --git a/src/replay/components/VesselListManager/VesselSearchFilter.jsx b/src/replay/components/VesselListManager/VesselSearchFilter.tsx similarity index 84% rename from src/replay/components/VesselListManager/VesselSearchFilter.jsx rename to src/replay/components/VesselListManager/VesselSearchFilter.tsx index ed1de482..68b287b5 100644 --- a/src/replay/components/VesselListManager/VesselSearchFilter.jsx +++ b/src/replay/components/VesselListManager/VesselSearchFilter.tsx @@ -5,11 +5,13 @@ */ import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { VesselState } from '../../types/replay.types'; +import type { VesselStateType } from '../../types/replay.types'; import { getSortedCountryOptions } from './utils/countryCodeUtils'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; import './VesselSearchFilter.scss'; // 선종 코드별 표시명 -const SHIP_KIND_NAMES = { +const SHIP_KIND_NAMES: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -20,17 +22,32 @@ const SHIP_KIND_NAMES = { '000028': '부이', }; +interface CountryGroupOption { + countryName: string; + codes: string[]; + displayName: string; + truncatedName: string; +} + +interface VesselSearchFilterProps { + vessels: ClassifiedVesselItem[]; + onFilteredVesselsChange: (filtered: ClassifiedVesselItem[]) => void; + onSelectedVesselsChange?: (selectedIds: Set) => void; + selectedVesselIds?: Set; + onBulkStateChange?: (vesselIds: string[], targetState: VesselStateType) => void; +} + export const VesselSearchFilter = ({ vessels, onFilteredVesselsChange, onSelectedVesselsChange, selectedVesselIds = new Set(), onBulkStateChange, -}) => { +}: VesselSearchFilterProps) => { const [searchText, setSearchText] = useState(''); const [selectedShipKind, setSelectedShipKind] = useState(''); - const [selectedCountryGroup, setSelectedCountryGroup] = useState(null); - const [selectedState, setSelectedState] = useState(''); + const [selectedCountryGroup, setSelectedCountryGroup] = useState(null); + const [selectedState, _setSelectedState] = useState(''); // 고유 선종 목록 추출 const availableShipKinds = useMemo(() => { @@ -88,36 +105,9 @@ export const VesselSearchFilter = ({ onFilteredVesselsChange(filteredVessels); }, [filteredVessels, onFilteredVesselsChange]); - /** - * 전체 선택/해제 토글 핸들러 - */ - const handleSelectAll = useCallback(() => { - const newSelectedIds = new Set(selectedVesselIds); - - if (filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId))) { - filteredVessels.forEach(vessel => { - newSelectedIds.delete(vessel.vesselId); - }); - } else { - filteredVessels.forEach(vessel => { - newSelectedIds.add(vessel.vesselId); - }); - } - - onSelectedVesselsChange?.(newSelectedIds); - }, [filteredVessels, selectedVesselIds, onSelectedVesselsChange]); - - // 필터 초기화 - const handleClearFilters = useCallback(() => { - setSearchText(''); - setSelectedShipKind(''); - setSelectedCountryGroup(null); - setSelectedState(''); - }, []); - // 선택된 선박들의 상태 일괄 변경 const handleBulkStateChange = useCallback( - (targetState) => { + (targetState: VesselStateType) => { const selectedIds = Array.from(selectedVesselIds); if (selectedIds.length > 0 && onBulkStateChange) { onBulkStateChange(selectedIds, targetState); @@ -127,10 +117,6 @@ export const VesselSearchFilter = ({ [selectedVesselIds, onBulkStateChange, onSelectedVesselsChange], ); - const isAllSelected = - filteredVessels.length > 0 && filteredVessels.every(vessel => selectedVesselIds.has(vessel.vesselId)); - const isPartiallySelected = filteredVessels.some(vessel => selectedVesselIds.has(vessel.vesselId)) && !isAllSelected; - return (
        diff --git a/src/replay/components/VesselListManager/VirtualVesselList.jsx b/src/replay/components/VesselListManager/VirtualVesselList.tsx similarity index 67% rename from src/replay/components/VesselListManager/VirtualVesselList.jsx rename to src/replay/components/VesselListManager/VirtualVesselList.tsx index 71222cfd..6252de3b 100644 --- a/src/replay/components/VesselListManager/VirtualVesselList.jsx +++ b/src/replay/components/VesselListManager/VirtualVesselList.tsx @@ -5,6 +5,22 @@ import React, { useMemo } from 'react'; import VesselItem from './VesselItem'; import { useVirtualScroll } from './hooks/useVirtualScroll'; +import type { ClassifiedVesselItem } from './hooks/useVesselClassification'; +import type { VesselStateType } from '../../types/replay.types'; + +interface VirtualVesselListProps { + vessels: ClassifiedVesselItem[]; + selectedVesselIds?: Set; + onDragStart?: (vesselId: string, sourceState: VesselStateType) => void; + onDragEnd?: () => void; + onMouseEnterVessel?: (vesselId: string) => void; + onMouseLeaveVessel?: (vesselId: string) => void; + onToggleSelection?: (vesselId: string, isSelected: boolean) => void; + onShowVesselDetail?: (vesselId: string) => void; + onContextMenu?: (vesselId: string, event: React.MouseEvent) => void; + containerHeight?: number; + itemHeight?: number; +} const VirtualVesselList = ({ vessels, @@ -18,10 +34,10 @@ const VirtualVesselList = ({ onContextMenu, containerHeight = 300, itemHeight = 40, // VesselItem의 대략적인 높이 -}) => { - const { containerRef, scrollElementRef, handleScroll, visibleItems, totalHeight, offsetY, startIndex, endIndex } = +}: VirtualVesselListProps) => { + const { scrollElementRef, handleScroll, visibleItems, totalHeight } = useVirtualScroll({ - items: vessels, + items: vessels as (ClassifiedVesselItem & Record)[], itemHeight, containerHeight, overscan: 5, @@ -31,7 +47,7 @@ const VirtualVesselList = ({ const renderedItems = useMemo(() => { return visibleItems.map((vessel) => (
        } className="virtual-vessel-list" style={{ height: containerHeight, diff --git a/src/replay/components/VesselListManager/hooks/useVesselActions.js b/src/replay/components/VesselListManager/hooks/useVesselActions.ts similarity index 78% rename from src/replay/components/VesselListManager/hooks/useVesselActions.js rename to src/replay/components/VesselListManager/hooks/useVesselActions.ts index ba130838..490173f1 100644 --- a/src/replay/components/VesselListManager/hooks/useVesselActions.js +++ b/src/replay/components/VesselListManager/hooks/useVesselActions.ts @@ -18,38 +18,35 @@ */ import { useCallback } from 'react'; import useReplayStore from '../../../stores/replayStore'; -import { VesselState } from '../../../types/replay.types'; +import { VesselState, VesselStateType } from '../../../types/replay.types'; + +interface DragDropResult { + vesselId: string; + sourceState: VesselStateType; + targetState: VesselStateType; +} + +interface UseVesselActionsResult { + handleDragDrop: (result: DragDropResult) => void; + setVesselState: (vesselId: string, targetState: VesselStateType) => void; + handleVesselStateTransition: (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => void; +} /** * 선박 액션 관리 훅 - * - * @returns {Object} 선박 상태 관리 함수들 - * @returns {Function} handleDragDrop - 드래그앤드롭 결과 처리 함수 - * @returns {Function} setVesselState - 선박 상태 직접 설정 함수 - * @returns {Function} handleVesselStateTransition - 상태 전환 로직 (디버깅용) - * - * @example - * const { handleDragDrop, setVesselState } = useVesselActions(); - * setVesselState('vessel_001', VesselState.SELECTED); */ -export const useVesselActions = () => { +export const useVesselActions = (): UseVesselActionsResult => { /** * 선박 상태 전환 로직 * * @description ReplayV2의 상태 전환 로직을 재현합니다. * 선박은 일반/선택/삭제 중 하나의 상태만 가질 수 있으며, 상태 간 전환은 DELETE/INSERT 액션으로 수행됩니다. * replayStore.setVesselState를 사용하여 Map/Set 동기화 보장 - * - * @param {string} vesselId - 선박 ID - * @param {'DELETE' | 'INSERT'} action - 수행할 액션 - * @param {boolean} isCurrentlyDeleted - 현재 삭제 상태 여부 - * @param {boolean} isCurrentlySelected - 현재 선택 상태 여부 - * @private */ const handleVesselStateTransition = useCallback( - (vesselId, action, isCurrentlyDeleted, isCurrentlySelected) => { + (vesselId: string, action: 'DELETE' | 'INSERT', isCurrentlyDeleted: boolean, isCurrentlySelected: boolean) => { // 상태 전환 로직 (상호 배타적) - let targetState; + let targetState: VesselStateType; if (action === 'DELETE') { if (isCurrentlyDeleted) { @@ -83,10 +80,9 @@ export const useVesselActions = () => { * 드래그앤드롭 결과 처리 * * @description 드래그앤드롭 이벤트 결과를 상태 전환 로직으로 변환하여 처리합니다 - * @param {Object} result - 드래그앤드롭 결과 (vesselId, sourceState, targetState) */ const handleDragDrop = useCallback( - (result) => { + (result: DragDropResult) => { const { vesselId, sourceState, targetState } = result; // 같은 상태로 드롭하면 무시 @@ -127,15 +123,13 @@ export const useVesselActions = () => { * 선박 상태 직접 설정 * * @description 개별 선박의 상태를 특정 상태로 직접 설정합니다 - * @param {string} vesselId - 선박 ID - * @param {string} targetState - 목표 상태 (NORMAL, SELECTED, DELETED) */ - const setVesselState = useCallback((vesselId, targetState) => { + const setVesselState = useCallback((vesselId: string, targetState: VesselStateType) => { const { deletedVesselIds, selectedVesselIds } = useReplayStore.getState(); const currentlyDeleted = deletedVesselIds.has(vesselId); const currentlySelected = selectedVesselIds.has(vesselId); - const currentState = currentlyDeleted + const currentState: VesselStateType = currentlyDeleted ? VesselState.DELETED : currentlySelected ? VesselState.SELECTED diff --git a/src/replay/components/VesselListManager/hooks/useVesselClassification.js b/src/replay/components/VesselListManager/hooks/useVesselClassification.ts similarity index 74% rename from src/replay/components/VesselListManager/hooks/useVesselClassification.js rename to src/replay/components/VesselListManager/hooks/useVesselClassification.ts index 1b8e89f9..5ee69e4c 100644 --- a/src/replay/components/VesselListManager/hooks/useVesselClassification.js +++ b/src/replay/components/VesselListManager/hooks/useVesselClassification.ts @@ -15,22 +15,45 @@ import { useEffect, useMemo, useState } from 'react'; import useReplayStore from '../../../stores/replayStore'; import useMergedTrackStore from '../../../stores/mergedTrackStore'; -import { VesselState } from '../../../types/replay.types'; +import { VesselState, VesselStateType } from '../../../types/replay.types'; + +/** 분류된 선박 아이템 */ +export interface ClassifiedVesselItem { + vesselId: string; + targetId: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + sigSrcCd: string; + state: VesselStateType; + lastPosition: number[] | undefined; + lastUpdateTime: number; +} + +interface VesselsByState { + normal: ClassifiedVesselItem[]; + selected: ClassifiedVesselItem[]; + deleted: ClassifiedVesselItem[]; +} + +interface TotalCounts { + normal: number; + selected: number; + deleted: number; + total: number; +} + +interface UseVesselClassificationResult { + vesselsByState: VesselsByState; + totalCount: number; + hasVessels: boolean; + totalCounts: TotalCounts; +} /** * 선박 분류 훅 - * - * @returns {Object} 선박 분류 결과 - * @returns {Object} vesselsByState - 상태별로 분류된 선박 목록 - * @returns {number} totalCount - 전체 선박 수 - * @returns {boolean} hasVessels - 선박 존재 여부 - * @returns {Object} totalCounts - 상태별 선박 수 - * - * @example - * const { vesselsByState, totalCount, hasVessels } = useVesselClassification(); - * console.log(vesselsByState.normal); // 일반 상태 선박 목록 */ -export const useVesselClassification = () => { +export const useVesselClassification = (): UseVesselClassificationResult => { const deletedVesselIds = useReplayStore(state => state.deletedVesselIds); const selectedVesselIds = useReplayStore(state => state.selectedVesselIds); const vesselStates = useReplayStore(state => state.vesselStates); @@ -46,10 +69,10 @@ export const useVesselClassification = () => { return unsubscribe; }, []); - const vesselsByState = useMemo(() => { - const normal = []; - const selected = []; - const deleted = []; + const vesselsByState = useMemo((): VesselsByState => { + const normal: ClassifiedVesselItem[] = []; + const selected: ClassifiedVesselItem[] = []; + const deleted: ClassifiedVesselItem[] = []; const vesselChunks = useMergedTrackStore.getState().vesselChunks; @@ -62,9 +85,9 @@ export const useVesselClassification = () => { if (!vesselInfo) return; // 상태 결정 (새로운 시스템 우선, 레거시 시스템 폴백) - let state; + let state: VesselStateType; if (vesselStates instanceof Map && vesselStates.has(vesselId)) { - state = vesselStates.get(vesselId); + state = vesselStates.get(vesselId)!; } else if (deletedVesselIds.has(vesselId)) { state = VesselState.DELETED; } else if (selectedVesselIds.has(vesselId)) { @@ -74,7 +97,7 @@ export const useVesselClassification = () => { } // 선박 정보 생성 - const vesselItem = { + const vesselItem: ClassifiedVesselItem = { vesselId, targetId: vesselInfo.targetId || vesselId.split('_')[1] || vesselId, // 원본 데이터 우선, fallback으로 split shipName: vesselInfo.shipName || 'Unknown', @@ -101,7 +124,7 @@ export const useVesselClassification = () => { }); // 선박명 기준으로 정렬 - const sortByName = (a, b) => + const sortByName = (a: ClassifiedVesselItem, b: ClassifiedVesselItem) => a.shipName.localeCompare(b.shipName); return { diff --git a/src/replay/components/VesselListManager/hooks/useVirtualScroll.js b/src/replay/components/VesselListManager/hooks/useVirtualScroll.ts similarity index 65% rename from src/replay/components/VesselListManager/hooks/useVirtualScroll.js rename to src/replay/components/VesselListManager/hooks/useVirtualScroll.ts index dfb20622..231ad829 100644 --- a/src/replay/components/VesselListManager/hooks/useVirtualScroll.js +++ b/src/replay/components/VesselListManager/hooks/useVirtualScroll.ts @@ -12,32 +12,35 @@ * * @module hooks/useVirtualScroll */ -import React, { useCallback, useMemo, useState, useRef } from 'react'; +import { useCallback, useMemo, useState, useRef, UIEvent } from 'react'; + +interface UseVirtualScrollOptions { + items: T[]; + itemHeight: number; + containerHeight: number; + overscan?: number; +} + +interface UseVirtualScrollResult { + containerRef: React.RefObject; + scrollElementRef: React.RefObject; + handleScroll: (event: UIEvent) => void; + totalHeight: number; + visibleItems: (T & { index: number })[]; + offsetY: number; + startIndex: number; + endIndex: number; +} /** * 가상 스크롤링 훅 - * - * @param {Object} options - 가상 스크롤 설정 - * @param {any[]} options.items - 전체 아이템 배열 - * @param {number} options.itemHeight - 각 아이템의 고정 높이 (px) - * @param {number} options.containerHeight - 컨테이너 높이 (px) - * @param {number} [options.overscan=5] - 화면 밖에서 미리 렌더링할 아이템 수 - * @returns {Object} 가상 스크롤 결과 및 핸들러 - * - * @example - * const { visibleItems, totalHeight, offsetY, handleScroll } = useVirtualScroll({ - * items: vessels, - * itemHeight: 60, - * containerHeight: 400, - * overscan: 5 - * }); */ -export const useVirtualScroll = ({ +export const useVirtualScroll = >({ items, itemHeight, containerHeight, overscan = 5 -}) => { +}: UseVirtualScrollOptions): UseVirtualScrollResult => { const [scrollTop, setScrollTop] = useState(0); // 가상 스크롤 계산 @@ -70,13 +73,13 @@ export const useVirtualScroll = ({ }, [items, itemHeight, containerHeight, scrollTop, overscan]); // 스크롤 이벤트 핸들러 - const handleScroll = useCallback((event) => { + const handleScroll = useCallback((event: UIEvent) => { setScrollTop(event.currentTarget.scrollTop); }, []); // Ref 생성 - const containerRef = useRef(null); - const scrollElementRef = useRef(null); + const containerRef = useRef(null); + const scrollElementRef = useRef(null); return { containerRef, diff --git a/src/replay/components/VesselListManager/index.js b/src/replay/components/VesselListManager/index.ts similarity index 100% rename from src/replay/components/VesselListManager/index.js rename to src/replay/components/VesselListManager/index.ts diff --git a/src/replay/components/VesselListManager/utils/countryCodeUtils.js b/src/replay/components/VesselListManager/utils/countryCodeUtils.ts similarity index 86% rename from src/replay/components/VesselListManager/utils/countryCodeUtils.js rename to src/replay/components/VesselListManager/utils/countryCodeUtils.ts index 1275c173..056c4620 100644 --- a/src/replay/components/VesselListManager/utils/countryCodeUtils.js +++ b/src/replay/components/VesselListManager/utils/countryCodeUtils.ts @@ -3,8 +3,22 @@ * 참조: https://www.vtexplorer.com/mmsi-mid-codes-en/ */ +/** 국가 그룹 (검색 필터용) */ +interface CountryGroup { + countryName: string; + codes: string[]; + displayName: string; + truncatedName: string; +} + +/** 선박 (국적 코드 포함) */ +interface VesselWithNationalCode { + nationalCode: string; + [key: string]: unknown; +} + // MMSI MID 코드와 한글 국가명 매핑 -export const MMSI_COUNTRY_NAMES = { +export const MMSI_COUNTRY_NAMES: Record = { // 유럽 '201': '알바니아', '202': '안도라', @@ -302,10 +316,10 @@ export const MMSI_COUNTRY_NAMES = { // 기타 '000': '알 수 없음', '999': '기타', -}; +} as const; -// MMSI MID → ISO 3166-1 alpha-2 매핑 -const MMSI_TO_ISO = { +// MMSI MID -> ISO 3166-1 alpha-2 매핑 +const MMSI_TO_ISO: Record = { '201': 'AL', '205': 'BE', '206': 'BY', '207': 'BG', '209': 'CY', '210': 'CY', '211': 'DE', '212': 'CY', '213': 'GE', '214': 'MD', '215': 'MT', '216': 'AM', '218': 'DE', '219': 'DK', '220': 'DK', @@ -346,14 +360,14 @@ const MMSI_TO_ISO = { '701': 'AR', '710': 'BR', '720': 'BO', '725': 'CL', '730': 'CO', '735': 'EC', '750': 'GY', '755': 'PY', '760': 'PE', '770': 'UY', '775': 'VE', -}; +} as const; /** - * MMSI MID 코드 → ISO alpha-2 국가코드 변환 - * @param {string} nationalCode MMSI MID 코드 (3자리) - * @returns {string} ISO alpha-2 코드 또는 원본 코드 + * MMSI MID 코드 -> ISO alpha-2 국가코드 변환 + * @param nationalCode MMSI MID 코드 (3자리) + * @returns ISO alpha-2 코드 또는 원본 코드 */ -export const getCountryIsoCode = (nationalCode) => { +export const getCountryIsoCode = (nationalCode: string | undefined | null): string => { if (!nationalCode) return ''; const code = String(nationalCode); return MMSI_TO_ISO[code] || code; @@ -361,23 +375,23 @@ export const getCountryIsoCode = (nationalCode) => { /** * MMSI MID 코드로부터 한글 국가명을 반환 - * @param {string} nationalCode MMSI MID 코드 (3자리 문자열) - * @returns {string} 한글 국가명 또는 "알 수 없음" + * @param nationalCode MMSI MID 코드 (3자리 문자열) + * @returns 한글 국가명 또는 "알 수 없음" */ -export const getCountryNameFromCode = (nationalCode) => { +export const getCountryNameFromCode = (nationalCode: string | undefined | null): string => { if (!nationalCode || nationalCode.length !== 3) { return '알 수 없음'; } - return MMSI_COUNTRY_NAMES[nationalCode] || `알 수 없음`; + return MMSI_COUNTRY_NAMES[nationalCode] || '알 수 없음'; }; /** * 국가 코드와 한글명을 함께 표시하는 형식으로 반환 - * @param {string} nationalCode MMSI MID 코드 - * @returns {string} "한글국가명 (코드)" 형식 문자열 + * @param nationalCode MMSI MID 코드 + * @returns "한글국가명 (코드)" 형식 문자열 */ -export const getCountryDisplayName = (nationalCode) => { +export const getCountryDisplayName = (nationalCode: string | undefined | null): string => { if (!nationalCode || nationalCode.length !== 3) { return '알 수 없음'; } @@ -387,28 +401,28 @@ export const getCountryDisplayName = (nationalCode) => { return `${countryName} (${nationalCode})`; } - return `알 수 없음`; + return '알 수 없음'; }; /** * 검색 필터에서 사용할 국가 목록을 반환 (국가별로 그룹화) - * @param {string[]} availableCodes 현재 데이터에서 사용 중인 국가 코드들 - * @returns {Array} 그룹화되고 정렬된 국가 목록 배열 + * @param availableCodes 현재 데이터에서 사용 중인 국가 코드들 + * @returns 그룹화되고 정렬된 국가 목록 배열 */ -export const getSortedCountryOptions = (availableCodes) => { +export const getSortedCountryOptions = (availableCodes: string[]): CountryGroup[] => { // 국가명별로 코드들을 그룹화 - const countryGroups = new Map(); + const countryGroups = new Map(); availableCodes.forEach(code => { - const countryName = MMSI_COUNTRY_NAMES[code] || `알 수 없음`; + const countryName = MMSI_COUNTRY_NAMES[code] || '알 수 없음'; if (!countryGroups.has(countryName)) { countryGroups.set(countryName, []); } - countryGroups.get(countryName).push(code); + countryGroups.get(countryName)!.push(code); }); // CountryGroup 배열로 변환 - const groupedCountries = Array.from(countryGroups.entries()).map(([countryName, codes]) => { + const groupedCountries = Array.from(countryGroups.entries()).map(([countryName, codes]): CountryGroup => { codes.sort(); // 코드 정렬 const displayName = codes.length === 1 ? `${countryName} (${codes[0]})` : `${countryName} (${codes.join(', ')})`; @@ -440,11 +454,14 @@ export const getSortedCountryOptions = (availableCodes) => { /** * 선택된 국가의 모든 코드가 포함된 선박을 필터링하는 헬퍼 함수 - * @param {Array} vessels 선박 목록 - * @param {Object|null} selectedCountryGroup 선택된 국가 그룹 - * @returns {Array} 필터링된 선박 목록 + * @param vessels 선박 목록 + * @param selectedCountryGroup 선택된 국가 그룹 + * @returns 필터링된 선박 목록 */ -export const filterVesselsByCountryGroup = (vessels, selectedCountryGroup) => { +export const filterVesselsByCountryGroup = ( + vessels: T[], + selectedCountryGroup: CountryGroup | null, +): T[] => { if (!selectedCountryGroup) return vessels; return vessels.filter(vessel => selectedCountryGroup.codes.includes(vessel.nationalCode)); diff --git a/src/replay/hooks/useReplayLayer.js b/src/replay/hooks/useReplayLayer.ts similarity index 78% rename from src/replay/hooks/useReplayLayer.js rename to src/replay/hooks/useReplayLayer.ts index a483c520..7efad39f 100644 --- a/src/replay/hooks/useReplayLayer.js +++ b/src/replay/hooks/useReplayLayer.ts @@ -3,19 +3,22 @@ * * 성능 최적화: * - currentTime은 zustand.subscribe로 React 렌더 바이패스 - * - 정적 레이어(PathLayer) 캐싱 — 필터 변경 시에만 재생성 + * - 정적 레이어(PathLayer) 캐싱 -- 필터 변경 시에만 재생성 * - 동적 레이어(IconLayer, TextLayer, TripsLayer)만 렌더 갱신 * - 재생 중 ~10fps 쓰로틀, seek 시 즉시 렌더 */ import { useEffect, useRef, useCallback } from 'react'; import { TripsLayer } from '@deck.gl/geo-layers'; +import type { Layer } from '@deck.gl/core'; import useMergedTrackStore from '../stores/mergedTrackStore'; import useAnimationStore from '../stores/animationStore'; import useReplayStore from '../stores/replayStore'; import usePlaybackTrailStore from '../stores/playbackTrailStore'; import useShipStore from '../../stores/shipStore'; import { VesselState, FilterModuleType } from '../types/replay.types'; +import type { VesselStateType, FilterModuleConfig, FilterModules } from '../types/replay.types'; import { createStaticTrackLayers, createVirtualShipLayers } from '../../map/layers/trackLayer'; +import type { ProcessedTrack } from '../../areaSearch/stores/areaSearchStore'; import { registerReplayLayers, unregisterReplayLayers } from '../utils/replayLayerRegistry'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; import { hideLiveShips, showLiveShips } from '../../utils/liveControl'; @@ -24,6 +27,56 @@ import { SIGNAL_KIND_CODE_NORMAL } from '../../types/constants'; const TRAIL_LENGTH_MS = 3600000; const RENDER_INTERVAL_MS = 100; // 재생 중 렌더링 쓰로틀 (~10fps) +// ========== 인터페이스 ========== + +/** 리플레이용 트랙 데이터 */ +interface ReplayTrack { + vesselId: string; + geometry: [number, number][]; + timestamps: (string | number)[]; + timestampsMs: number[]; + speeds: number[]; + shipKindCode: string; + shipName: string; + sigSrcCd: string; +} + +/** TripsLayer용 데이터 */ +interface TripsData { + vesselId: string; + shipKindCode: string; + path: [number, number][]; + timestamps: number[]; +} + +/** 가상 선박 위치 (renderFrame에서 사용) */ +interface FormattedPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipKindCode: string; + shipName: string; +} + +/** 정적 레이어 캐시 의존성 */ +interface StaticLayerCacheDeps { + tracks: ReplayTrack[]; + shipKindCodeFilter: Set; + vesselStates: Map; + deletedVesselIds: Set; + selectedVesselIds: Set; + filterModules: FilterModules; + highlightedVesselId: string | null; +} + +/** 정적 레이어 캐시 */ +interface StaticLayerCache { + layers: Layer[]; + deps: StaticLayerCacheDeps | null; +} + // ========== 이상치 판별 유틸 ========== const MAX_DIST_DEG = 1.0; @@ -32,7 +85,7 @@ const MAX_AVG_SPEED_KNOTS = 50; const DEG_TO_NM = 60; const MS_TO_HOURS = 1 / 3600000; -function isOutlierVessel(geometry, timestampsMs, queryDays) { +function isOutlierVessel(geometry: [number, number][], timestampsMs: number[], queryDays: number): boolean { if (!geometry || geometry.length < 2) return false; const outlierThreshold = Math.max(OUTLIER_PER_DAY * queryDays, OUTLIER_PER_DAY); @@ -70,10 +123,16 @@ function isOutlierVessel(geometry, timestampsMs, queryDays) { return false; } -function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds, selectedVesselIds) { - let state = VesselState.NORMAL; +function shouldShowVessel( + vesselId: string, + filterModule: FilterModuleConfig, + vesselStates: Map, + deletedVesselIds: Set, + selectedVesselIds: Set, +): boolean { + let state: VesselStateType = VesselState.NORMAL; if (vesselStates.has(vesselId)) { - state = vesselStates.get(vesselId); + state = vesselStates.get(vesselId)!; } else if (deletedVesselIds.has(vesselId)) { state = VesselState.DELETED; } else if (selectedVesselIds.has(vesselId)) { @@ -94,13 +153,13 @@ function shouldShowVessel(vesselId, filterModule, vesselStates, deletedVesselIds // ========== 메인 훅 ========== -export default function useReplayLayer() { - const tracksRef = useRef([]); - const tripsDataRef = useRef([]); +export default function useReplayLayer(): void { + const tracksRef = useRef([]); + const tripsDataRef = useRef([]); const startTimeRef = useRef(0); // 정적 레이어 캐시 - const staticLayerCacheRef = useRef({ layers: [], deps: null }); + const staticLayerCacheRef = useRef({ layers: [], deps: null }); // React 구독: 필터/상태 (비빈번 변경) const queryCompleted = useReplayStore((s) => s.queryCompleted); @@ -112,14 +171,15 @@ export default function useReplayLayer() { const highlightedVesselId = useReplayStore((s) => s.highlightedVesselId); const setHighlightedVesselId = useReplayStore((s) => s.setHighlightedVesselId); const isTrailEnabled = usePlaybackTrailStore((s) => s.isEnabled); - // currentTime — React 구독 제거, zustand.subscribe로 대체 + // currentTime -- React 구독 제거, zustand.subscribe로 대체 - const handlePathHover = useCallback((vesselId) => { + const handlePathHover = useCallback((vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId]); - const handleIconHover = useCallback((shipData, x, y) => { + const handleIconHover = useCallback((shipData: FormattedPosition | null, x: number, y: number) => { if (shipData) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- setHoverInfo는 호버 툴팁 전용이라 ShipFeature 전체가 불필요, 필요 필드만 전달 useShipStore.getState().setHoverInfo({ ship: { shipName: shipData.shipName, @@ -127,7 +187,7 @@ export default function useReplayLayer() { signalKindCode: shipData.shipKindCode, sog: shipData.speed || 0, cog: shipData.heading || 0, - }, + } as any, x, y, }); @@ -148,7 +208,7 @@ export default function useReplayLayer() { return; } - const formattedPositions = currentPositions.map((pos) => ({ + const formattedPositions: FormattedPosition[] = currentPositions.map((pos) => ({ vesselId: pos.vesselId, lon: pos.position[0], lat: pos.position[1], @@ -163,8 +223,8 @@ export default function useReplayLayer() { const customFilter = filterModules[FilterModuleType.CUSTOM]; const labelFilter = filterModules[FilterModuleType.LABEL]; - const iconPositions = []; - const labelPositions = []; + const iconPositions: FormattedPosition[] = []; + const labelPositions: FormattedPosition[] = []; formattedPositions.forEach((pos) => { if (!shipKindCodeFilter.has(pos.shipKindCode)) return; @@ -178,14 +238,14 @@ export default function useReplayLayer() { }); // 선종별 카운트 - const counts = {}; + const counts: Record = {}; iconPositions.forEach((pos) => { const code = pos.shipKindCode || SIGNAL_KIND_CODE_NORMAL; counts[code] = (counts[code] || 0) + 1; }); useReplayStore.getState().setReplayShipCounts(counts); - const layers = []; + const layers: Layer[] = []; // 1. TripsLayer 궤적 if (isTrailEnabled && tripsDataRef.current.length > 0) { @@ -198,11 +258,11 @@ export default function useReplayLayer() { const relativeCurrentTime = useAnimationStore.getState().currentTime - startTimeRef.current; layers.push( - new TripsLayer({ + new TripsLayer({ id: 'replay-trips-trail', data: filteredTripsData, - getPath: (d) => d.path, - getTimestamps: (d) => d.timestamps, + getPath: (d: TripsData) => d.path, + getTimestamps: (d: TripsData) => d.timestamps, getColor: [120, 120, 120, 180], widthMinPixels: 2, widthMaxPixels: 3, @@ -235,8 +295,9 @@ export default function useReplayLayer() { }); staticLayerCacheRef.current = { + // ReplayTrack은 ProcessedTrack의 부분 집합 -- createStaticTrackLayers는 geometry/vesselId/shipKindCode만 사용 layers: createStaticTrackLayers({ - tracks: filteredTracks, + tracks: filteredTracks as unknown as ProcessedTrack[], showPoints: false, highlightedVesselId: currentHighlightedVesselId, onPathHover: handlePathHover, @@ -281,7 +342,7 @@ export default function useReplayLayer() { const chunks = useMergedTrackStore.getState().vesselChunks; if (chunks.size === 0) return; - const tracks = []; + const tracks: ReplayTrack[] = []; chunks.forEach((vc, vesselId) => { const path = useMergedTrackStore.getState().getMergedPath(vesselId); if (!path || path.geometry.length < 2) return; @@ -294,7 +355,7 @@ export default function useReplayLayer() { speeds: path.speeds, shipKindCode: vc.shipKindCode || '000027', shipName: vc.shipName || '', - sigSrcCd: vc.sigSrcCd, + sigSrcCd: vc.sigSrcCd || '', }); }); @@ -307,8 +368,8 @@ export default function useReplayLayer() { const queryDays = Math.max(1, Math.ceil((endTime - startTime) / (24 * 60 * 60 * 1000))); - const tripsData = []; - const outlierVesselIds = []; + const tripsData: TripsData[] = []; + const outlierVesselIds: string[] = []; tracks.forEach((track) => { if (track.geometry.length < 2) return; @@ -336,7 +397,7 @@ export default function useReplayLayer() { /** * 쿼리 완료 시 데이터 빌드 + 자동 재생 - * renderFrame 제외 — rebuildTracksAndTripsData가 vesselStates를 변경하므로 + * renderFrame 제외 -- rebuildTracksAndTripsData가 vesselStates를 변경하므로 * renderFrame이 deps에 있으면 무한 루프 발생 */ useEffect(() => { @@ -357,9 +418,9 @@ export default function useReplayLayer() { }, [queryCompleted, rebuildTracksAndTripsData]); /** - * currentTime 구독 (zustand.subscribe — React 리렌더 바이패스) + * currentTime 구독 (zustand.subscribe -- React 리렌더 바이패스) * 재생 중: ~10fps 쓰로틀, seek/정지: 즉시 렌더 - * renderFrame이 deps → 필터 변경 시 자동 재구독 + 즉시 렌더 + * renderFrame이 deps -> 필터 변경 시 자동 재구독 + 즉시 렌더 */ useEffect(() => { if (!queryCompleted) return; @@ -367,7 +428,7 @@ export default function useReplayLayer() { renderFrame(); let lastRenderTime = 0; - let pendingRafId = null; + let pendingRafId: number | null = null; const unsub = useAnimationStore.subscribe( (s) => s.currentTime, @@ -402,22 +463,22 @@ export default function useReplayLayer() { useEffect(() => { if (!queryCompleted) return; - const handleKeyDown = (event) => { + const handleKeyDown = (event: KeyboardEvent) => { const currentHighlightedId = useReplayStore.getState().highlightedVesselId; if (!currentHighlightedId) return; const { vesselStates: vs, deletedVesselIds: dvi, selectedVesselIds: svi, setVesselState } = useReplayStore.getState(); - let currentState = VesselState.NORMAL; + let currentState: VesselStateType = VesselState.NORMAL; if (vs.has(currentHighlightedId)) { - currentState = vs.get(currentHighlightedId); + currentState = vs.get(currentHighlightedId)!; } else if (dvi.has(currentHighlightedId)) { currentState = VesselState.DELETED; } else if (svi.has(currentHighlightedId)) { currentState = VesselState.SELECTED; } - let targetState = null; + let targetState: VesselStateType | null = null; if (event.key === 'Delete') { if (currentState === VesselState.DELETED) { diff --git a/src/replay/services/ReplayWebSocketService.js b/src/replay/services/ReplayWebSocketService.ts similarity index 75% rename from src/replay/services/ReplayWebSocketService.js rename to src/replay/services/ReplayWebSocketService.ts index 428429af..3e0d615b 100644 --- a/src/replay/services/ReplayWebSocketService.js +++ b/src/replay/services/ReplayWebSocketService.ts @@ -10,29 +10,57 @@ * * @singleton 애플리케이션 전체에서 하나의 인스턴스만 사용 */ -import { Client } from '@stomp/stompjs'; -import { transformExtent } from 'ol/proj'; +import { Client, IMessage, StompSubscription } from '@stomp/stompjs'; import { ConnectionState, + ConnectionStateType, isTrackChunkResponse, isQueryStatusUpdate, - normalizeChunkResponse, - extractTracks, + TrackQueryRequest, + TrackData, + TrackChunkResponse, } from '../types/replay.types'; import useReplayStore from '../stores/replayStore'; import useMergedTrackStore from '../stores/mergedTrackStore'; // WebSocket 엔드포인트 (환경 변수) -const WS_ENDPOINT = import.meta.env.VITE_TRACKING_WS; +const WS_ENDPOINT = import.meta.env.VITE_TRACKING_WS as string; // 타임아웃 설정 const CONNECTION_TIMEOUT = 10000; // 10초 const QUERY_TIMEOUT = 300000; // 5분 +/** 정규화된 청크 응답 (isLastChunk 추가) */ +interface NormalizedChunk extends TrackChunkResponse { + chunkId?: string; + isLastChunk: boolean; +} + +/** 맵 뷰포트 좌표 (EPSG:4326) */ +interface MapViewport { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + /** * ReplayWebSocketService */ class ReplayWebSocketService { + private client: Client | null; + private subscriptions: StompSubscription[]; + private isConnecting: boolean; + private currentQueryId: string | null; + private connectionPromise: Promise | null; + private queryTimeoutId: ReturnType | null; + + // timestamp 기반 진행률 추적 + private queryStartTimestamp: number; + private queryEndTimestamp: number; + private maxReceivedTimestamp: number; + private estimatedProgress: number; + constructor() { this.client = null; this.subscriptions = []; @@ -57,13 +85,8 @@ class ReplayWebSocketService { * 2. 채널 구독 * 3. 쿼리 전송 * 4. 완료 시 자동 정리 - * - * @param {Object} request - 항적 조회 요청 - * @param {string} request.startTime - 시작 시간 (ISO 형식, KST) - * @param {string} request.endTime - 종료 시간 (ISO 형식, KST) - * @param {string[]} [request.vesselIds] - 조회 대상 선박 ID (빈 배열이면 전체 조회) */ - async executeQuery(request) { + async executeQuery(request: TrackQueryRequest): Promise { try { // 이전 쿼리가 진행 중이면 취소 if (this.currentQueryId) { @@ -92,7 +115,7 @@ class ReplayWebSocketService { this._sendQuery(request); } catch (error) { console.error('[ReplayWS] 쿼리 실행 실패:', error); - this._handleError(error); + this._handleError(error as Error); throw error; } } @@ -100,7 +123,7 @@ class ReplayWebSocketService { /** * 쿼리 취소 */ - async cancelQuery() { + async cancelQuery(): Promise { if (!this.currentQueryId) return; try { @@ -120,14 +143,14 @@ class ReplayWebSocketService { /** * 연결 해제 */ - disconnect() { + disconnect(): void { this._cleanup(); } /** * 연결 상태 확인 */ - get connected() { + get connected(): boolean { return this.client?.connected || false; } @@ -136,7 +159,7 @@ class ReplayWebSocketService { /** * 연결 확보 (기존 연결 재사용 또는 새 연결 생성) */ - async _ensureConnected() { + private async _ensureConnected(): Promise { if (this.client?.connected) return; if (this.isConnecting && this.connectionPromise) { @@ -152,7 +175,7 @@ class ReplayWebSocketService { * WebSocket 연결 생성 * 참조: mda-react-front TrackingWebSocketService.createConnection() */ - async _createConnection() { + private async _createConnection(): Promise { this.isConnecting = true; try { @@ -191,7 +214,8 @@ class ReplayWebSocketService { onWebSocketError: (event) => { console.error('[ReplayWS] WebSocket 에러:', event); - if (event.type === 'close' && event.code === 1006) { + const closeEvent = event as CloseEvent; + if (closeEvent.type === 'close' && closeEvent.code === 1006) { this._handleAbnormalClose(); } }, @@ -202,7 +226,7 @@ class ReplayWebSocketService { this.client.activate(); // 연결 완료 대기 (폴링) - await new Promise((resolve, reject) => { + await new Promise((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error('연결 타임아웃')); }, CONNECTION_TIMEOUT); @@ -229,7 +253,7 @@ class ReplayWebSocketService { * 구독 설정 * 참조: mda-react-front TrackingWebSocketService.setupSubscriptions() */ - _setupSubscriptions() { + private _setupSubscriptions(): void { if (!this.client?.connected) { console.error('[ReplayWS] 구독 설정 실패: 연결되지 않음'); return; @@ -239,33 +263,33 @@ class ReplayWebSocketService { this._clearSubscriptions(); // 1. 청크 데이터 구독 - const chunkSub = this.client.subscribe('/user/queue/tracks/chunk', (message) => { + const chunkSub = this.client.subscribe('/user/queue/tracks/chunk', (message: IMessage) => { this._handleChunkMessage(message); }); this.subscriptions.push(chunkSub); // 2. 상태 업데이트 구독 - const statusSub = this.client.subscribe('/user/queue/tracks/status', (message) => { + const statusSub = this.client.subscribe('/user/queue/tracks/status', (message: IMessage) => { this._handleStatusMessage(message); }); this.subscriptions.push(statusSub); // 3. 쿼리 응답 구독 - const responseSub = this.client.subscribe('/user/queue/tracks/response', (message) => { + const responseSub = this.client.subscribe('/user/queue/tracks/response', (message: IMessage) => { this._handleResponseMessage(message); }); this.subscriptions.push(responseSub); // 4. 에러 구독 - const errorSub = this.client.subscribe('/user/queue/errors', (message) => { + const errorSub = this.client.subscribe('/user/queue/errors', (message: IMessage) => { this._handleErrorMessage(message); }); this.subscriptions.push(errorSub); } - _clearSubscriptions() { + private _clearSubscriptions(): void { this.subscriptions.forEach((sub) => { - try { sub.unsubscribe(); } catch (e) { /* ignore */ } + try { sub.unsubscribe(); } catch { /* ignore */ } }); this.subscriptions = []; } @@ -279,15 +303,13 @@ class ReplayWebSocketService { * - startTime을 HH:00:00 형식으로 정규화 (hourly 테이블 최적화) * - chunkedMode: true, chunkSize: 20000 강제 설정 */ - _sendQuery(request) { + private _sendQuery(request: TrackQueryRequest): void { if (!this.client?.connected) { throw new Error('WebSocket이 연결되지 않았습니다'); } // 시작 시간 정규화: HH:00:00 (hourly 테이블 최적화) - const startTimeStr = typeof request.startTime === 'string' - ? request.startTime - : request.startTime.toString(); + const startTimeStr = request.startTime; const [datePart, timePart] = startTimeStr.split('T'); const [hour] = timePart ? timePart.split(':') : ['00']; const normalizedStartTime = `${datePart}T${hour}:00:00`; @@ -306,7 +328,7 @@ class ReplayWebSocketService { const zoomLevel = this._getMapZoomLevel(); // 요청 객체 구성 (메인 프로젝트와 동일) - const enrichedRequest = { + const enrichedRequest: TrackQueryRequest = { ...request, startTime: normalizedStartTime, vesselIds, @@ -341,9 +363,9 @@ class ReplayWebSocketService { * 청크 메시지 처리 * 참조: mda-react-front TrackingWebSocketService.handleChunkMessage() */ - _handleChunkMessage(message) { + private _handleChunkMessage(message: IMessage): void { try { - const chunk = JSON.parse(message.body); + const chunk = JSON.parse(message.body) as Record; const normalized = this._normalizeChunkResponse(chunk); if (!isTrackChunkResponse(normalized)) { @@ -351,7 +373,7 @@ class ReplayWebSocketService { return; } - const tracks = extractTracks(normalized); + const tracks = normalized.tracks || normalized.mergedTracks || normalized.compactTracks || []; if (!tracks || tracks.length === 0) return; // tracks를 정규화된 필드로 설정 @@ -380,9 +402,9 @@ class ReplayWebSocketService { * 상태 메시지 처리 * 참조: mda-react-front TrackingWebSocketService.handleStatusMessage() */ - _handleStatusMessage(message) { + private _handleStatusMessage(message: IMessage): void { try { - const status = JSON.parse(message.body); + const status = JSON.parse(message.body) as Record; if (!isQueryStatusUpdate(status)) { console.error('[ReplayWS] 잘못된 상태 형식:', status); @@ -395,12 +417,12 @@ class ReplayWebSocketService { } if (status.status === 'ERROR') { - this._handleError(new Error(status.error || '쿼리 처리 중 오류 발생')); + this._handleError(new Error((status.error as string) || '쿼리 처리 중 오류 발생')); } // 서버에서 queryId를 반환하면 저장 if (status.queryId) { - useReplayStore.getState().setQueryId(status.queryId); + useReplayStore.getState().setQueryId(status.queryId as string); } } catch (error) { console.error('[ReplayWS] 상태 처리 오류:', error); @@ -410,9 +432,9 @@ class ReplayWebSocketService { /** * 응답 메시지 처리 */ - _handleResponseMessage(message) { + private _handleResponseMessage(message: IMessage): void { try { - const response = JSON.parse(message.body); + const response = JSON.parse(message.body) as { queryId?: string }; if (response.queryId) { useReplayStore.getState().setQueryId(response.queryId); @@ -425,7 +447,7 @@ class ReplayWebSocketService { /** * 에러 메시지 처리 */ - _handleErrorMessage(message) { + private _handleErrorMessage(message: IMessage): void { console.error('[ReplayWS] 서버 에러:', message.body); this._handleError(new Error(message.body)); } @@ -436,12 +458,12 @@ class ReplayWebSocketService { * timestamp 기반 진행률 업데이트 * 참조: mda-react-front TrackingWebSocketService.updateProgressByTimestamp() */ - _updateProgressByTimestamp(tracks, isLastChunk) { + private _updateProgressByTimestamp(tracks: TrackData[], isLastChunk: boolean): void { try { tracks.forEach((track) => { if (track.timestamps && Array.isArray(track.timestamps)) { track.timestamps.forEach((ts) => { - const timestamp = typeof ts === 'number' ? ts : parseInt(ts, 10); + const timestamp = typeof ts === 'number' ? ts : parseInt(ts as string, 10); if (timestamp > this.maxReceivedTimestamp) { this.maxReceivedTimestamp = timestamp; } @@ -472,7 +494,7 @@ class ReplayWebSocketService { /** * 쿼리 완료 처리 */ - _handleQueryComplete() { + private _handleQueryComplete(): void { this._clearQueryTimeout(); useReplayStore.setState({ progress: 100 }); @@ -484,10 +506,10 @@ class ReplayWebSocketService { /** * 에러 처리 */ - _handleError(error) { + private _handleError(error: Error): void { console.error('[ReplayWS] 에러:', error); - const userMessage = this._getUserFriendlyError(error); + this._getUserFriendlyError(error); useReplayStore.setState({ connectionState: ConnectionState.ERROR, }); @@ -498,7 +520,7 @@ class ReplayWebSocketService { /** * 비정상 종료 처리 (버퍼 오버플로우 등) */ - _handleAbnormalClose() { + private _handleAbnormalClose(): void { console.error('[ReplayWS] 비정상 종료 (버퍼 오버플로우 가능)'); this._cleanup(); } @@ -506,7 +528,7 @@ class ReplayWebSocketService { /** * 사용자 친화적 에러 메시지 */ - _getUserFriendlyError(error) { + private _getUserFriendlyError(error: Error): string { const message = error.message.toLowerCase(); if (message.includes('timeout')) return '서버 응답 시간이 초과되었습니다.'; if (message.includes('network') || message.includes('connect')) return '네트워크 연결을 확인해주세요.'; @@ -515,7 +537,7 @@ class ReplayWebSocketService { // ===== 타임아웃 (private) ===== - _setQueryTimeout() { + private _setQueryTimeout(): void { this._clearQueryTimeout(); this.queryTimeoutId = setTimeout(() => { console.error('[ReplayWS] 쿼리 타임아웃'); @@ -523,7 +545,7 @@ class ReplayWebSocketService { }, QUERY_TIMEOUT); } - _clearQueryTimeout() { + private _clearQueryTimeout(): void { if (this.queryTimeoutId) { clearTimeout(this.queryTimeoutId); this.queryTimeoutId = null; @@ -532,7 +554,7 @@ class ReplayWebSocketService { // ===== 연결 상태 (private) ===== - _updateConnectionState(state) { + private _updateConnectionState(state: ConnectionStateType): void { useReplayStore.getState().setConnectionState(state); } @@ -542,12 +564,12 @@ class ReplayWebSocketService { * 전체 정리 * 참조: mda-react-front TrackingWebSocketService.cleanup() */ - _cleanup() { + private _cleanup(): void { this._clearQueryTimeout(); this._clearSubscriptions(); if (this.client?.connected) { - try { this.client.deactivate(); } catch (e) { /* ignore */ } + try { this.client.deactivate(); } catch { /* ignore */ } } this.client = null; @@ -561,23 +583,24 @@ class ReplayWebSocketService { // ===== 지도 뷰포트 (private) ===== /** - * OpenLayers 맵에서 현재 뷰포트 좌표 추출 - * 참조: mda-react-front/src/tracking/components/ReplayV2.tsx getMapViewport() - * @returns {{ minLon: number, maxLon: number, minLat: number, maxLat: number } | undefined} + * MapLibre 맵에서 현재 뷰포트 좌표 추출 */ - _getMapViewport() { + private _getMapViewport(): MapViewport | undefined { try { - const map = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 MapLibre Map 인스턴스 (글로벌 접근) + const map = (window as any).__mainMap__; if (!map) { console.warn('[ReplayWS] 맵 인스턴스 없음 (window.__mainMap__)'); return undefined; } - const view = map.getView(); - const extent3857 = view.calculateExtent(map.getSize()); - const [minLon, minLat, maxLon, maxLat] = transformExtent(extent3857, 'EPSG:3857', 'EPSG:4326'); - - return { minLon, maxLon, minLat, maxLat }; + const bounds = map.getBounds(); + return { + minLon: bounds.getWest(), + maxLon: bounds.getEast(), + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + }; } catch (error) { console.error('[ReplayWS] 뷰포트 추출 실패:', error); return undefined; @@ -585,13 +608,14 @@ class ReplayWebSocketService { } /** - * 현재 맵 줌 레벨 + * 현재 맵 줌 레벨 (OL 규약: MapLibre zoom + 1) */ - _getMapZoomLevel() { + private _getMapZoomLevel(): number { try { - const map = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 MapLibre Map 인스턴스 (글로벌 접근) + const map = (window as any).__mainMap__; if (!map) return 10; - return Math.round(map.getView().getZoom()) || 10; + return Math.round(map.getZoom() + 1) || 10; // +1: OL 줌 규약 } catch { return 10; } @@ -599,25 +623,25 @@ class ReplayWebSocketService { // ===== 유틸 (private) ===== - _normalizeChunkResponse(chunk) { + private _normalizeChunkResponse(chunk: Record): NormalizedChunk { return { - queryId: chunk.queryId, - chunkId: chunk.chunkId || `chunk_${chunk.chunkIndex}`, - chunkIndex: chunk.chunkIndex, - totalChunks: chunk.totalChunks, - tracks: chunk.tracks, - mergedTracks: chunk.mergedTracks, - compactTracks: chunk.compactTracks, - isLastChunk: chunk.isLastChunk || false, - metadata: chunk.metadata, + queryId: chunk.queryId as string, + chunkId: (chunk.chunkId as string) || `chunk_${chunk.chunkIndex}`, + chunkIndex: chunk.chunkIndex as number, + totalChunks: (chunk.totalChunks as number | null) ?? null, + tracks: chunk.tracks as TrackData[] | undefined, + mergedTracks: chunk.mergedTracks as TrackData[] | undefined, + compactTracks: chunk.compactTracks as TrackData[] | undefined, + isLastChunk: (chunk.isLastChunk as boolean) || false, + metadata: chunk.metadata as Record | null, }; } } // 싱글톤 인스턴스 -let instance = null; +let instance: ReplayWebSocketService | null = null; -export function getReplayWebSocketService() { +export function getReplayWebSocketService(): ReplayWebSocketService { if (!instance) { instance = new ReplayWebSocketService(); } diff --git a/src/replay/stores/animationStore.js b/src/replay/stores/animationStore.ts similarity index 81% rename from src/replay/stores/animationStore.js rename to src/replay/stores/animationStore.ts index 03b885a1..6c08230d 100644 --- a/src/replay/stores/animationStore.js +++ b/src/replay/stores/animationStore.ts @@ -8,7 +8,54 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import useMergedTrackStore from './mergedTrackStore'; -import useReplayStore from './replayStore'; + +// ===== 인터페이스 ===== + +interface TimeRange { + start: number; + end: number; +} + +interface VesselPosition { + vesselId: string; + position: [number, number]; + heading: number; + speed: number; + timestamp: number; + shipKindCode: string; + shipName: string; + sigSrcCd?: string; +} + +interface AnimationState { + // ========== 재생 상태 ========== + isPlaying: boolean; + playbackState: PlaybackStateType; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + loop: boolean; + loopEnabled: boolean; + + // ========== 내부 상태 ========== + animationFrameId: number | null; + lastFrameTime: number; + + // ========== 액션 ========== + play: () => void; + pause: () => void; + stop: () => void; + seekTo: (time: number) => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + toggleLoop: () => void; + updateTimeRange: () => TimeRange; + setTimeRange: (startTime: number, endTime: number) => void; + getProgress: () => number; + getCurrentVesselPositions: () => VesselPosition[]; + reset: () => void; +} /** * 재생 상태 (레거시 호환) @@ -18,12 +65,14 @@ export const PlaybackState = { PLAYING: 'PLAYING', PAUSED: 'PAUSED', STOPPED: 'STOPPED', -}; +} as const; + +export type PlaybackStateType = (typeof PlaybackState)[keyof typeof PlaybackState]; /** * 두 지점 간의 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: [number, number], p2: [number, number]): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; const dx = lon2 - lon1; @@ -36,7 +85,13 @@ function calculateHeading(p1, p2) { /** * 두 지점 사이의 위치를 시간 기반으로 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition( + p1: [number, number], + p2: [number, number], + t1: number, + t2: number, + currentTime: number, +): [number, number] { if (t1 === t2) return p1; const ratio = (currentTime - t1) / (t2 - t1); return [ @@ -48,7 +103,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 청크 기반 데이터에서 시간 범위 추출 */ -function getTimeRangeFromVessels() { +function getTimeRangeFromVessels(): TimeRange | null { const vesselChunks = useMergedTrackStore.getState().vesselChunks; if (vesselChunks.size === 0) { @@ -74,12 +129,12 @@ function getTimeRangeFromVessels() { } // 커서 기반 선형 탐색용 (vesselId → lastIndex) -const positionCursors = new Map(); +const positionCursors = new Map(); /** * 애니메이션 스토어 */ -const useAnimationStore = create(subscribeWithSelector((set, get) => ({ +const useAnimationStore = create()(subscribeWithSelector((set, get) => ({ // ========== 재생 상태 ========== isPlaying: false, playbackState: PlaybackState.IDLE, // 레거시 호환 @@ -133,7 +188,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ }); // 애니메이션 루프 시작 - const animate = (timestamp) => { + const animate = (timestamp: number) => { const state = get(); if (!state.isPlaying) return; @@ -207,7 +262,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 특정 시간으로 이동 */ - seekTo: (time) => { + seekTo: (time: number) => { const state = get(); const clampedTime = Math.max(state.startTime, Math.min(time, state.endTime)); set({ currentTime: clampedTime }); @@ -216,14 +271,14 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 현재 시간 설정 (레거시 호환) */ - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { set({ currentTime: time }); }, /** * 재생 속도 설정 */ - setPlaybackSpeed: (speed) => { + setPlaybackSpeed: (speed: number) => { set({ playbackSpeed: speed }); }, @@ -240,7 +295,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 시간 범위 업데이트 */ - updateTimeRange: () => { + updateTimeRange: (): TimeRange => { const timeRange = getTimeRangeFromVessels(); if (timeRange) { const state = get(); @@ -264,7 +319,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 시간 범위 직접 설정 */ - setTimeRange: (startTime, endTime) => { + setTimeRange: (startTime: number, endTime: number) => { set({ startTime, endTime, @@ -275,7 +330,7 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ /** * 진행률 계산 (0 ~ 100) */ - getProgress: () => { + getProgress: (): number => { const { currentTime, startTime, endTime } = get(); if (endTime === startTime) return 0; return ((currentTime - startTime) / (endTime - startTime)) * 100; @@ -286,10 +341,10 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ * 재생 중: 커서에서 선형 전진 O(1~2) * seek/역방향: 이진 탐색 fallback O(log n) */ - getCurrentVesselPositions: () => { + getCurrentVesselPositions: (): VesselPosition[] => { const { currentTime } = get(); const vesselChunks = useMergedTrackStore.getState().vesselChunks; - const positions = []; + const positions: VesselPosition[] = []; vesselChunks.forEach((vessel, vesselId) => { if (!vessel) return; @@ -332,9 +387,9 @@ const useAnimationStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, cursor - 1); const idx2 = Math.min(timestampsMs.length - 1, cursor); - let finalPosition; - let heading; - let speed; + let finalPosition: [number, number]; + let heading: number; + let speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { finalPosition = mergedPath.geometry[idx1]; diff --git a/src/replay/stores/mergedTrackStore.js b/src/replay/stores/mergedTrackStore.ts similarity index 67% rename from src/replay/stores/mergedTrackStore.js rename to src/replay/stores/mergedTrackStore.ts index bec13ea8..fced726d 100644 --- a/src/replay/stores/mergedTrackStore.js +++ b/src/replay/stores/mergedTrackStore.ts @@ -5,12 +5,64 @@ * 청크 기반 선박 항적 데이터 저장 및 관리 */ import { create } from 'zustand'; -import { parseTimestamp } from '../types/replay.types'; +import { parseTimestamp, TrackData, TrackChunkResponse } from '../types/replay.types'; + +// ===== 인터페이스 ===== + +interface CachedPath { + geometry: [number, number][]; + timestamps: (string | number)[]; + timestampsMs: number[]; + speeds: number[]; + lastUpdated: number; +} + +interface VesselChunks { + vesselId: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + chunks: TrackData[]; + cachedPath: CachedPath | null; + totalDistance: number; + maxSpeed: number; + avgSpeed: number; +} + +interface TimeRange { + start: number; + end: number; +} + +interface SpatialBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +interface MergedTrackState { + // ===== 상태 ===== + vesselChunks: Map; + rawChunks: TrackChunkResponse[]; + timeRange: TimeRange | null; + spatialBounds: SpatialBounds | null; + + // ===== 액션 ===== + addChunkOptimized: (chunkResponse: TrackChunkResponse) => void; + getMergedPath: (vesselId: string) => CachedPath | null; + addRawChunk: (chunkResponse: TrackChunkResponse) => void; + clear: () => void; + getAllVesselIds: () => string[]; + getVesselChunks: (vesselId: string) => VesselChunks | null; +} /** * 청크 기반 선박 데이터 병합 */ -function mergeVesselChunks(existingChunks, newChunk) { +function mergeVesselChunks(existingChunks: VesselChunks | undefined, newChunk: TrackData): VesselChunks { if (!existingChunks) { return { vesselId: newChunk.vesselId, @@ -18,6 +70,7 @@ function mergeVesselChunks(existingChunks, newChunk) { targetId: newChunk.targetId, shipName: newChunk.shipName, shipKindCode: newChunk.shipKindCode, + nationalCode: newChunk.nationalCode, chunks: [newChunk], cachedPath: null, totalDistance: newChunk.totalDistance || 0, @@ -28,8 +81,8 @@ function mergeVesselChunks(existingChunks, newChunk) { // 기존 청크에 새 청크 추가 (시간순 정렬) const chunks = [...existingChunks.chunks, newChunk].sort((a, b) => { - const timeA = parseTimestamp(a.timestamps[0]); - const timeB = parseTimestamp(b.timestamps[0]); + const timeA = parseTimestamp(a.timestamps![0]); + const timeB = parseTimestamp(b.timestamps![0]); return timeA - timeB; }); @@ -45,11 +98,11 @@ function mergeVesselChunks(existingChunks, newChunk) { /** * 병합된 경로 생성 (캐싱) */ -function buildCachedPath(chunks) { - const geometry = []; - const timestamps = []; - const timestampsMs = []; - const speeds = []; +function buildCachedPath(chunks: TrackData[]): CachedPath { + const geometry: [number, number][] = []; + const timestamps: (string | number)[] = []; + const timestampsMs: number[] = []; + const speeds: number[] = []; chunks.forEach((chunk) => { if (chunk.geometry) { @@ -78,7 +131,7 @@ function buildCachedPath(chunks) { /** * MergedTrackStore */ -const useMergedTrackStore = create((set, get) => ({ +const useMergedTrackStore = create((set, get) => ({ // ===== 상태 ===== // 청크 기반 저장소 (메인) @@ -96,7 +149,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 청크 추가 (최적화) */ - addChunkOptimized: (chunkResponse) => { + addChunkOptimized: (chunkResponse: TrackChunkResponse) => { const tracks = chunkResponse.tracks || []; set((state) => { @@ -136,7 +189,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 병합된 경로 반환 (캐시 사용) */ - getMergedPath: (vesselId) => { + getMergedPath: (vesselId: string): CachedPath | null => { const vesselChunks = get().vesselChunks.get(vesselId); if (!vesselChunks) return null; @@ -164,7 +217,7 @@ const useMergedTrackStore = create((set, get) => ({ /** * 원본 청크 추가 */ - addRawChunk: (chunkResponse) => { + addRawChunk: (chunkResponse: TrackChunkResponse) => { set((state) => ({ rawChunks: [...state.rawChunks, chunkResponse], })); @@ -185,14 +238,14 @@ const useMergedTrackStore = create((set, get) => ({ /** * 모든 선박 ID 반환 */ - getAllVesselIds: () => { + getAllVesselIds: (): string[] => { return Array.from(get().vesselChunks.keys()); }, /** * 선박 데이터 반환 */ - getVesselChunks: (vesselId) => { + getVesselChunks: (vesselId: string): VesselChunks | null => { return get().vesselChunks.get(vesselId) || null; }, })); diff --git a/src/replay/stores/playbackTrailStore.js b/src/replay/stores/playbackTrailStore.ts similarity index 79% rename from src/replay/stores/playbackTrailStore.js rename to src/replay/stores/playbackTrailStore.ts index d1994b96..f54818e1 100644 --- a/src/replay/stores/playbackTrailStore.js +++ b/src/replay/stores/playbackTrailStore.ts @@ -13,6 +13,68 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +// ===== 인터페이스 ===== + +interface TrailPoint { + lon: number; + lat: number; + frameIndex: number; + vesselId: string; +} + +interface LastPosition { + lon: number; + lat: number; +} + +interface RecordFramePosition { + vesselId: string; + lon: number; + lat: number; + shipKindCode?: string; +} + +interface TrailConfig { + maxFrames?: number; + maxPointSize?: number; + minPointSize?: number; +} + +interface PlaybackTrailState { + /** 항적표시 토글 상태 */ + isEnabled: boolean; + /** 선박별 항적 포인트 Map (vesselId -> TrailPoint[]) */ + trails: Map; + /** 선박별 마지막 기록 위치 (거리 필터용) */ + lastPositions: Map; + /** 선박별 선종 코드 (필터 동기화용) */ + vesselKindCodes: Map; + /** 현재 프레임 인덱스 */ + frameIndex: number; + /** 프로그레스 바 스크러빙 중 여부 */ + isScrubbing: boolean; + /** 현재 재생 배속 */ + playbackSpeed: number; + /** 유지할 최대 프레임 수 */ + maxFrames: number; + /** 포인트 최대 크기 (px) */ + maxPointSize: number; + /** 포인트 최소 크기 (px) */ + minPointSize: number; + + // ===== 액션 ===== + setEnabled: (enabled: boolean) => void; + clearTrails: () => void; + setScrubbing: (scrubbing: boolean) => void; + removeTrailsByFilter: (activeKindCodes: Set) => void; + recordFrame: (positions: RecordFramePosition[]) => void; + getVisiblePoints: () => TrailPoint[]; + getOpacity: (pointFrameIndex: number) => number; + getPointSize: (pointFrameIndex: number) => number; + updatePlaybackSpeed: (speed: number) => void; + setConfig: (config: TrailConfig) => void; +} + // 프레임 설정 (역비례 계산 — 배속 무관 동일 시각적 궤적 길이) const REFERENCE_SPEED = 1000; // 기준 배속 (1000x에서 궤적 길이가 적절) const REFERENCE_FRAMES = 60; // 기준 배속에서의 프레임 수 @@ -34,7 +96,7 @@ const MIN_TRAIL_DISTANCE_SQ = 0.000001; * * 1000x: 60, 500x: 120, 100x: 150(cap), 50x~1x: 150(cap) */ -const calculateMaxFrames = (playbackSpeed) => { +const calculateMaxFrames = (playbackSpeed: number): number => { if (playbackSpeed <= 0) return REFERENCE_FRAMES; const frames = Math.round(REFERENCE_FRAMES * REFERENCE_SPEED / playbackSpeed); return Math.max(MIN_MAX_FRAMES, Math.min(MAX_MAX_FRAMES, frames)); @@ -43,7 +105,7 @@ const calculateMaxFrames = (playbackSpeed) => { /** * 재생 항적 스토어 */ -const usePlaybackTrailStore = create( +const usePlaybackTrailStore = create()( subscribeWithSelector((set, get) => ({ // ========== 상태 ========== @@ -82,7 +144,7 @@ const usePlaybackTrailStore = create( /** * 토글 ON/OFF */ - setEnabled: (enabled) => { + setEnabled: (enabled: boolean) => { if (!enabled) { set({ isEnabled: false, @@ -113,7 +175,7 @@ const usePlaybackTrailStore = create( * true: 궤적 클리어 + 기록 중단 * false: 기록 재개 (재생 상태이면 자동으로 다시 그려짐) */ - setScrubbing: (scrubbing) => { + setScrubbing: (scrubbing: boolean) => { if (scrubbing) { set({ isScrubbing: true, @@ -130,9 +192,8 @@ const usePlaybackTrailStore = create( /** * 선종 필터와 궤적 동기화 * activeKindCodes에 포함되지 않는 선종의 궤적을 즉시 제거 - * @param {Set} activeKindCodes - 현재 활성화된 선종 코드 Set */ - removeTrailsByFilter: (activeKindCodes) => { + removeTrailsByFilter: (activeKindCodes: Set) => { const state = get(); if (state.trails.size === 0) return; @@ -162,9 +223,8 @@ const usePlaybackTrailStore = create( /** * 프레임 기록 (매 렌더링마다 호출) * 거리 기반 필터: 이전 위치와 비교해 MIN_TRAIL_DISTANCE_SQ 미만이면 스킵 - * @param {Array<{vesselId: string, lon: number, lat: number, shipKindCode: string}>} positions */ - recordFrame: (positions) => { + recordFrame: (positions: RecordFramePosition[]) => { const state = get(); if (!state.isEnabled || state.isScrubbing || positions.length === 0) return; @@ -236,11 +296,10 @@ const usePlaybackTrailStore = create( /** * 모든 가시 포인트 반환 (렌더링용) - * @returns {Array} */ - getVisiblePoints: () => { + getVisiblePoints: (): TrailPoint[] => { const state = get(); - const result = []; + const result: TrailPoint[] = []; state.trails.forEach((points) => { for (let i = 0; i < points.length; i++) { @@ -257,7 +316,7 @@ const usePlaybackTrailStore = create( /** * 포인트 투명도 계산 (0~1) */ - getOpacity: (pointFrameIndex) => { + getOpacity: (pointFrameIndex: number): number => { const state = get(); const frameAge = state.frameIndex - pointFrameIndex; if (frameAge >= state.maxFrames) return 0; @@ -268,7 +327,7 @@ const usePlaybackTrailStore = create( /** * 포인트 크기 계산 (px) */ - getPointSize: (pointFrameIndex) => { + getPointSize: (pointFrameIndex: number): number => { const state = get(); const frameAge = state.frameIndex - pointFrameIndex; if (frameAge >= state.maxFrames) return state.minPointSize; @@ -280,7 +339,7 @@ const usePlaybackTrailStore = create( /** * 재생 배속 업데이트 (maxFrames 자동 재계산) */ - updatePlaybackSpeed: (speed) => { + updatePlaybackSpeed: (speed: number) => { set({ playbackSpeed: speed, maxFrames: calculateMaxFrames(speed), @@ -294,7 +353,7 @@ const usePlaybackTrailStore = create( /** * 설정 변경 */ - setConfig: (config) => { + setConfig: (config: TrailConfig) => { set({ maxFrames: config.maxFrames ?? get().maxFrames, maxPointSize: config.maxPointSize ?? get().maxPointSize, diff --git a/src/replay/stores/replayStore.js b/src/replay/stores/replayStore.ts similarity index 77% rename from src/replay/stores/replayStore.js rename to src/replay/stores/replayStore.ts index e5bef0fc..f1a2ee56 100644 --- a/src/replay/stores/replayStore.js +++ b/src/replay/stores/replayStore.ts @@ -8,9 +8,15 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import { ConnectionState, + ConnectionStateType, VesselState, + VesselStateType, FilterModuleType, DEFAULT_FILTER_MODULE, + FilterModules, + FilterModuleConfig, + FilterModuleTypeValue, + TrackQueryRequest, } from '../types/replay.types'; import useMergedTrackStore from './mergedTrackStore'; import usePlaybackTrailStore from './playbackTrailStore'; @@ -31,10 +37,73 @@ import { SIGNAL_KIND_CODE_BUOY, } from '../../types/constants'; +// ===== 인터페이스 ===== + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface Viewport { [key: string]: any; } // TODO: OL viewport type + +interface ReplayState { + // ===== 쿼리/연결 상태 ===== + currentQuery: TrackQueryRequest | null; + queryId: string | null; + connectionState: ConnectionStateType; + queryCompleted: boolean; + + // ===== 진행률 ===== + progress: number; + receivedChunks: number; + totalChunks: number | null; + lastReceivedTimestamp: number | null; + + // ===== 필터 ===== + sigSrcCdFilter: Set; + shipKindCodeFilter: Set; + + // ===== 선박 상태 ===== + vesselStates: Map; + deletedVesselIds: Set; + selectedVesselIds: Set; + + // ===== 필터 모듈 (3계층) ===== + filterModules: FilterModules; + + // ===== 뷰포트 ===== + currentViewport: Viewport | null; + currentZoomLevel: number; + + // ===== 리플레이 선종별 카운트 ===== + replayShipCounts: Record; + replayTotalCount: number; + + // ===== 하이라이트 ===== + highlightedVesselId: string | null; + + // ===== 액션 ===== + setCurrentQuery: (query: TrackQueryRequest | null) => void; + setQueryId: (queryId: string | null) => void; + setConnectionState: (state: ConnectionStateType) => void; + setQueryCompleted: (completed: boolean) => void; + updateProgress: (received: number, total: number | null, timestamp: number) => void; + setProgress: (progress: number) => void; + toggleSigSrcCd: (code: string) => void; + toggleShipKindCode: (code: string) => void; + setVesselState: (vesselId: string, state: VesselStateType) => void; + getVesselState: (vesselId: string) => VesselStateType; + toggleVesselState: (vesselId: string, targetState: VesselStateType) => void; + clearVesselState: (vesselId: string) => void; + updateFilterModule: (moduleId: FilterModuleTypeValue, config: Partial) => void; + setFilterModuleAll: (moduleId: FilterModuleTypeValue, enabled: boolean) => void; + setReplayShipCounts: (counts: Record) => void; + setHighlightedVesselId: (vesselId: string | null) => void; + setViewport: (viewport: Viewport | null) => void; + setZoomLevel: (zoom: number) => void; + reset: () => void; +} + /** * 초기 선종별 카운트 (리플레이용) */ -const initialReplayShipCounts = { +const initialReplayShipCounts: Record = { [SIGNAL_KIND_CODE_FISHING]: 0, [SIGNAL_KIND_CODE_KCGV]: 0, [SIGNAL_KIND_CODE_PASSENGER]: 0, @@ -74,7 +143,7 @@ const initialShipKindCodeFilter = new Set([ /** * ReplayStore */ -const useReplayStore = create( +const useReplayStore = create()( subscribeWithSelector((set, get) => ({ // ===== 쿼리/연결 상태 ===== currentQuery: null, // TrackQueryRequest @@ -142,7 +211,7 @@ const useReplayStore = create( receivedChunks: received, totalChunks: total, lastReceivedTimestamp: timestamp, - progress: total > 0 ? (received / total) * 100 : 0, + progress: total !== null && total > 0 ? (received / total) * 100 : 0, }), setProgress: (progress) => set({ progress }), diff --git a/src/replay/types/replay.types.js b/src/replay/types/replay.types.ts similarity index 55% rename from src/replay/types/replay.types.js rename to src/replay/types/replay.types.ts index 30e9661e..d229dffc 100644 --- a/src/replay/types/replay.types.js +++ b/src/replay/types/replay.types.ts @@ -11,7 +11,9 @@ export const ConnectionState = { CONNECTING: 'CONNECTING', CONNECTED: 'CONNECTED', ERROR: 'ERROR', -}; +} as const; + +export type ConnectionStateType = (typeof ConnectionState)[keyof typeof ConnectionState]; // ===================================== // 쿼리 상태 @@ -21,7 +23,9 @@ export const QueryStatus = { COMPLETED: 'COMPLETED', ERROR: 'ERROR', CANCELLED: 'CANCELLED', -}; +} as const; + +export type QueryStatusType = (typeof QueryStatus)[keyof typeof QueryStatus]; // ===================================== // 선박 상태 (기본/선택/삭제) @@ -30,7 +34,9 @@ export const VesselState = { NORMAL: 'NORMAL', SELECTED: 'SELECTED', DELETED: 'DELETED', -}; +} as const; + +export type VesselStateType = (typeof VesselState)[keyof typeof VesselState]; // ===================================== // 간소화 모드 @@ -39,12 +45,12 @@ export const SimplificationMode = { AUTO: 'AUTO', // 자동 (zoom 기반) ADAPTIVE: 'ADAPTIVE', // 적응형 AGGRESSIVE: 'AGGRESSIVE', // 공격적 (최대 압축) -}; +} as const; // ===================================== // 배속 옵션 // ===================================== -export const PLAYBACK_SPEEDS = [1, 10, 50, 100, 500, 1000]; +export const PLAYBACK_SPEEDS: readonly number[] = [1, 10, 50, 100, 500, 1000]; // ===================================== // 필터 모듈 타입 @@ -53,17 +59,94 @@ export const FilterModuleType = { CUSTOM: 'custom', // 선박 아이콘 PATH: 'path', // 항적 라인 LABEL: 'label', // 라벨 -}; +} as const; + +export type FilterModuleTypeValue = (typeof FilterModuleType)[keyof typeof FilterModuleType]; + +// ===================================== +// 필터 모듈 설정 인터페이스 +// ===================================== +export interface FilterModuleConfig { + showNormal: boolean; + showSelected: boolean; + showDeleted: boolean; +} + +export interface FilterModules { + [FilterModuleType.CUSTOM]: FilterModuleConfig; + [FilterModuleType.PATH]: FilterModuleConfig; + [FilterModuleType.LABEL]: FilterModuleConfig; +} // ===================================== // 기본 필터 설정 // ===================================== -export const DEFAULT_FILTER_MODULE = { +export const DEFAULT_FILTER_MODULE: FilterModuleConfig = { showNormal: true, showSelected: true, showDeleted: false, }; +// ===================================== +// 트랙 관련 인터페이스 +// ===================================== + +export interface TrackChunkResponse { + queryId: string; + chunkIndex: number; + totalChunks?: number | null; + tracks?: TrackData[]; + mergedTracks?: TrackData[]; + compactTracks?: TrackData[]; + isLastChunk?: boolean; + metadata?: Record | null; + estimatedSize?: number; +} + +export interface TrackData { + vesselId: string; + sigSrcCd?: string; + targetId?: string; + shipName?: string; + shipKindCode?: string; + nationalCode?: string; + geometry?: [number, number][]; + timestamps?: (string | number)[]; + speeds?: number[]; + totalDistance?: number; + maxSpeed?: number; + avgSpeed?: number; +} + +export interface QueryStatusUpdate { + queryId: string; + status: QueryStatusType; + error?: string; +} + +export interface NormalizedChunkResponse { + queryId: string; + chunkIndex: number; + totalChunks: number | null; + tracks: TrackData[]; + estimatedSize: number; + metadata: Record | null; +} + +export interface TrackQueryRequest { + startTime: string; + endTime: string; + vesselIds?: string[]; + chunkedMode?: boolean; + chunkSize?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + viewport?: any; // TODO: OL viewport type + simplificationMode?: string; + zoomLevel?: number; + minDistance?: number; + isIntegration?: string; +} + // ===================================== // 요청/응답 타입 체크 // ===================================== @@ -71,28 +154,30 @@ export const DEFAULT_FILTER_MODULE = { /** * TrackChunkResponse 타입 체크 */ -export function isTrackChunkResponse(data) { +export function isTrackChunkResponse(data: unknown): data is TrackChunkResponse { return ( - data && + data !== null && + data !== undefined && typeof data === 'object' && - typeof data.queryId === 'string' && - typeof data.chunkIndex === 'number' && - (Array.isArray(data.tracks) || - Array.isArray(data.mergedTracks) || - Array.isArray(data.compactTracks)) + typeof (data as TrackChunkResponse).queryId === 'string' && + typeof (data as TrackChunkResponse).chunkIndex === 'number' && + (Array.isArray((data as TrackChunkResponse).tracks) || + Array.isArray((data as TrackChunkResponse).mergedTracks) || + Array.isArray((data as TrackChunkResponse).compactTracks)) ); } /** * QueryStatusUpdate 타입 체크 */ -export function isQueryStatusUpdate(data) { +export function isQueryStatusUpdate(data: unknown): data is QueryStatusUpdate { return ( - data && + data !== null && + data !== undefined && typeof data === 'object' && - typeof data.queryId === 'string' && - typeof data.status === 'string' && - Object.values(QueryStatus).includes(data.status) + typeof (data as QueryStatusUpdate).queryId === 'string' && + typeof (data as QueryStatusUpdate).status === 'string' && + (Object.values(QueryStatus) as string[]).includes((data as QueryStatusUpdate).status) ); } @@ -104,7 +189,7 @@ export function isQueryStatusUpdate(data) { * 청크 응답에서 tracks 추출 * 다양한 필드명 지원 (tracks, mergedTracks, compactTracks) */ -export function extractTracks(chunkResponse) { +export function extractTracks(chunkResponse: TrackChunkResponse): TrackData[] { return ( chunkResponse.tracks || chunkResponse.mergedTracks || @@ -116,7 +201,7 @@ export function extractTracks(chunkResponse) { /** * 청크 응답 정규화 */ -export function normalizeChunkResponse(raw) { +export function normalizeChunkResponse(raw: TrackChunkResponse): NormalizedChunkResponse { return { queryId: raw.queryId, chunkIndex: raw.chunkIndex, @@ -140,7 +225,7 @@ export function normalizeChunkResponse(raw) { * - KST 문자열 ('YYYY-MM-DD HH:mm:ss') → 브라우저 로컬 시간대로 해석 * - ISO 형식 → Date.parse() */ -export function parseTimestamp(timestamp) { +export function parseTimestamp(timestamp: string | number): number { if (typeof timestamp === 'number') { // 10억보다 작으면 초 단위로 간주 (2001년 9월 이전은 초 단위) if (timestamp < 10000000000) { @@ -174,7 +259,7 @@ export function parseTimestamp(timestamp) { /** * 배속 라벨 생성 */ -export function getSpeedLabel(speed) { +export function getSpeedLabel(speed: number): string { if (speed === 1) return '1x'; if (speed < 1000) return `${speed}x`; return `${speed / 1000}k`; @@ -183,7 +268,7 @@ export function getSpeedLabel(speed) { /** * 선박 종류 한글 라벨 */ -export const SHIP_KIND_LABELS = { +export const SHIP_KIND_LABELS: Record = { '000020': '어선', '000021': '경비함정', '000022': '여객선', @@ -197,7 +282,7 @@ export const SHIP_KIND_LABELS = { /** * 신호원 한글 라벨 */ -export const SIGNAL_SOURCE_LABELS = { +export const SIGNAL_SOURCE_LABELS: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', diff --git a/src/replay/utils/replayLayerRegistry.js b/src/replay/utils/replayLayerRegistry.ts similarity index 65% rename from src/replay/utils/replayLayerRegistry.js rename to src/replay/utils/replayLayerRegistry.ts index 399d5d4a..98b52802 100644 --- a/src/replay/utils/replayLayerRegistry.js +++ b/src/replay/utils/replayLayerRegistry.ts @@ -6,14 +6,16 @@ * useShipLayer의 handleBatchRender에서 가져와 deck.gl에 병합 */ -export function registerReplayLayers(layers) { +import type { Layer } from '@deck.gl/core'; + +export function registerReplayLayers(layers: Layer[]): void { window.__replayLayers__ = layers; } -export function getReplayLayers() { +export function getReplayLayers(): Layer[] { return window.__replayLayers__ || []; } -export function unregisterReplayLayers() { +export function unregisterReplayLayers(): void { window.__replayLayers__ = []; } diff --git a/src/stores/authStore.js b/src/stores/authStore.ts similarity index 74% rename from src/stores/authStore.js rename to src/stores/authStore.ts index 16ce87e6..531b7e2c 100644 --- a/src/stores/authStore.js +++ b/src/stores/authStore.ts @@ -1,8 +1,39 @@ import { create } from 'zustand'; import { SESSION_TIMEOUT_MS } from '../types/constants'; +/** 사용자 정보 */ +interface User { + userName: string; + userId: string; + groupId: string; + loginDate: number; + accountNo: string | null; + accountRoll: string | null; + lat: string | null; + lon: string | null; + shipName: string | null; + targetId: string | null; +} + +/** 세션 체크 결과 */ +interface SessionCheckResult { + valid: boolean; + reason?: string; +} + +/** 인증 스토어 상태 */ +interface AuthStoreState { + // state + user: User | null; + isAuthenticated: boolean; + isChecking: boolean; + // actions + checkSession: () => SessionCheckResult; + handleSessionExpired: () => void; +} + /** 로컬 개발 모의 사용자 (포트가 달라 localStorage 공유 불가) */ -const DEV_MOCK_USER = { +const DEV_MOCK_USER: User = { userName: 'DevUser', userId: 'dev', groupId: '2', @@ -19,7 +50,7 @@ const DEV_MOCK_USER = { * localStorage에서 사용자 정보 읽기 * 메인 프로젝트(mda-react-front) 로그인 시 저장된 값 */ -function readUserFromStorage() { +function readUserFromStorage(): User | null { const userName = localStorage.getItem('userName'); const userId = localStorage.getItem('userId'); const groupId = localStorage.getItem('groupId'); @@ -48,7 +79,7 @@ function readUserFromStorage() { }; } -export const useAuthStore = create((set) => ({ +export const useAuthStore = create()((set) => ({ user: null, isAuthenticated: false, isChecking: true, diff --git a/src/stores/favoriteStore.js b/src/stores/favoriteStore.ts similarity index 60% rename from src/stores/favoriteStore.js rename to src/stores/favoriteStore.ts index f1415eb7..709a6de6 100644 --- a/src/stores/favoriteStore.js +++ b/src/stores/favoriteStore.ts @@ -1,11 +1,42 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +/** 관심선박 API 응답 아이템 */ +interface FavoriteItem { + signalSourceCode?: string; + targetId?: string; + [key: string]: unknown; +} + +/** 관심구역 API 응답 아이템 */ +interface RealmItem { + [key: string]: unknown; +} + /** * 관심선박 + 관심구역 스토어 * 참조: mda-react-front/src/shared/model/favoriteStore.ts */ -const useFavoriteStore = create(subscribeWithSelector((set) => ({ +interface FavoriteStoreState { + // === 관심선박 === + favoriteList: FavoriteItem[]; + favoriteSet: Set; + isFavoriteEnabled: boolean; + // === 관심구역 === + realmList: RealmItem[]; + isRealmVisible: boolean; + // === 액션 === + /** + * 관심선박 목록 설정 + * API 응답의 item.targetId는 originalTargetId에 해당 + */ + setFavoriteList: (list: FavoriteItem[]) => void; + toggleFavoriteEnabled: () => void; + setRealmList: (list: RealmItem[]) => void; + toggleRealmVisible: () => void; +} + +const useFavoriteStore = create()(subscribeWithSelector((set) => ({ // === 관심선박 === favoriteList: [], // API 원본 배열 favoriteSet: new Set(), // O(1) lookup: signalSourceCode_originalTargetId @@ -17,12 +48,8 @@ const useFavoriteStore = create(subscribeWithSelector((set) => ({ // === 액션 === - /** - * 관심선박 목록 설정 - * API 응답의 item.targetId는 originalTargetId에 해당 - */ setFavoriteList: (list) => { - const newSet = new Set(); + const newSet = new Set(); list.forEach((item) => { if (item.signalSourceCode && item.targetId) { newSet.add(`${item.signalSourceCode}_${item.targetId}`); diff --git a/src/stores/mapStore.js b/src/stores/mapStore.ts similarity index 58% rename from src/stores/mapStore.js rename to src/stores/mapStore.ts index 09ef5ee5..bf947ee1 100644 --- a/src/stores/mapStore.js +++ b/src/stores/mapStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +import type maplibregl from 'maplibre-gl'; /** * 배경지도 타입 @@ -11,7 +12,9 @@ export const BASE_MAP_TYPES = { NORMAL: 'normal', ENC: 'enc', DARK: 'dark', -}; +} as const; + +type BaseMapType = typeof BASE_MAP_TYPES[keyof typeof BASE_MAP_TYPES]; /** * 테마 타입 (배경지도에 연동) @@ -21,22 +24,34 @@ export const BASE_MAP_TYPES = { export const THEME_TYPES = { LIGHT: 'light', DARK: 'dark', -}; +} as const; + +type ThemeType = typeof THEME_TYPES[keyof typeof THEME_TYPES]; /** * 배경지도 -> 테마 매핑 */ -const BASE_MAP_TO_THEME = { +const BASE_MAP_TO_THEME: Record = { [BASE_MAP_TYPES.NORMAL]: THEME_TYPES.LIGHT, [BASE_MAP_TYPES.ENC]: THEME_TYPES.LIGHT, [BASE_MAP_TYPES.DARK]: THEME_TYPES.DARK, }; +/** RGBA 색상 (4-tuple) */ +type RgbaColor = [number, number, number, number]; + +interface ThemeColorSet { + shipLabel: RgbaColor; + shipLabelOutline: RgbaColor; + speedVector: RgbaColor; + shipDim: RgbaColor; +} + /** * 테마별 색상 정의 * - 선박 레이어에서 사용 */ -export const THEME_COLORS = { +export const THEME_COLORS: Record = { [THEME_TYPES.LIGHT]: { shipLabel: [30, 30, 30, 255], shipLabelOutline: [255, 255, 255, 255], @@ -51,10 +66,55 @@ export const THEME_COLORS = { }, }; +type MeasureTool = 'distance' | 'area' | 'rangeRing'; +type AreaShape = 'circle' | 'rectangle' | 'Box' | 'Polygon' | 'Circle'; + +interface LayerVisibility { + baseMap: boolean; + ships: boolean; + weather: boolean; + satellite: boolean; +} + +interface MapStoreState { + // 지도 인스턴스 (MapLibre GL JS) + map: maplibregl.Map | null; + setMap: (map: maplibregl.Map | null) => void; + + // 배경지도 타입 + baseMapType: BaseMapType; + setBaseMapType: (type: BaseMapType) => void; + + // 테마 셀렉터 + getTheme: () => ThemeType; + getThemeColors: () => ThemeColorSet; + + // 줌 레벨 + zoom: number; + setZoom: (zoom: number) => void; + zoomIn: () => void; + zoomOut: () => void; + + // 중심 좌표 [lon, lat] + center: [number, number]; + setCenter: (center: [number, number]) => void; + + // 측정 도구 + activeMeasureTool: MeasureTool | null; + areaShape: AreaShape | null; + setMeasureTool: (tool: MeasureTool) => void; + setAreaShape: (shape: AreaShape) => void; + clearMeasure: () => void; + + // 레이어 가시성 + layerVisibility: LayerVisibility; + toggleLayer: (layerName: keyof LayerVisibility) => void; +} + /** * 지도 상태 관리 스토어 */ -export const useMapStore = create(subscribeWithSelector((set, get) => ({ +export const useMapStore = create()(subscribeWithSelector((set, get) => ({ // 지도 인스턴스 map: null, setMap: (map) => set({ map }), @@ -77,7 +137,7 @@ export const useMapStore = create(subscribeWithSelector((set, get) => ({ const { map, zoom } = get(); if (map && zoom < 17) { const newZoom = zoom + 1; - map.getView().setZoom(newZoom); + map.zoomTo(newZoom - 1, { duration: 200 }); // -1: OL→MapLibre 줌 규약 set({ zoom: newZoom }); } }, @@ -86,7 +146,7 @@ export const useMapStore = create(subscribeWithSelector((set, get) => ({ const { map, zoom } = get(); if (map && zoom > 0) { const newZoom = zoom - 1; - map.getView().setZoom(newZoom); + map.zoomTo(newZoom - 1, { duration: 200 }); // -1: OL→MapLibre 줌 규약 set({ zoom: newZoom }); } }, diff --git a/src/stores/shipStore.js b/src/stores/shipStore.ts similarity index 77% rename from src/stores/shipStore.js rename to src/stores/shipStore.ts index b9b5d5f9..0b12360b 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.ts @@ -29,11 +29,12 @@ import { SOURCE_TO_ACTIVE_KEY, USER_SETTING_CODES, } from '../types/constants'; +import type { ShipFeature } from '../types/ship'; // ===================== // 국적 코드 매핑 (ShipBatchRenderer.js와 동일) // ===================== -function mapNationalCode(nationalCode) { +function mapNationalCode(nationalCode: string | undefined): string { if (!nationalCode) return 'OTHER'; const code = nationalCode.toUpperCase(); if (code === 'KR' || code === 'KOR' || code === '440') return 'KR'; @@ -44,10 +45,10 @@ function mapNationalCode(nationalCode) { } // ===================== -// 서버 수신시간 파싱 (receivedTime → ms timestamp) +// 서버 수신시간 파싱 (receivedTime -> ms timestamp) // 형식: "YYYYMMDDHHmmss" // ===================== -function parseReceivedTime(receivedTime) { +function parseReceivedTime(receivedTime: string | undefined): number { if (!receivedTime || receivedTime.length < 14) return Date.now(); const y = receivedTime.slice(0, 4); const M = receivedTime.slice(4, 6); @@ -62,53 +63,34 @@ function parseReceivedTime(receivedTime) { // ===================== // 타임아웃 상수 (카운트 사이클에서 상태 전환/삭제 판정) // ===================== -// -// ■ 영해안 (LOST=0, Inshore) -// 국내 직접 수집수단(AIS 기지국, VTS 등)이 커버하는 해역. -// 수신 주기가 짧으므로(수 초~수 분) 12분 무수신 시 정상 이탈로 판단하여 삭제. -// -// ■ 영해밖 (LOST=1, Offshore) -// 직접 수집수단이 닿지 않아 위성 AIS(S-AIS) 등 간접 수단에 의존. -// 위성 AIS는 선박 위치·궤도에 따라 수신 간격이 30분~최대 1시간까지 벌어질 수 있어, -// 유효한 항해 중인 선박이 다크시그널로 오판되지 않도록 65분(3900초)으로 설정. -// -// ■ 레이더 (단독, 비통합) -// 레이더 신호는 실시간 회전 주기(수 초)에 맞춰 갱신되므로 타임아웃을 짧게 유지. -// 함정용은 /topic/ship-throttled-60s 채널 기반이므로 90초로 설정. -// -// 참조: mda-react-front/src/common/deck.ts -// 추후 사용자 설정 화면에서 커스텀 가능하도록 상수로 분리. -// ===================== -const INSHORE_TIMEOUT_MS = 12 * 60 * 1000; // 720초 (12분) — 영해안: LOST=0, 무수신 시 삭제 -const OFFSHORE_TIMEOUT_MS = 65 * 60 * 1000; // 3900초 (65분) — 영해밖: LOST=1, 무수신 시 다크시그널 전환 -const RADAR_TIMEOUT_MS = 60 * 1000; // 90초 — 단독 레이더 비통합, 무수신 시 삭제 +const INSHORE_TIMEOUT_MS = 12 * 60 * 1000; // 720초 (12분) -- 영해안: LOST=0, 무수신 시 삭제 +const OFFSHORE_TIMEOUT_MS = 65 * 60 * 1000; // 3900초 (65분) -- 영해밖: LOST=1, 무수신 시 다크시그널 전환 +const RADAR_TIMEOUT_MS = 60 * 1000; // 90초 -- 단독 레이더 비통합, 무수신 시 삭제 const SIGNAL_SOURCE_RADAR = '000005'; // ===================== // 장비 활성 상태 판단 -// 참조: mda-react-front/src/common/deck.ts - isAnyEquipmentActive // AVETDR 6개 장비 중 하나라도 '1'(활성)이면 true // ===================== -const EQUIPMENT_KEYS = ['ais', 'vpass', 'enav', 'vtsAis', 'dMfHf', 'vtsRadar']; +const EQUIPMENT_KEYS = ['ais', 'vpass', 'enav', 'vtsAis', 'dMfHf', 'vtsRadar'] as const; -function isAnyEquipmentActive(ship) { +function isAnyEquipmentActive(ship: ShipFeature): boolean { return EQUIPMENT_KEYS.some(key => ship[key] === '1'); } /** * 동적 대표 Set 생성 - * 참조: mda-react-front/docs/dynamic-priority.md §4.1 + * 참조: mda-react-front/docs/dynamic-priority.md 4.1 * * O(N) 단일 패스로 통합선박별 동적 대표 featureId의 Set 반환. * 통합선박(targetId에 '_' 포함)만 처리하며, 단독선박은 스킵. - * - * @param {Map} features - 전체 선박 feature 맵 - * @param {Set} enabledSources - 필터에서 ON된 신호원 코드 Set - * @param {Set} darkSignalIds - 다크시그널 선박 ID Set - * @returns {Set} 동적 대표 featureId 집합 */ -function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { - const bestByTargetId = new Map(); // targetId → { featureId, rank } +function buildDynamicPrioritySet( + features: Map, + enabledSources: Set, + darkSignalIds: Set, +): Set { + const bestByTargetId = new Map(); features.forEach((ship, featureId) => { // 다크시그널 스킵 @@ -134,7 +116,7 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { } }); - const result = new Set(); + const result = new Set(); bestByTargetId.forEach(({ featureId }) => result.add(featureId)); return result; } @@ -142,7 +124,7 @@ function buildDynamicPrioritySet(features, enabledSources, darkSignalIds) { /** * 초기 선박 종류별 카운트 */ -const initialKindCounts = { +const initialKindCounts: Record = { [SIGNAL_KIND_CODE_FISHING]: 0, [SIGNAL_KIND_CODE_KCGV]: 0, [SIGNAL_KIND_CODE_PASSENGER]: 0, @@ -157,7 +139,7 @@ const initialKindCounts = { /** * 초기 선박 종류별 표시 설정 */ -const initialKindVisibility = { +const initialKindVisibility: Record = { [SIGNAL_KIND_CODE_FISHING]: true, [SIGNAL_KIND_CODE_KCGV]: true, [SIGNAL_KIND_CODE_PASSENGER]: true, @@ -171,7 +153,7 @@ const initialKindVisibility = { /** * 초기 신호원별 표시 설정 */ -const initialSourceVisibility = { +const initialSourceVisibility: Record = { [SIGNAL_SOURCE_CODE_AIS]: true, [SIGNAL_SOURCE_CODE_VPASS]: true, [SIGNAL_SOURCE_CODE_ENAV]: true, @@ -183,7 +165,7 @@ const initialSourceVisibility = { /** * 초기 국적별 표시 설정 */ -const initialNationalVisibility = { +const initialNationalVisibility: Record = { [NATIONAL_CODE_KR]: true, [NATIONAL_CODE_CN]: true, [NATIONAL_CODE_JP]: true, @@ -191,23 +173,150 @@ const initialNationalVisibility = { [NATIONAL_CODE_OTHER]: true, }; +// ===================== +// AI 모드 토글 인터페이스 +// ===================== +interface AiModeVisibility { + mmsiChange: boolean; + chinaPermission: boolean; + govShip: boolean; + sseZoneContact: boolean; + nonPermission: boolean; + northKoreaAi: boolean; +} + +// ===================== +// 선명표시 옵션 인터페이스 +// ===================== +interface LabelOptions { + showShipName: boolean; + showSpeedVector: boolean; + showShipSize: boolean; + showSignalStatus: boolean; +} + +// ===================== +// 컨텍스트 메뉴 / 호버 / 모달 인터페이스 +// ===================== +interface ContextMenuInfo { + x: number; + y: number; + ships: ShipFeature[]; +} + +interface HoverInfo { + ship: ShipFeature; + x: number; + y: number; +} + +interface DetailModal { + ship: ShipFeature; + id: string; + initialPos: { x: number; y: number }; +} + +// ===================== +// 필터 설정 아이템 인터페이스 +// ===================== +interface FilterSettingItem { + settingCode: string; + settingValue: string; +} + +interface FilterSettingOutput { + code: string; + value: string; +} + +// ===================== +// 선박 스토어 State +// ===================== +interface ShipStoreState { + // 상태 + features: Map; + darkSignalIds: Set; + kindCounts: Record; + kindVisibility: Record; + sourceVisibility: Record; + nationalVisibility: Record; + selectedShipId: string | null; + selectedShipIds: string[]; + contextMenu: ContextMenuInfo | null; + hoverInfo: HoverInfo | null; + detailModals: DetailModal[]; + lastModalPos: { x: number; y: number } | null; + aiModeVisibility: AiModeVisibility; + hazardVisible: boolean; + darkSignalVisible: boolean; + darkSignalCount: number; + isShipVisible: boolean; + isIntegrate: boolean; + showLabels: boolean; + labelOptions: LabelOptions; + isConnected: boolean; + showLegend: boolean; + changedIds: Set; + totalCount: number; + + // 액션 + mergeFeatures: (ships: ShipFeature[]) => void; + applyCleanup: (deleteIds: string[], darkSignalConvertIds: string[]) => void; + addOrUpdateFeature: (ship: ShipFeature) => void; + deleteFeatureById: (featureId: string) => void; + deleteFeaturesByIds: (featureIds: string[]) => void; + toggleKindVisibility: (kindCode: string) => void; + toggleSourceVisibility: (sourceCode: string) => void; + toggleNationalVisibility: (nationalCode: string) => void; + toggleAiModeEnabled: () => void; + toggleAiModeVisibility: (key: keyof AiModeVisibility) => void; + toggleHazardVisible: () => void; + toggleDarkSignalVisible: () => void; + clearDarkSignals: () => void; + toggleShipVisible: () => void; + toggleShowLabels: () => void; + toggleLabelOption: (optionKey: keyof LabelOptions) => void; + setLabelOptions: (options: Partial) => void; + toggleIntegrate: () => void; + selectShip: (featureId: string | null) => void; + setSelectedShipIds: (ids: string[]) => void; + clearSelectedShips: () => void; + openContextMenu: (info: ContextMenuInfo) => void; + closeContextMenu: () => void; + syncSelectedWithIntegrateMode: (toIntegrateMode: boolean) => void; + setHoverInfo: (info: HoverInfo | null) => void; + openDetailModal: (ship: ShipFeature) => void; + updateModalPos: (modalId: string, pos: { x: number; y: number }) => void; + closeDetailModal: (modalId: string) => void; + closeAllDetailModals: () => void; + setConnected: (connected: boolean) => void; + toggleShowLegend: () => void; + applyFilterSettings: (filterArray: FilterSettingItem[]) => void; + buildFilterSettings: () => FilterSettingOutput[]; + clearFeatures: () => void; + clearChangedIds: () => void; + setKindCounts: (counts: Record) => void; + getVisibleShips: () => ShipFeature[]; + getSelectedShip: () => ShipFeature | undefined; + getSelectedShips: () => ShipFeature[]; + getDownloadShips: () => ShipFeature[]; +} + /** * 선박 스토어 */ -const useShipStore = create(subscribeWithSelector((set, get) => ({ +const useShipStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 상태 (State) // ===================== /** 선박 데이터 맵 (featureId -> shipData), featureId = signalSourceCode + targetId - * ※ immutable 패턴: 변경 시 new Map() 생성 → Zustand 참조 비교로 변경 감지 - * (메인 프로젝트 동일 구조) */ - features: new Map(), + * immutable 패턴: 변경 시 new Map() 생성 -> Zustand 참조 비교로 변경 감지 */ + features: new Map(), - /** 다크시그널 선박 ID Set (features와 별도 관리, 메인 프로젝트 동일 구조) - * 참조: mda-react-front/src/shared/model/deckStore.ts - darkSignalIds - * ※ immutable 패턴: 변경 시 new Set() 생성 → Zustand 참조 비교로 변경 감지 */ - darkSignalIds: new Set(), + /** 다크시그널 선박 ID Set (features와 별도 관리) + * immutable 패턴: 변경 시 new Set() 생성 -> Zustand 참조 비교로 변경 감지 */ + darkSignalIds: new Set(), /** 선박 종류별 카운트 */ kindCounts: { ...initialKindCounts }, @@ -227,26 +336,26 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** Ctrl+Drag 다중 선택된 featureId 배열 (제한 없음) */ selectedShipIds: [], - /** 컨텍스트 메뉴 상태 { x, y, ships: [] } | null */ + /** 컨텍스트 메뉴 상태 */ contextMenu: null, - /** 호버 중인 선박 정보 { ship, x, y } | null */ + /** 호버 중인 선박 정보 */ hoverInfo: null, - /** 상세 모달 배열 (최대 3개) [{ ship, id, initialPos }] */ + /** 상세 모달 배열 (최대 3개) */ detailModals: [], /** 마지막 모달 위치 (새 모달 초기 위치 계산용) */ lastModalPos: null, - /** AI 모드 서브 토글 (메인 토글은 컴포넌트에서 every()로 파생 — 선종/국적/신호와 동일 패턴) */ + /** AI 모드 서브 토글 */ aiModeVisibility: { - mmsiChange: false, // MMSI 변조 - chinaPermission: false, // 중국 허가선박 - govShip: false, // 관공선 - sseZoneContact: false, // 비정상 접촉 - nonPermission: false, // 비정상 선박 - northKoreaAi: false, // 북한선박 + mmsiChange: false, + chinaPermission: false, + govShip: false, + sseZoneContact: false, + nonPermission: false, + northKoreaAi: false, }, /** 위험물 표시 여부 */ @@ -269,10 +378,10 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** 선명표시 옵션 (개발 중 기본 모두 활성화) */ labelOptions: { - showShipName: true, // 선박명 - showSpeedVector: true, // 속도벡터 - showShipSize: true, // 선박크기 - showSignalStatus: false, // 신호상태 + showShipName: true, + showSpeedVector: true, + showShipSize: true, + showSignalStatus: false, }, /** STOMP 연결 상태 */ @@ -282,7 +391,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ showLegend: true, /** 변경된 선박 ID 추적 (렌더링 최적화용) */ - changedIds: new Set(), + changedIds: new Set(), /** 총 선박 수 */ totalCount: 0, @@ -294,10 +403,9 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 여러 선박 데이터 병합 (bulk update) * 카운트는 ShipBatchRenderer의 렌더 사이클에서 계산 (메인 프로젝트 동일) - * @param {Array} ships - 선박 데이터 배열 */ mergeFeatures: (ships) => { - // ※ immutable 패턴: 배치 단위로 변경 후 1회만 new Map()/new Set() 생성 + // immutable 패턴: 배치 단위로 변경 후 1회만 new Map()/new Set() 생성 const state = get(); const newFeatures = new Map(state.features); const newDarkSignalIds = new Set(state.darkSignalIds); @@ -313,16 +421,15 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } // 타임스탬프 비교: 기존 데이터보다 오래된 메시지는 무시 - // 참조: mda-react-front/src/shared/model/deckStore.ts - mergeFeatures (line 163) const newTimestamp = parseReceivedTime(ship.receivedTime); const currentFeature = newFeatures.get(featureId); - if (currentFeature && newTimestamp < currentFeature.receivedTimestamp) { - return; // 이전 시간대 데이터 → 무시 + if (currentFeature && currentFeature.receivedTimestamp !== undefined && newTimestamp < currentFeature.receivedTimestamp) { + return; // 이전 시간대 데이터 -> 무시 } const hasActive = isAnyEquipmentActive(ship); - // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 → 저장하지 않음 (완전 삭제) + // 규칙 1: LOST=0(영해 내) + 모든 장비 비활성 -> 저장하지 않음 (완전 삭제) if (!ship.lost && !hasActive) { newFeatures.delete(featureId); if (newDarkSignalIds.delete(featureId)) darkChanged = true; @@ -342,7 +449,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ newFeatures.set(featureId, { ...ship, receivedTimestamp: newTimestamp }); }); - // immutable 참조 변경 → Zustand 감지 + // immutable 참조 변경 -> Zustand 감지 set({ features: newFeatures, ...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}), @@ -352,8 +459,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 타임아웃 cleanup 적용 (ShipBatchRenderer에서 호출) * immutable 패턴으로 삭제 + 다크시그널 전환을 한 번에 처리 - * @param {Array} deleteIds - 삭제할 featureId 배열 - * @param {Array} darkSignalConvertIds - 다크시그널로 전환할 featureId 배열 */ applyCleanup: (deleteIds, darkSignalConvertIds) => { if (deleteIds.length === 0 && darkSignalConvertIds.length === 0) return; @@ -374,7 +479,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 단일 선박 추가/업데이트 - * @param {Object} ship - 선박 데이터 */ addOrUpdateFeature: (ship) => { get().mergeFeatures([ship]); @@ -382,13 +486,12 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선박 삭제 - * @param {string} featureId - 삭제할 선박 ID (signalSourceCode + targetId) */ deleteFeatureById: (featureId) => { const state = get(); const newFeatures = new Map(state.features); newFeatures.delete(featureId); - const updates = { features: newFeatures }; + const updates: Partial = { features: newFeatures }; if (state.darkSignalIds.has(featureId)) { const newDark = new Set(state.darkSignalIds); @@ -405,7 +508,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 여러 선박 삭제 - * @param {Array} featureIds - 삭제할 선박 ID 배열 (signalSourceCode + targetId) */ deleteFeaturesByIds: (featureIds) => { const state = get(); @@ -421,14 +523,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ set({ features: newFeatures, ...(darkChanged ? { darkSignalIds: newDarkSignalIds } : {}), - selectedShipId: featureIds.includes(state.selectedShipId) ? null : state.selectedShipId, + selectedShipId: state.selectedShipId !== null && featureIds.includes(state.selectedShipId) ? null : state.selectedShipId, }); }, /** * 선박 종류별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} kindCode - 선박 종류 코드 + * 필터 변경 -> useShipLayer subscription -> immediateRender -> 카운트 재계산 */ toggleKindVisibility: (kindCode) => { set((state) => ({ @@ -441,8 +542,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 신호원별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} sourceCode - 신호원 코드 */ toggleSourceVisibility: (sourceCode) => { set((state) => ({ @@ -455,8 +554,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 국적별 표시 토글 - * 필터 변경 → useShipLayer subscription → immediateRender → 카운트 재계산 - * @param {string} nationalCode - 국적 코드 */ toggleNationalVisibility: (nationalCode) => { set((state) => ({ @@ -468,7 +565,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ }, /** - * 다크시그널 표시 토글 + * AI 모드 전체 토글 */ toggleAiModeEnabled: () => { set((state) => { @@ -512,7 +609,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ state.darkSignalIds.forEach((fid) => { newFeatures.delete(fid); }); - set({ features: newFeatures, darkSignalIds: new Set() }); + set({ features: newFeatures, darkSignalIds: new Set() }); }, /** @@ -534,8 +631,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ }, /** - * 선명표시 옵션 설정 - * @param {string} optionKey - 옵션 키 (showShipName, showSpeedVector, showShipSize, showSignalStatus) + * 선명표시 옵션 토글 */ toggleLabelOption: (optionKey) => { set((state) => ({ @@ -548,7 +644,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선명표시 옵션 직접 설정 - * @param {Object} options - 옵션 객체 */ setLabelOptions: (options) => { set((state) => ({ @@ -572,7 +667,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선박 선택 - * @param {string|null} featureId - 선택할 선박 ID (null이면 선택 해제, signalSourceCode + targetId) */ selectShip: (featureId) => { set({ selectedShipId: featureId }); @@ -580,7 +674,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * Ctrl+Drag 다중 선택 설정 - * @param {Array} ids - featureId 배열 */ setSelectedShipIds: (ids) => set({ selectedShipIds: ids }), @@ -591,7 +684,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 컨텍스트 메뉴 열기 - * @param {{ x: number, y: number, ships: Array }} info */ openContextMenu: (info) => set({ contextMenu: info }), @@ -603,7 +695,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 통합모드 전환 시 selectedShipIds 동기화 * 참조: mda-react-front/src/shared/model/deckStore.ts - syncSelectedFeaturesWithIntegrateMode - * @param {boolean} toIntegrateMode - 전환 후 통합모드 ON 여부 */ syncSelectedWithIntegrateMode: (toIntegrateMode) => { const { selectedShipIds, features } = get(); @@ -615,13 +706,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ { index: 2, signalSourceCode: '000002', dataKey: 'enav' }, { index: 3, signalSourceCode: '000004', dataKey: 'vtsAis' }, { index: 4, signalSourceCode: '000016', dataKey: 'dMfHf' }, - // index 5 = VTS-Radar → 확장 시 제외 - ]; + // index 5 = VTS-Radar -> 확장 시 제외 + ] as const; if (toIntegrateMode) { - // OFF → ON: 개별 장비 → 대표(isPriority) 선박으로 축소 - const newIds = []; - const seenTargetIds = new Set(); + // OFF -> ON: 개별 장비 -> 대표(isPriority) 선박으로 축소 + const newIds: string[] = []; + const seenTargetIds = new Set(); selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -636,7 +727,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ if (seenTargetIds.has(tid)) return; seenTargetIds.add(tid); - let priorityFid = null; + let priorityFid: string | null = null; features.forEach((s, id) => { if (s.targetId === tid && s.isPriority) priorityFid = id; }); @@ -645,8 +736,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ set({ selectedShipIds: newIds }); } else { - // ON → OFF: 대표 선박 → isActive인 개별 장비로 확장 - const newIds = []; + // ON -> OFF: 대표 선박 -> isActive인 개별 장비로 확장 + const newIds: string[] = []; selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -681,7 +772,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 호버 정보 설정 - * @param {Object|null} info - { ship, x, y } 또는 null */ setHoverInfo: (info) => { set({ hoverInfo: info }); @@ -690,8 +780,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 상세 모달 열기 (최대 3개, 4번째부터 FIFO 제거) * 새 모달은 마지막 모달의 현재 위치 기준 우측 140px 오프셋으로 생성 - * 참조: mda-react-front/src/shared/model/deckStore.ts - setAddDetailModal - * @param {Object} ship - 선박 데이터 */ openDetailModal: (ship) => { set((state) => { @@ -707,7 +795,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ f.signalSourceCode !== SIGNAL_SOURCE_CODE_RADAR ) .sort((a, b) => { - // 우선순위 정렬 (낮은 숫자 = 높은 우선순위) const rankA = SOURCE_PRIORITY_RANK[a.signalSourceCode] ?? 99; const rankB = SOURCE_PRIORITY_RANK[b.signalSourceCode] ?? 99; return rankA - rankB; @@ -724,12 +811,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } // 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측 - // 최초 모달은 화면 중앙 근처에서 시작 (가로: 화면 중앙 - 200px, 세로: 100px) const defaultX = typeof window !== 'undefined' ? Math.max(100, (window.innerWidth / 2) - 200) : 400; const basePos = state.lastModalPos || { x: defaultX - 140, y: 100 }; const initialPos = { x: basePos.x + 140, y: basePos.y }; - const newModal = { ship: displayShip, id: displayShip.featureId, initialPos }; + const newModal: DetailModal = { ship: displayShip, id: displayShip.featureId, initialPos }; let modals = [...state.detailModals, newModal]; // 3개 초과 시 가장 오래된 모달 제거 @@ -746,16 +832,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 모달 위치 업데이트 (드래그 후 호출) - * @param {string} modalId - 모달 ID - * @param {{ x: number, y: number }} pos - 현재 위치 */ - updateModalPos: (modalId, pos) => { + updateModalPos: (_modalId, pos) => { set({ lastModalPos: pos }); }, /** * 특정 상세 모달 닫기 - * @param {string} modalId - 모달 ID (featureId) */ closeDetailModal: (modalId) => { set((state) => ({ @@ -772,7 +855,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * STOMP 연결 상태 설정 - * @param {boolean} connected - 연결 상태 */ setConnected: (connected) => { set({ isConnected: connected }); @@ -788,15 +870,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 서버에서 불러온 필터 설정 배열을 스토어에 적용 * 참조: mda-react-front/src/common/userSetting.ts - * @param {Array<{settingCode: string, settingValue: string}>} filterArray */ applyFilterSettings: (filterArray) => { if (!Array.isArray(filterArray) || filterArray.length === 0) return; - const toBoolean = (item) => item?.settingValue === 'true'; + const toBoolean = (item: FilterSettingItem | undefined): boolean => item?.settingValue === 'true'; - // settingCode → settingValue 맵 생성 - const map = {}; + // settingCode -> settingValue 맵 생성 + const map: Record = {}; filterArray.forEach((item) => { if (item?.settingCode) map[item.settingCode] = item; }); @@ -828,7 +909,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ [SIGNAL_SOURCE_CODE_RADAR]: toBoolean(map[USER_SETTING_CODES.RADAR]), }, darkSignalVisible: toBoolean(map[USER_SETTING_CODES.LOST_SIGNAL]), - // AI 모드 (메인 토글은 하위 토글에서 파생 — 서버에 000039가 없을 수 있음) + // AI 모드 (메인 토글은 하위 토글에서 파생) aiModeVisibility: { mmsiChange: toBoolean(map[USER_SETTING_CODES.MMSI_CHANGE]), chinaPermission: toBoolean(map[USER_SETTING_CODES.CHINA_PERMISSION]), @@ -844,7 +925,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 현재 필터 상태를 서버 저장 형식으로 직렬화 - * @returns {Array<{code: string, value: string}>} */ buildFilterSettings: () => { const { kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible, aiModeVisibility, hazardVisible } = get(); @@ -887,8 +967,8 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ */ clearFeatures: () => { set({ - features: new Map(), - darkSignalIds: new Set(), + features: new Map(), + darkSignalIds: new Set(), kindCounts: { ...initialKindCounts }, selectedShipId: null, selectedShipIds: [], @@ -902,12 +982,11 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * 변경 ID 초기화 (렌더링 후 호출) */ clearChangedIds: () => { - set({ changedIds: new Set() }); + set({ changedIds: new Set() }); }, /** * 선박 종류별 카운트 직접 설정 (서버 count 토픽용) - * @param {Object} counts - 종류별 카운트 객체 */ setKindCounts: (counts) => { const totalCount = Object.values(counts).reduce((sum, count) => sum + count, 0); @@ -923,14 +1002,13 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 표시 가능한 선박 목록 (필터 적용) - * @returns {Array} 필터링된 선박 배열 */ getVisibleShips: () => { const state = get(); if (!state.isShipVisible) return []; const { features, darkSignalIds, kindVisibility, sourceVisibility, darkSignalVisible } = state; - const result = []; + const result: ShipFeature[] = []; features.forEach((ship, featureId) => { // 다크시그널은 독립 필터 (선종/신호원/국적 필터 무시) @@ -953,22 +1031,20 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ /** * 선택된 선박 정보 - * @returns {Object|null} 선박 데이터 또는 null */ getSelectedShip: () => { const { features, selectedShipId } = get(); - return selectedShipId ? features.get(selectedShipId) : null; + return selectedShipId ? features.get(selectedShipId) : undefined; }, /** * 선택된 모든 선박 정보 (하이라이트 표시용) * selectedShipIds(박스선택) + detailModals(상세모달) 통합 - * @returns {Array} 선박 데이터 배열 */ getSelectedShips: () => { const { features, selectedShipIds, detailModals } = get(); - const result = []; - const seen = new Set(); + const result: ShipFeature[] = []; + const seen = new Set(); selectedShipIds.forEach((fid) => { const ship = features.get(fid); @@ -1000,7 +1076,6 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ * - 통합 모드: isPriority만 포함 * - 다크시그널: 독립 필터 적용 * - 일반: 선종/신호원/국적 필터 적용 - * @returns {Array} 다운로드용 선박 배열 (downloadTargetId 포함) */ getDownloadShips: () => { const state = get(); @@ -1010,14 +1085,14 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ } = state; // 통합 모드: 동적 우선순위 Set 생성 - let dynamicPrioritySet = null; + let dynamicPrioritySet: Set | null = null; if (isIntegrate) { - const enabledSources = new Set(); + const enabledSources = new Set(); Object.entries(sourceVisibility).forEach(([code, on]) => { if (on) enabledSources.add(code); }); dynamicPrioritySet = buildDynamicPrioritySet(features, enabledSources, darkSignalIds); } - const result = []; + const result: ShipFeature[] = []; features.forEach((ship, featureId) => { // 레이더 항상 제외 @@ -1025,7 +1100,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({ // 통합 모드: 동적 대표만 포함 if (isIntegrate && ship.targetId && ship.targetId.includes('_')) { - if (!dynamicPrioritySet.has(featureId)) return; + if (!dynamicPrioritySet!.has(featureId)) return; } const downloadTargetId = isIntegrate ? ship.targetId : ship.originalTargetId; @@ -1063,4 +1138,16 @@ export { RADAR_TIMEOUT_MS, SIGNAL_SOURCE_RADAR, }; + +export type { + AiModeVisibility, + LabelOptions, + ContextMenuInfo, + HoverInfo, + DetailModal, + FilterSettingItem, + FilterSettingOutput, + ShipStoreState, +}; + export default useShipStore; diff --git a/src/stores/trackStore.js b/src/stores/trackStore.ts similarity index 76% rename from src/stores/trackStore.js rename to src/stores/trackStore.ts index 034391b4..6a1aff52 100644 --- a/src/stores/trackStore.js +++ b/src/stores/trackStore.ts @@ -15,11 +15,13 @@ import { toLocalISOString, buildVesselListForQuery, } from '../api/trackApi'; +import type { ProcessedTrack } from '../areaSearch/stores/areaSearchStore'; +import type { ShipFeature } from '../types/ship'; // ===================== // 선종별 항적 색상 (RGBA) // ===================== -export const SHIP_KIND_TRACK_COLORS = { +export const SHIP_KIND_TRACK_COLORS: Record = { '000020': [25, 116, 25, 150], // 어선 '000021': [0, 41, 255, 150], // 함정 '000022': [176, 42, 42, 150], // 여객선 @@ -28,36 +30,105 @@ export const SHIP_KIND_TRACK_COLORS = { '000025': [92, 30, 224, 150], // 관공선 '000027': [255, 135, 207, 150], // 기타 '000028': [232, 95, 27, 150], // 부이 -}; +} as const; -export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; +export const DEFAULT_TRACK_COLOR: number[] = [128, 128, 128, 150]; /** * 선종코드로 항적 색상 반환 */ -export function getShipKindTrackColor(shipKindCode) { +export function getShipKindTrackColor(shipKindCode: string): number[] { return SHIP_KIND_TRACK_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; } /** 기본 조회 기간 (3일) */ const DEFAULT_QUERY_DAYS = 3; +/** 항적 모달 아이템 */ +interface TrackModal { + ships: ShipFeature[]; + id: string; + isIntegrated: boolean; +} + +/** 현재 위치 보간 결과 */ +interface TrackPosition { + vesselId: string; + lon: number; + lat: number; + heading: number; + speed: number; + shipName: string; + shipKindCode: string; +} + +// ===================== +// 항적 스토어 State +// ===================== +interface TrackStoreState { + // 항적 데이터 + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + disabledSigSrcCds: Set; + + // 시간 범위 + dataStartTime: number; + dataEndTime: number; + requestedStartTime: number; + currentTime: number; + + // 로딩/에러 + isLoading: boolean; + error: string | null; + + // 표시 옵션 + showPoints: boolean; + showVirtualShip: boolean; + showLabels: boolean; + + // 모달 상태 + trackModals: TrackModal[]; + + // 액션 + setTracks: (tracks: ProcessedTrack[], requestedStartTime?: number) => void; + setCurrentTime: (time: number) => void; + setProgressByRatio: (ratio: number) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setShowPoints: (show: boolean) => void; + setShowVirtualShip: (show: boolean) => void; + setShowLabels: (show: boolean) => void; + toggleVesselEnabled: (vesselId: string) => void; + isVesselEnabled: (vesselId: string) => boolean; + toggleEquipment: (sigSrcCd: string) => void; + enableAllEquipment: () => void; + resetEquipmentToDefault: (ship: ShipFeature | null) => void; + isEquipmentEnabled: (sigSrcCd: string) => boolean; + getEnabledTracks: () => ProcessedTrack[]; + getCurrentPositions: () => TrackPosition[]; + getProgress: () => number; + openTrackModal: (ships: ShipFeature[]) => void; + queryTracks: (ships: ShipFeature[], startDate?: Date, endDate?: Date) => Promise; + closeTrackModal: (modalId: string) => void; + reset: () => void; +} + // ===================== // 항적 스토어 // ===================== -const useTrackStore = create(subscribeWithSelector((set, get) => ({ +const useTrackStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 항적 데이터 // ===================== - /** 조회된 항적 배열 (ProcessedTrack[]) */ + /** 조회된 항적 배열 */ tracks: [], /** 비활성화된 선박 ID Set */ - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), /** 비활성화된 장비(신호원) Set - 통합선박 장비필터용 */ - disabledSigSrcCds: new Set(), + disabledSigSrcCds: new Set(), // ===================== // 시간 범위 @@ -92,7 +163,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ // 모달 상태 // ===================== - /** 항적 조회 모달 배열 [{ ships, id, initialPos, isIntegrated }] */ + /** 항적 조회 모달 배열 */ trackModals: [], // ===================== @@ -111,8 +182,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ dataEndTime: 0, requestedStartTime: 0, currentTime: 0, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), }); return; } @@ -135,10 +206,10 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ dataStartTime: minTime, dataEndTime: maxTime, requestedStartTime: requestedStartTime || minTime, - // 핵심 수정: currentTime을 dataEndTime으로 설정 → 전체 항적 즉시 표시 + // 핵심 수정: currentTime을 dataEndTime으로 설정 -> 전체 항적 즉시 표시 currentTime: maxTime, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), error: null, }); }, @@ -210,18 +281,18 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** 장비 전체 활성화 */ enableAllEquipment: () => { - set({ disabledSigSrcCds: new Set(), disabledVesselIds: new Set() }); + set({ disabledSigSrcCds: new Set(), disabledVesselIds: new Set() }); }, /** 장비 기본값 복원 (ship의 active 장비만 활성) */ resetEquipmentToDefault: (ship) => { if (!ship) return; const { tracks } = get(); - const newDisabledSrc = new Set(); - const newDisabledVessels = new Set(); + const newDisabledSrc = new Set(); + const newDisabledVessels = new Set(); // ship 객체의 active 플래그로 판단 - const activeMap = { + const activeMap: Record = { '000001': ship.ais, '000003': ship.vpass, '000002': ship.enav, @@ -258,7 +329,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: () => { const { tracks, currentTime, disabledVesselIds } = get(); - const positions = []; + const positions: TrackPosition[] = []; tracks.forEach((track) => { if (disabledVesselIds.has(track.vesselId)) return; @@ -358,7 +429,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ // 통합선박 여부 판단 const isIntegrated = ships.length === 1 && !!ships[0].integrate; - const newModal = { ships, id, isIntegrated }; + const newModal: TrackModal = { ships, id, isIntegrated }; // 기존 모달 대체 (하나의 항적만 활성) set({ trackModals: [newModal] }); @@ -370,7 +441,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** * 항적 조회 실행 */ - queryTracks: async (ships, startDate, endDate) => { + queryTracks: async (ships, startDate?, endDate?) => { const now = new Date(); const start = startDate || new Date(now.getTime() - DEFAULT_QUERY_DAYS * 24 * 60 * 60 * 1000); const end = endDate || now; @@ -396,8 +467,9 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ }); get().setTracks(result, start.getTime()); - } catch (err) { - set({ error: err.message || '항적 조회 실패' }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : '항적 조회 실패'; + set({ error: message }); } finally { set({ isLoading: false }); } @@ -406,7 +478,7 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ /** * 항적 모달 닫기 */ - closeTrackModal: (modalId) => { + closeTrackModal: (_modalId) => { set({ trackModals: [], tracks: [], @@ -415,8 +487,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ currentTime: 0, error: null, isLoading: false, - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), }); }, @@ -424,8 +496,8 @@ const useTrackStore = create(subscribeWithSelector((set, get) => ({ reset: () => { set({ tracks: [], - disabledVesselIds: new Set(), - disabledSigSrcCds: new Set(), + disabledVesselIds: new Set(), + disabledSigSrcCds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, diff --git a/src/stores/trackingModeStore.js b/src/stores/trackingModeStore.ts similarity index 60% rename from src/stores/trackingModeStore.js rename to src/stores/trackingModeStore.ts index 2c18416b..4c30be0f 100644 --- a/src/stores/trackingModeStore.js +++ b/src/stores/trackingModeStore.ts @@ -5,21 +5,29 @@ */ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; +import type { ShipFeature } from '../types/ship'; -// 반경 옵션 (NM) -export const RADIUS_OPTIONS = [10, 25, 50, 100, 200]; +/** 추적 모드 */ +type TrackingMode = 'map' | 'ship'; -// NM to meters 변환 (1 NM = 1852m) +/** 반경 옵션 (NM) */ +export const RADIUS_OPTIONS = [10, 25, 50, 100, 200] as const; + +/** NM to meters 변환 (1 NM = 1852m) */ export const NM_TO_METERS = 1852; +/** 추적 중심 좌표 */ +interface TrackedCenter { + lon: number; + lat: number; +} + /** * 경비함정 여부 판별 * - originalTargetId가 '#'으로 시작 * - 또는 IP 형태 (10.xxx.xxx.xxx) - * @param {string} originalTargetId - * @returns {boolean} */ -export function isPatrolShip(originalTargetId) { +export function isPatrolShip(originalTargetId: string | undefined | null): boolean { if (!originalTargetId) return false; // '#'으로 시작 if (originalTargetId.startsWith('#')) return true; @@ -30,15 +38,10 @@ export function isPatrolShip(originalTargetId) { /** * 두 좌표 간 거리 계산 (Haversine, 미터 단위) - * @param {number} lon1 - * @param {number} lat1 - * @param {number} lon2 - * @param {number} lat2 - * @returns {number} 거리 (미터) */ -export function calculateDistance(lon1, lat1, lon2, lat2) { +export function calculateDistance(lon1: number, lat1: number, lon2: number, lat2: number): number { const R = 6371000; // 지구 반지름 (미터) - const toRad = (deg) => (deg * Math.PI) / 180; + const toRad = (deg: number) => (deg * Math.PI) / 180; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); @@ -52,29 +55,44 @@ export function calculateDistance(lon1, lat1, lon2, lat2) { /** * 선박이 반경 내에 있는지 확인 - * @param {Object} ship - 선박 데이터 (longitude, latitude) - * @param {number} centerLon - 중심 경도 - * @param {number} centerLat - 중심 위도 - * @param {number} radiusNM - 반경 (NM) - * @returns {boolean} */ -export function isWithinRadius(ship, centerLon, centerLat, radiusNM) { +export function isWithinRadius(ship: { longitude: number; latitude: number }, centerLon: number, centerLat: number, radiusNM: number): boolean { if (!ship.longitude || !ship.latitude) return false; const radiusMeters = radiusNM * NM_TO_METERS; const distance = calculateDistance(centerLon, centerLat, ship.longitude, ship.latitude); return distance <= radiusMeters; } -/** - * 추적 모드 스토어 - */ -const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ +/** 추적 모드 스토어 상태 */ +interface TrackingModeStoreState { + // state + mode: TrackingMode; + trackedShipId: string | null; + trackedShip: ShipFeature | null; + radiusNM: number; + showShipSelector: boolean; + // actions + setMapMode: () => void; + setShipMode: () => void; + selectTrackedShip: (featureId: string, ship: ShipFeature) => void; + updateTrackedShip: (ship: ShipFeature) => void; + setRadius: (radiusNM: number) => void; + toggleShipSelector: () => void; + closeShipSelector: () => void; + toggleMode: () => void; + // selectors + isShipMode: () => boolean; + hasTrackedShip: () => boolean; + getTrackedCenter: () => TrackedCenter | null; +} + +const useTrackingModeStore = create()(subscribeWithSelector((set, get) => ({ // ===================== // 상태 (State) // ===================== - /** 현재 모드: 'map' | 'ship' */ - mode: 'map', + /** 현재 모드 */ + mode: 'map' as TrackingMode, /** 추적 중인 함정 featureId */ trackedShipId: null, @@ -92,9 +110,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ // 액션 (Actions) // ===================== - /** - * 지도 모드로 전환 - */ + /** 지도 모드로 전환 */ setMapMode: () => { set({ mode: 'map', @@ -104,9 +120,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 선박 모드로 전환 (함정 선택 드롭다운 표시) - */ + /** 선박 모드로 전환 (함정 선택 드롭다운 표시) */ setShipMode: () => { set({ mode: 'ship', @@ -114,11 +128,7 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 함정 선택 - * @param {string} featureId - * @param {Object} ship - 선박 데이터 - */ + /** 함정 선택 */ selectTrackedShip: (featureId, ship) => { set({ mode: 'ship', @@ -128,39 +138,27 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ }); }, - /** - * 추적 중인 함정 데이터 업데이트 (실시간) - * @param {Object} ship - */ + /** 추적 중인 함정 데이터 업데이트 (실시간) */ updateTrackedShip: (ship) => { set({ trackedShip: ship }); }, - /** - * 반경 설정 - * @param {number} radiusNM - */ + /** 반경 설정 */ setRadius: (radiusNM) => { set({ radiusNM }); }, - /** - * 함정 선택 드롭다운 토글 - */ + /** 함정 선택 드롭다운 토글 */ toggleShipSelector: () => { set((state) => ({ showShipSelector: !state.showShipSelector })); }, - /** - * 함정 선택 드롭다운 닫기 - */ + /** 함정 선택 드롭다운 닫기 */ closeShipSelector: () => { set({ showShipSelector: false }); }, - /** - * 모드 토글 (지도 <-> 선박) - */ + /** 모드 토글 (지도 <-> 선박) */ toggleMode: () => { const { mode } = get(); if (mode === 'map') { @@ -174,20 +172,13 @@ const useTrackingModeStore = create(subscribeWithSelector((set, get) => ({ // 셀렉터 (Selectors) // ===================== - /** - * 선박 모드 활성화 여부 - */ + /** 선박 모드 활성화 여부 */ isShipMode: () => get().mode === 'ship', - /** - * 추적 중인 함정이 있는지 - */ + /** 추적 중인 함정이 있는지 */ hasTrackedShip: () => get().trackedShipId !== null, - /** - * 추적 중인 함정의 중심 좌표 - * @returns {{ lon: number, lat: number } | null} - */ + /** 추적 중인 함정의 중심 좌표 */ getTrackedCenter: () => { const { trackedShip } = get(); if (!trackedShip || !trackedShip.longitude || !trackedShip.latitude) return null; diff --git a/src/stores/uiStore.js b/src/stores/uiStore.ts similarity index 54% rename from src/stores/uiStore.js rename to src/stores/uiStore.ts index 52211765..f2bda067 100644 --- a/src/stores/uiStore.js +++ b/src/stores/uiStore.ts @@ -1,9 +1,28 @@ import { create } from 'zustand'; -/** - * UI 상태 관리 스토어 - */ -export const useUIStore = create((set) => ({ +/** 모달 상태 */ +interface ModalState { + isOpen: boolean; + data: unknown; +} + +/** UI 상태 관리 스토어 */ +interface UIStoreState { + // state + isPanelOpen: boolean; + activeMenu: string; + isLoading: boolean; + modals: Record; + // actions + togglePanel: () => void; + setPanel: (isOpen: boolean) => void; + setActiveMenu: (menu: string) => void; + setLoading: (loading: boolean) => void; + openModal: (modalId: string, data?: unknown) => void; + closeModal: (modalId: string) => void; +} + +export const useUIStore = create()((set) => ({ // 사이드패널 열림 상태 isPanelOpen: true, togglePanel: () => set((state) => ({ isPanelOpen: !state.isPanelOpen })), diff --git a/src/tracking/components/GlobalTrackQueryViewer.jsx b/src/tracking/components/GlobalTrackQueryViewer.tsx similarity index 96% rename from src/tracking/components/GlobalTrackQueryViewer.jsx rename to src/tracking/components/GlobalTrackQueryViewer.tsx index b4e4a9a7..8ebbbec3 100644 --- a/src/tracking/components/GlobalTrackQueryViewer.jsx +++ b/src/tracking/components/GlobalTrackQueryViewer.tsx @@ -7,7 +7,7 @@ * - 닫기 버튼으로 항적 레이어 및 데이터 정리 */ -import React, { useCallback, useEffect } from 'react'; +import { useCallback, useEffect } from 'react'; import { useTrackQueryStore } from '../stores/trackQueryStore'; import { TrackQueryViewer } from './TrackQueryViewer'; import { unregisterTrackQueryLayers } from '../utils/trackQueryLayerUtils'; diff --git a/src/tracking/components/TrackQueryTimeline.jsx b/src/tracking/components/TrackQueryTimeline.tsx similarity index 93% rename from src/tracking/components/TrackQueryTimeline.jsx rename to src/tracking/components/TrackQueryTimeline.tsx index 4df51c53..b05163ef 100644 --- a/src/tracking/components/TrackQueryTimeline.jsx +++ b/src/tracking/components/TrackQueryTimeline.tsx @@ -11,15 +11,18 @@ import { useTrackQueryAnimationStore, PLAYBACK_SPEED_OPTIONS } from '../stores/t import { useTrackQueryStore } from '../stores/trackQueryStore'; import './TrackQueryTimeline.scss'; +interface TrackQueryTimelineProps { + startTime: number; + endTime: number; + compact?: boolean; +} + +type MarkerType = 'start' | 'end'; + /** * 항적조회 타임라인 컨트롤 컴포넌트 - * - * @param {Object} props - * @param {number} props.startTime 데이터 시작 시간 - * @param {number} props.endTime 데이터 종료 시간 - * @param {boolean} [props.compact] 컴팩트 모드 */ -export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { +export const TrackQueryTimeline = ({ startTime, endTime, compact = false }: TrackQueryTimelineProps) => { // 애니메이션 스토어 const isPlaying = useTrackQueryAnimationStore(state => state.isPlaying); const animationCurrentTime = useTrackQueryAnimationStore(state => state.currentTime); @@ -43,11 +46,11 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 배속 드롭다운 상태 const [showSpeedMenu, setShowSpeedMenu] = useState(false); - const speedMenuRef = useRef(null); - const sliderContainerRef = useRef(null); + const speedMenuRef = useRef(null); + const sliderContainerRef = useRef(null); // 구간반복 마커 드래그 상태 - const [draggingMarker, setDraggingMarker] = useState(null); + const [draggingMarker, setDraggingMarker] = useState(null); // 시간 범위 설정 useEffect(() => { @@ -80,8 +83,8 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 외부 클릭 시 드롭다운 닫기 useEffect(() => { - const handleClickOutside = (event) => { - if (speedMenuRef.current && !speedMenuRef.current.contains(event.target)) { + const handleClickOutside = (event: MouseEvent) => { + if (speedMenuRef.current && !speedMenuRef.current.contains(event.target as Node)) { setShowSpeedMenu(false); } }; @@ -111,7 +114,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 배속 변경 const handleSpeedChange = useCallback( - (speed) => { + (speed: number) => { setPlaybackSpeed(speed); setShowSpeedMenu(false); }, @@ -120,7 +123,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 슬라이더로 시간 변경 const handleSliderChange = useCallback( - (e) => { + (e: React.ChangeEvent) => { const newTime = parseFloat(e.target.value); setAnimationCurrentTime(newTime); const ratio = (newTime - startTime) / (endTime - startTime); @@ -136,7 +139,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { // 구간 마커 드래그 시작 const handleMarkerMouseDown = useCallback( - (marker) => (e) => { + (marker: MarkerType) => (e: React.MouseEvent) => { if (isPlaying) return; e.preventDefault(); e.stopPropagation(); @@ -149,8 +152,8 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { useEffect(() => { if (!draggingMarker || !sliderContainerRef.current) return; - const handleMouseMove = (e) => { - const rect = sliderContainerRef.current.getBoundingClientRect(); + const handleMouseMove = (e: MouseEvent) => { + const rect = sliderContainerRef.current!.getBoundingClientRect(); const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const newTime = startTime + ratio * (endTime - startTime); @@ -182,7 +185,7 @@ export const TrackQueryTimeline = ({ startTime, endTime, compact = false }) => { const loopProgress = getLoopProgress(); // 시간 포맷팅 (날짜 + 시간) - YYYY-MM-DD HH:mm:ss - const formatTime = (timestamp) => { + const formatTime = (timestamp: number) => { if (!timestamp) return '---------- --:--:--'; const date = new Date(timestamp); const year = date.getFullYear(); diff --git a/src/tracking/components/TrackQueryViewer.jsx b/src/tracking/components/TrackQueryViewer.tsx similarity index 88% rename from src/tracking/components/TrackQueryViewer.jsx rename to src/tracking/components/TrackQueryViewer.tsx index 9e294333..37694554 100644 --- a/src/tracking/components/TrackQueryViewer.jsx +++ b/src/tracking/components/TrackQueryViewer.tsx @@ -29,24 +29,25 @@ import { useTrackHighlight } from '../hooks/useTrackHighlight'; import { TrackQueryTimeline } from './TrackQueryTimeline'; import { useEquipmentFilter } from '../hooks/useEquipmentFilter'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; -import { fromLonLat } from 'ol/proj'; import ShipTooltip from '../../components/ship/ShipTooltip'; import './TrackQueryViewer.scss'; +import type { Layer } from '@deck.gl/core'; + /** 일 단위를 밀리초로 변환 */ const DAYS_TO_MS = 24 * 60 * 60 * 1000; /** datetime-local 입력용 포맷 */ -function toDateTimeLocal(date) { - const pad = (n) => String(n).padStart(2, '0'); +function toDateTimeLocal(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } /** * 유효한 선명인지 확인하고 포맷팅 */ -function formatShipName(name, fallback = '선명 없음') { +function formatShipName(name: string | undefined | null, fallback = '선명 없음'): string { if (!name) return fallback; const trimmed = name.trim(); if (!trimmed) return fallback; @@ -55,6 +56,29 @@ function formatShipName(name, fallback = '선명 없음') { return trimmed; } +/** 시간 범위 */ +interface TimeRange { + fromDate: string; + toDate: string; +} + +/** TrackQueryShipItem Props */ +interface TrackQueryShipItemProps { + vesselId: string; + shipName: string; + targetId: string; + shipKindCode: string; + sigSrcCd: string; + isEnabled: boolean; + isActive: boolean; + isHighlighted: boolean; + speed: number | null; + onToggle: (vesselId: string) => void; + onMouseEnter: (vesselId: string) => void; + onMouseLeave: () => void; + onContextMenu: (vesselId: string, e: React.MouseEvent) => void; +} + /** * 메모이제이션된 선박 목록 아이템 */ @@ -73,7 +97,7 @@ const TrackQueryShipItem = React.memo( onMouseEnter, onMouseLeave, onContextMenu, - }) => ( + }: TrackQueryShipItemProps) => (
        onToggle(vesselId)} @@ -99,19 +123,48 @@ const TrackQueryShipItem = React.memo( prev.speed === next.speed, ); +/** 호버된 선박 데이터 (ShipTooltip용) */ +interface HoveredShipData { + shipName: string; + targetId: string; + signalKindCode: string; + sog: number; + cog: number; +} + +/** 아이콘 호버 시 전달되는 선박 데이터 */ +interface IconHoverShipData { + shipName: string; + vesselId: string; + shipKindCode: string; + speed?: number; + heading?: number; +} + +/** 포인트 호버 정보 */ +interface PointHoverInfo { + vesselId: string; + position: [number, number]; + timestamp: number; + speed: number; + index: number; +} + +/** TrackQueryViewer Props */ +interface TrackQueryViewerProps { + compact?: boolean; + onClose?: () => void; + modalMode?: boolean; + isIntegrated?: boolean; + timeRange?: TimeRange; + onTimeRangeChange?: (timeRange: TimeRange) => void; + onQuery?: () => void; + isQuerying?: boolean; + showPlayback?: boolean; +} + /** * 항적조회 뷰어 메인 컴포넌트 - * - * @param {Object} props - * @param {boolean} [props.compact] 컴팩트 모드 - * @param {Function} [props.onClose] 닫기 핸들러 - * @param {boolean} [props.modalMode] 선박 모달 모드 - * @param {boolean} [props.isIntegrated] 통합선박 여부 - * @param {Object} [props.timeRange] 시간 범위 { fromDate, toDate } - * @param {Function} [props.onTimeRangeChange] 시간 범위 변경 핸들러 - * @param {Function} [props.onQuery] 조회 버튼 클릭 핸들러 - * @param {boolean} [props.isQuerying] 조회 중 상태 - * @param {boolean} [props.showPlayback] 재생 컨트롤 표시 여부 */ export const TrackQueryViewer = ({ compact = false, @@ -123,7 +176,7 @@ export const TrackQueryViewer = ({ onQuery, isQuerying = false, showPlayback = false, -}) => { +}: TrackQueryViewerProps) => { // 스토어 상태 const tracks = useTrackQueryStore(state => state.tracks); const currentTime = useTrackQueryStore(state => state.currentTime); @@ -169,48 +222,41 @@ export const TrackQueryViewer = ({ } = useEquipmentFilter(tracks); // 로컬 상태 - const [hoveredPoint, setHoveredPoint] = useState(null); - const [hoveredShip, setHoveredShip] = useState(null); - const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + const [, setHoveredPoint] = useState(null); + const [hoveredShip, setHoveredShip] = useState(null); + const [, setTooltipPosition] = useState({ x: 0, y: 0 }); const [shipTooltipPosition, setShipTooltipPosition] = useState({ x: 0, y: 0 }); const [isDragging, setIsDragging] = useState(false); - const [zoomLevel, setZoomLevel] = useState(undefined); + const [zoomLevel, setZoomLevel] = useState(undefined); const [staticLayerVersion, setStaticLayerVersion] = useState(0); - const progressBarRef = useRef(null); - const panelRef = useRef(null); + const progressBarRef = useRef(null); + const panelRef = useRef(null); const pathTriggerRef = useRef(0); const pointsTriggerRef = useRef(0); const dynamicTriggerRef = useRef(0); - const pathLayersRef = useRef([]); - const pointsLayerRef = useRef(null); - const liveConnectionLayerRef = useRef(null); + const pathLayersRef = useRef([]); + const pointsLayerRef = useRef(null); + const liveConnectionLayerRef = useRef(null); const liveConnectionTriggerRef = useRef(0); // 줌 레벨 변경 감지 (클러스터링용) - tracks 변경 시 재시도 useEffect(() => { - const mapInstance = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 MapLibre Map 인스턴스 (글로벌 접근) + const mapInstance = (window as any).__mainMap__; if (!mapInstance) return; - const view = mapInstance.getView(); - if (!view) return; - - const initialZoom = view.getZoom(); - if (initialZoom !== undefined) { - setZoomLevel(Math.floor(initialZoom)); - } + const initialZoom = mapInstance.getZoom() + 1; // +1: OL 줌 규약 + setZoomLevel(Math.floor(initialZoom)); const handleZoomChange = () => { - const newZoom = view.getZoom(); - if (newZoom !== undefined) { - const flooredZoom = Math.floor(newZoom); - setZoomLevel(prev => (prev !== flooredZoom ? flooredZoom : prev)); - } + const newZoom = Math.floor(mapInstance.getZoom() + 1); + setZoomLevel(prev => (prev !== newZoom ? newZoom : prev)); }; - view.on('change:resolution', handleZoomChange); + mapInstance.on('zoomend', handleZoomChange); return () => { - view.un('change:resolution', handleZoomChange); + mapInstance.off('zoomend', handleZoomChange); }; }, [tracks.length]); @@ -247,7 +293,7 @@ export const TrackQueryViewer = ({ // 현재 시간에 활성 상태인 선박 ID Set const activeVesselIds = useMemo(() => { - const active = new Set(); + const active = new Set(); tracks.forEach(track => { if (track.timestampsMs.length === 0) return; const firstTime = track.timestampsMs[0]; @@ -261,7 +307,7 @@ export const TrackQueryViewer = ({ // 현재 시간의 선박 속도 Map const vesselSpeedMap = useMemo(() => { - const speedMap = new Map(); + const speedMap = new Map(); currentPositions.forEach(pos => { speedMap.set(pos.vesselId, pos.speed ?? null); }); @@ -290,13 +336,13 @@ export const TrackQueryViewer = ({ }, [setHideLiveShips, onClose]); // 포인트 호버 핸들러 - const handlePointHover = useCallback((info, x, y) => { + const handlePointHover = useCallback((info: PointHoverInfo | null, x: number, y: number) => { setHoveredPoint(info); setTooltipPosition({ x, y }); }, []); // 가상 선박 아이콘 호버 핸들러 - const handleIconHover = useCallback((shipData, x, y) => { + const handleIconHover = useCallback((shipData: IconHoverShipData | null, x: number, y: number) => { if (shipData) { // ShipTooltip 형식에 맞게 변환 setHoveredShip({ @@ -317,17 +363,18 @@ export const TrackQueryViewer = ({ // 선박 목록 우클릭 핸들러 (해당 위치로 지도 중심 이동) const handleShipContextMenu = useCallback( - (vesselId, e) => { + (vesselId: string, e: React.MouseEvent) => { e.preventDefault(); - const mapInstance = window.__mainMap__; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- window.__mainMap__은 MapLibre Map 인스턴스 (글로벌 접근) + const mapInstance = (window as any).__mainMap__; if (!mapInstance) return; const track = tracks.find(t => t.vesselId === vesselId); if (!track || !track.geometry || track.geometry.length === 0) return; - let targetLon; - let targetLat; + let targetLon: number; + let targetLat: number; const currentPos = currentPositions.find(p => p.vesselId === vesselId); if (currentPos) { @@ -336,7 +383,6 @@ export const TrackQueryViewer = ({ } else { if (!track.timestampsMs || track.timestampsMs.length === 0) return; const firstTime = track.timestampsMs[0]; - const lastTime = track.timestampsMs[track.timestampsMs.length - 1]; if (currentTime < firstTime) { targetLon = track.geometry[0][0]; @@ -348,9 +394,8 @@ export const TrackQueryViewer = ({ } } - const view = mapInstance.getView(); - view.animate({ - center: fromLonLat([targetLon, targetLat]), + mapInstance.flyTo({ + center: [targetLon, targetLat], duration: 300, }); }, @@ -480,7 +525,7 @@ export const TrackQueryViewer = ({ handleIconHover, ); - const allLayers = [ + const allLayers: Layer[] = [ ...pathLayersRef.current, ...(pointsLayerRef.current ? [pointsLayerRef.current] : []), ...dynamicLayers, @@ -493,13 +538,13 @@ export const TrackQueryViewer = ({ }, [tracks.length, enabledTracks, currentPositions, currentTime, isPlaying, showVirtualShip, showLabels, staticLayerVersion, handleIconHover]); // 프로그레스 바 드래그 핸들러 - const handleProgressMouseDown = useCallback((e) => { + const handleProgressMouseDown = useCallback((e: React.MouseEvent) => { setIsDragging(true); updateProgressFromMouse(e); }, []); const handleProgressMouseMove = useCallback( - (e) => { + (e: MouseEvent) => { if (isDragging) { updateProgressFromMouse(e); } @@ -512,7 +557,7 @@ export const TrackQueryViewer = ({ }, []); const updateProgressFromMouse = useCallback( - (e) => { + (e: MouseEvent | React.MouseEvent) => { if (!progressBarRef.current) return; const rect = progressBarRef.current.getBoundingClientRect(); @@ -537,8 +582,8 @@ export const TrackQueryViewer = ({ }, [isDragging, handleProgressMouseMove, handleProgressMouseUp]); // 패널 드래그 핸들러 - const handlePanelDragStart = useCallback((e) => { - if (e.target.closest('button')) return; + const handlePanelDragStart = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('button')) return; e.preventDefault(); dragStartRef.current = { @@ -555,7 +600,7 @@ export const TrackQueryViewer = ({ useEffect(() => { if (!isDraggingPanel) return; - const handleMouseMove = (e) => { + const handleMouseMove = (e: MouseEvent) => { const newDeltaX = dragStartRef.current.deltaX + (e.clientX - dragStartRef.current.x); const newDeltaY = dragStartRef.current.deltaY + (e.clientY - dragStartRef.current.y); setDragDelta({ x: newDeltaX, y: newDeltaY }); @@ -575,7 +620,7 @@ export const TrackQueryViewer = ({ }, [isDraggingPanel]); // 시간 포맷팅 - const formatTime = useCallback((timestamp) => { + const formatTime = useCallback((timestamp: number) => { if (!timestamp) return '--:--:--'; const date = new Date(timestamp); return date.toLocaleTimeString('ko-KR', { @@ -585,7 +630,7 @@ export const TrackQueryViewer = ({ }); }, []); - const formatDate = useCallback((timestamp) => { + const formatDate = useCallback((timestamp: number) => { if (!timestamp) return '----.--.--'; const date = new Date(timestamp); return date.toLocaleDateString('ko-KR', { @@ -596,7 +641,7 @@ export const TrackQueryViewer = ({ }, []); // 조회 기간 검증 및 자동 조정 (blur 시 실행) - const validateAndAdjustTimeRange = useCallback((changedField) => { + const validateAndAdjustTimeRange = useCallback((changedField: 'from' | 'to') => { if (!timeRange || !onTimeRangeChange) return; const fromDate = new Date(timeRange.fromDate); @@ -605,7 +650,7 @@ export const TrackQueryViewer = ({ // 유효하지 않은 날짜면 무시 if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) return; - const diffDays = (toDate - fromDate) / DAYS_TO_MS; + const diffDays = (toDate.getTime() - fromDate.getTime()) / DAYS_TO_MS; if (changedField === 'from') { // 시작일이 종료일보다 뒤인 경우 → 종료일을 시작일 + 기본조회기간으로 조정 @@ -668,7 +713,7 @@ export const TrackQueryViewer = ({ const singleVessel = isSingleVessel ? tracks[0] : null; // 패널 스타일 (드래그 위치 적용) - const panelStyle = + const panelStyle: React.CSSProperties = dragDelta.x !== 0 || dragDelta.y !== 0 ? { transform: `translateX(calc(-50% + ${dragDelta.x}px)) translateY(${dragDelta.y}px)`, diff --git a/src/tracking/components/VesselListManager/VesselContextMenu.scss b/src/tracking/components/VesselListManager/VesselContextMenu.scss new file mode 100644 index 00000000..673eedf7 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselContextMenu.scss @@ -0,0 +1,83 @@ +.vessel-context-menu { + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + min-width: 200px; + user-select: none; + font-size: 14px; + z-index: 10001; + animation: contextMenuFadeIn 0.15s ease-out; // 애니메이션 + .context-menu-header { + padding: 12px 16px; + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + .vessel-name { + display: block; + font-weight: 600; + color: #212529; + margin-bottom: 4px; + word-break: break-word; + } + + .vessel-id { + font-size: 12px; + color: #6c757d; + font-family: 'Courier New', monospace; + } + } + .context-menu-divider { + height: 1px; + background: #e9ecef; + margin: 0; + } + .context-menu-items { + padding: 8px 0; + + .context-menu-item { + width: 100%; + background: none; + border: none; + padding: 10px 16px; + text-align: left; + cursor: pointer; + transition: background-color 0.2s ease; + display: flex; + align-items: center; + gap: 12px; + font-size: 14px; + color: #495057; + + &:hover { + background: #f8f9fa; + } + + &:active { + background: #e9ecef; + } + + i { + width: 16px; + color: #213079; + font-size: 14px; + } + + span { + flex: 1; + } + } + } + @keyframes contextMenuFadeIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselItem.scss b/src/tracking/components/VesselListManager/VesselItem.scss new file mode 100644 index 00000000..d5adb7f9 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselItem.scss @@ -0,0 +1,209 @@ +.vessel-item { + display: flex; + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + margin-bottom: 4px; + padding: 8px 12px; + cursor: grab; + transition: all 0.2s ease; + //min-height: 60px; // 가상 스크롤링을 위한 고정 높이 + //max-height: 60px; // 최대 높이 제한 + overflow: hidden; // 내용 넘침 방지 + + &:hover { + background: #f8f9fa; + border-color: #2494d3; + box-shadow: 0 2px 4px rgba(33, 48, 121, 0.1); + } + + &.dragging { + cursor: grabbing; + background: #e3f2fd; + border-color: #2196f3; + box-shadow: 0 8px 16px rgba(33, 150, 243, 0.3); + transform: rotate(5deg) scale(1.05); + z-index: 1000; + opacity: 0.9; + transition: all 0.2s ease; + } + + &.disabled { + cursor: not-allowed; + opacity: 0.6; + + &:hover { + background: #ffffff; + border-color: #e9ecef; + box-shadow: none; + } + } + + &.selected { + background: #e3f2fd; + border-color: #2196f3; + box-shadow: 0 2px 8px rgba(33, 150, 243, 0.15); + + .vessel-name { + color: #1976d2; + font-weight: 700; + } + } + + .vessel-item-content { + display: flex; + align-items: center; + width: 100%; + gap: 0 12px; + } + + .selection-checkbox { + flex-shrink: 0; + cursor: pointer; + position: relative; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 16px; + height: 16px; + border: 2px solid #ced4da; + border-radius: 3px; + background: #ffffff; + transition: all 0.2s ease; + display: block; + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 7px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + + input[type="checkbox"]:checked + .checkmark { + //background: #213079; + border: solid 2px #2494d3; + + //&::after { + // background-image: url("@/assets/img/pub/checkbox-22-on.png"); + // background-repeat: no-repeat; + // background-position: center; + // background-size: 70%; + // background-color: white; + // border: solid 2px #2494d3 !important; + //} + background-image: url("@/assets/img/pub/checkbox-22-on.png"); + background-repeat: no-repeat; + background-position: center; + background-size: 70%; + background-color: white; + border: solid 2px #2494d3 !important; + } + + &:hover .checkmark { + border-color: #213079; + } + } + + .ship-icon { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + + img { + border-radius: 4px; + } + } + + .vessel-info { + flex: 1; + min-width: 0; // 텍스트 말줄임을 위한 설정 + + .vessel-name { + font-size: 14px; + font-weight: 600; + color: #212529; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + //margin-bottom: 2px; + display: flex; + align-items: center; + } + + .vessel-details { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #6c757d; + + .country-flag { + font-size: 14px; + } + + .ship-kind { + background: #e9ecef; + padding: 2px 6px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + } + + .signal-source { + color: #868e96; + font-size: 10px; + } + } + } + + .drag-handle { + flex-shrink: 0; + color: #adb5bd; + font-size: 12px; + cursor: grab; + padding: 4px; + + &:hover { + color: #6c757d; + } + } + + .drag-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(33, 150, 243, 0.1); + border-radius: 8px; + pointer-events: none; + } +} + +// 드래그 플레이스홀더 스타일 +.vessel-item-placeholder { + background: #f8f9fa; + border: 2px dashed #dee2e6; + border-radius: 8px; + height: 60px; + margin-bottom: 4px; + display: flex; + align-items: center; + justify-content: center; + color: #adb5bd; + font-size: 12px; +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselListManager.scss b/src/tracking/components/VesselListManager/VesselListManager.scss new file mode 100644 index 00000000..fcc7cb29 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselListManager.scss @@ -0,0 +1,287 @@ +.vessel-list-manager { + // position, top, left는 인라인 스타일에서 처리하므로 제거 + width: 900px; + max-width: calc(100vw - 40px); // 좌우 여백 최소화 + border-radius: 12px; + //box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); + transition: all 0.3s ease; + z-index: 1200; // ReplayV2(1060)보다 높은 z-index + + &.closed { + .manager-content { + display: none; + } + } + + &.dragging { + z-index: 1300; + + .manager-header { + cursor: grabbing !important; + background: linear-gradient(135deg, #3a5ba7 0%, #4a6bb8 100%); + } + } + + .manager-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 10px; + background: linear-gradient(135deg, #213079 0%, #2c4590 100%); + border-radius: 12px 12px 12px 12px; // 상단 배치시 모든 모서리 라운드 + color: white; + cursor: grab; + user-select: none; + + &:hover { + background: linear-gradient(135deg, #2c4590 0%, #3a5ba7 100%); + } + + &:active { + cursor: grabbing; + } + + .header-left { + display: flex; + align-items: center; + gap: 5px; + flex: 1; + } + + .drag-handle { + font-size: 12px; + color: rgba(255, 255, 255, 0.7); + cursor: grab; + padding: 2px; + transition: color 0.2s ease; + + &:hover { + color: rgba(255, 255, 255, 1); + } + + &:active { + cursor: grabbing; + } + } + + .toggle-button { + display: flex; + align-items: center; + gap: 5px; + background: none; + border: none; + color: white; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + padding: 3px 6px; + border-radius: 5px; + flex: 1; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + + &:active { + background: rgba(255, 255, 255, 0.25); + transform: scale(0.98); + } + + i { + font-size: 12px; + transition: transform 0.2s ease; + } + + span { + white-space: nowrap; + } + } + + .vessel-counts { + display: flex; + align-items: center; + gap: 10px; + + .count-item { + display: flex; + align-items: center; + gap: 2px; + font-size: 11px; + font-weight: 500; + opacity: 0.9; + + .icon { + font-size: 11px; + } + + &.normal { + color: #a8e6a3; + } + + &.selected { + color: #87ceeb; + } + + &.deleted { + color: #ffb3ba; + } + } + } + } + + .manager-content { + max-height: 80vh; // 뷰포트 높이의 80%로 설정하여 더 유연하게 + overflow: hidden; + display: flex; + flex-direction: column; + + .vessel-lists-container { + //display: grid; + //grid-template-columns: 1fr 1fr 1fr; + gap: 8px; + padding: 10px; + max-height: 450px; // 패널 영역 높이는 유지 + overflow: hidden; + flex: 1; // 남은 공간 활용 + + // 반응형 레이아웃 + @media (max-width: 700px) { + grid-template-columns: 1fr; + gap: 5px; + padding: 6px; + max-height: 500px; // 모바일에서는 더 높게 + } + + @media (max-width: 1000px) and (min-width: 701px) { + grid-template-columns: 1fr 1fr; + max-height: 400px; // 중간 화면에서는 2열 + .vessel-list-panel:last-child { + grid-column: 1 / -1; + } + } + + // 넓은 화면에서는 항상 3열로 표시 + @media (min-width: 1001px) { + grid-template-columns: 1fr 1fr 1fr; + gap: 10px; + padding: 10px; + max-height: 450px; + } + } + + .usage-hint { + display: flex; + align-items: center; + justify-content: center; + gap: 5px; + padding: 5px 10px; + background: #f8f9fa; + border-top: 1px solid #e9ecef; + color: #6c757d; + font-size: 10px; + font-style: italic; + + i { + color: #007bff; + } + } + } + + // 드래그 중 전체 컨테이너 스타일 조정 + &.dragging { + .manager-content { + user-select: none; + } + } + + // 접기/펼치기 애니메이션 (상단 배치용) + &.open { + .manager-header .toggle-button i { + transform: rotate(0deg); // 열릴 때 아래 화살표 + } + } + + &.closed { + .manager-header .toggle-button i { + transform: rotate(-90deg); // 닫힐 때 오른쪽 화살표 + } + } + + // ReplayV2에서 사용할 때의 스타일 조정 - 화면 전체 기준 고정 위치 + &.vessel-list-manager-replay { + //position: fixed !important; // 화면 전체 기준 고정 + //top: 100px !important; // 화면 상단에서 100px + //right: 50px !important; // 화면 우측에서 50px + //width: 1000px; + //max-width: 80vw !important; + //background-color: #99A3AE; + z-index: 9999 !important; // 모든 요소 위에 표시 + + @media (max-width: 1500px) { + right: 20px; + width: 600px; + max-width: calc(100vw - 40px); + } + + @media (max-width: 1200px) { + width: calc(100vw - 40px); + right: 20px; + left: 20px; + top: 60px; + } + + @media (max-width: 768px) { + top: 100px; // 모바일에서는 더 아래로 + } + } +} + +// 전역 드래그 스타일 (드래그 중인 아이템) +.vessel-item-drag-ghost { + background: #e3f2fd !important; + border-color: #2196f3 !important; + box-shadow: 0 8px 16px rgba(33, 150, 243, 0.3) !important; + transform: rotate(5deg) !important; + z-index: 9999 !important; +} + +// 반응형 조정 +@media (max-width: 1500px) { + .vessel-list-manager { + top: 80px; + width: calc(100vw - 40px); + right: 20px; + left: 20px; + max-width: none; + + .manager-content .vessel-lists-container { + padding: 8px; + } + } +} + +@media (max-width: 1200px) { + .vessel-list-manager { + .manager-header { + padding: 5px 8px; + + .toggle-button { + font-size: 11px; + } + + .vessel-counts { + gap: 8px; + + .count-item { + font-size: 10px; + } + } + } + + .manager-content .vessel-lists-container { + padding: 6px; + gap: 5px; + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselListPanel.scss b/src/tracking/components/VesselListManager/VesselListPanel.scss new file mode 100644 index 00000000..3e93a983 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselListPanel.scss @@ -0,0 +1,280 @@ +.vessel-list-panel { + background: #ffffff; + border-radius: 8px; + //border: 1px solid #e9ecef; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; // 가로 길이를 65%로 제한 + margin: 0 auto; // 중앙 정렬 + + .panel-header { + padding: 12px 16px; + border-bottom: 1px solid #e9ecef; + border-right: 4px solid #28a745; // 우측으로 이동, 인라인 스타일로 오버라이드됨 + background: #f8f9fa; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + display: flex; + justify-content: space-between; + align-items: center; + + .panel-title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 600; + font-size: 14px; + color: #212529; + + .panel-icon { + font-size: 16px; + } + + .panel-text { + flex: 1; + } + + .panel-count { + color: #6c757d; + font-weight: 500; + font-size: 13px; + background: #e9ecef; + padding: 2px 8px; + border-radius: 12px; + } + } + + .panel-select-all { + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f8f9fa; + } + + .panel-select-checkbox { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: #495057; + user-select: none; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 16px; + height: 16px; + border: 2px solid #ced4da; + border-radius: 3px; + background: #ffffff; + transition: all 0.2s ease; + + &::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 7px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + input[type="checkbox"]:checked + .checkmark { + background: #213079; + border-color: #213079; + + &::after { + opacity: 1; + } + } + + input[type="checkbox"]:indeterminate + .checkmark { + background: #6c757d; + border-color: #6c757d; + + &::after { + left: 3px; + top: 6px; + width: 8px; + height: 2px; + border: none; + background: white; + transform: none; + opacity: 1; + } + } + + .select-text { + white-space: nowrap; + font-size: 11px; + } + } + } + } + + .vessel-list-container { + flex: 1; + //padding: 12px; + //border: 2px dashed transparent; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + min-height: 200px; + max-height: 350px; // 조금 줄여서 3개 패널이 한 화면에 잘 맞도록 + overflow-y: auto; + transition: all 0.2s ease; + position: relative; + + &.drag-over { + border-style: solid; + box-shadow: inset 0 0 8px rgba(0, 123, 255, 0.1); + transform: scale(1.02); + transition: all 0.2s ease; + } + + // 드래그 타겟 하이라이트 효과 + &.drag-target { + border-color: #007bff !important; + background-color: rgba(0, 123, 255, 0.05) !important; + transform: scale(1.02); + box-shadow: 0 4px 20px rgba(33, 48, 121, 0.15); + transition: all 0.3s ease; + } + + .vessel-list { + display: flex; + flex-direction: column; + } + + .virtual-vessel-list { + flex: 1; + overflow-y: auto; + + // 가상 스크롤링 전용 스크롤바 스타일 + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } + } + + .empty-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 120px; + color: #adb5bd; + text-align: center; + + .empty-icon { + font-size: 32px; + margin-bottom: 8px; + opacity: 0.5; + } + + .empty-text { + font-size: 13px; + font-weight: 500; + } + } + + .drop-hint { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.9); + padding: 16px; + border-radius: 8px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + color: #007bff; + font-weight: 600; + font-size: 14px; + pointer-events: none; + z-index: 10; + + i { + font-size: 20px; + margin-bottom: 8px; + animation: bounce 1s infinite; + } + + @keyframes bounce { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-5px); + } + } + } + } + + // 스크롤바 스타일링 + .vessel-list-container::-webkit-scrollbar { + width: 6px; + } + + .vessel-list-container::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + .vessel-list-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } +} + +// 상태별 색상 테마 +.vessel-list-panel[data-state="NORMAL"] { + .panel-header { + border-right-color: #28a745; + } +} + +.vessel-list-panel[data-state="SELECTED"] { + .panel-header { + border-right-color: #007bff; + } +} + +.vessel-list-panel[data-state="DELETED"] { + .panel-header { + border-right-color: #dc3545; + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselSearchFilter.scss b/src/tracking/components/VesselListManager/VesselSearchFilter.scss new file mode 100644 index 00000000..339e8cd1 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselSearchFilter.scss @@ -0,0 +1,356 @@ +.vessel-search-filter { + background: #f8f9fa; + border-bottom: 1px solid #e9ecef; + padding: 16px; + border-radius: 12px 12px 0 0; + + .search-row { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 12px; + + .search-input-group { + position: relative; + flex: 1; + min-width: 200px; + + .search-icon { + position: absolute; + left: 12px; + top: 50%; + transform: translateY(-50%); + color: #6c757d; + font-size: 14px; + pointer-events: none; + } + + .search-input { + width: 100%; + padding: 8px 16px 8px 36px; + border: 1px solid #ced4da; + border-radius: 8px; + font-size: 14px; + background: #ffffff; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + + &:focus { + outline: none; + border-color: #213079; + box-shadow: 0 0 0 2px rgba(33, 48, 121, 0.1); + } + + &::placeholder { + color: #adb5bd; + } + } + + .clear-search-btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #6c757d; + cursor: pointer; + padding: 4px; + border-radius: 4px; + font-size: 12px; + + &:hover { + color: #495057; + background: #e9ecef; + } + } + } + + .select-all-group { + .select-all-checkbox { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 500; + color: #495057; + + input[type="checkbox"] { + display: none; + } + + .checkmark { + position: relative; + width: 18px; + height: 18px; + border: 2px solid #ced4da; + border-radius: 4px; + background: #ffffff; + transition: all 0.2s ease; + + &::after { + content: ''; + position: absolute; + left: 5px; + top: 2px; + width: 5px; + height: 8px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + opacity: 0; + transition: opacity 0.2s ease; + } + } + + input[type="checkbox"]:checked + .checkmark { + background: #213079; + border-color: #213079; + + &::after { + opacity: 1; + } + } + + input[type="checkbox"]:indeterminate + .checkmark { + background: #6c757d; + border-color: #6c757d; + + &::after { + left: 3px; + top: 7px; + width: 10px; + height: 2px; + border: none; + background: white; + transform: none; + opacity: 1; + } + } + + .select-all-text { + white-space: nowrap; + } + } + } + } + + .filter-row { + display: flex; + align-items: center; + gap: 0px 8px; + flex-wrap: wrap; + margin-bottom: 8px; + width: 100%; + justify-content: space-between; + + .filter-group { + display: flex; + align-items: center; + gap: 6px; + width: 120px; + + label { + font-size: 13px; + font-weight: 500; + color: #495057; + white-space: nowrap; + } + + .filter-select { + padding: 6px 8px; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 13px; + background: #ffffff; + min-width: 100px; + transition: border-color 0.2s ease; + + &:focus { + outline: none; + border-color: #213079; + } + + // 국적 드롭다운은 더 넓게 + //&.country-select { + // min-width: 180px; + // max-width: 250px; + //} + } + } + + .clear-filters-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: #ffffff; + border: 1px solid #ced4da; + border-radius: 6px; + font-size: 13px; + color: #6c757d; + cursor: pointer; + transition: all 0.2s ease; + + &:hover:not(:disabled) { + background: #f8f9fa; + border-color: #adb5bd; + color: #495057; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + i { + font-size: 12px; + } + } + } + + // 반응형 조정 + @media (max-width: 768px) { + padding: 12px; + + .search-row { + flex-direction: column; + gap: 12px; + + .search-input-group { + width: 100%; + } + + .select-all-group { + width: 100%; + } + } + + .filter-row { + flex-direction: column; + align-items: stretch; + gap: 8px; + + .filter-group { + justify-content: space-between; + + .filter-select { + flex: 1; + max-width: 200px; + } + } + + .clear-filters-btn { + align-self: flex-end; + } + } + + .filter-summary { + flex-direction: column; + gap: 8px; + align-items: stretch; + + .selected-actions { + flex-direction: column; + gap: 8px; + + .bulk-actions { + justify-content: center; + flex-wrap: wrap; + } + } + } + } +} + +.filter-summary { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 12px; + color: #6c757d; + + .result-count { + font-weight: 500; + } + + .selected-actions { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + margin-top: 8px; + + .selected-count { + background: #213079; + color: white; + padding: 6px 17.5px; + border-radius: 13px; + font-weight: 500; + } + + .bulk-actions { + display: flex; + align-items: center; + gap: 6px; + + .action-label { + font-size: 11px; + color: black; + font-weight: 500; + } + + .bulk-action-btn { + width: 64px; + height: 34px; + display: flex; + justify-content: center; + align-items: center; + //gap: 4px; + //padding: 4px 8px; + border: 1px solid #9eb7d2; + border-radius: 4px; + font-size: 13px; + font-weight: bold; + cursor: pointer; + transition: all 0.2s ease; + background-color: #e5f1ff; + color: #426891; + + .icon { + font-size: 12px; + } + + &:hover { + background: #f8f9fa; + border-color: #adb5bd; + } + + &.normal { + &:hover { + background: #d4edda; + border-color: #28a745; + color: #155724; + } + } + + &.selected { + &:hover { + background: #d1ecf1; + border-color: #007bff; + color: #0c5460; + } + } + + &.deleted { + &:hover { + background: #f8d7da; + border-color: #dc3545; + color: #721c24; + } + } + } + } + } +} \ No newline at end of file diff --git a/src/tracking/components/VesselListManager/VesselTooltip.scss b/src/tracking/components/VesselListManager/VesselTooltip.scss new file mode 100644 index 00000000..41c89966 --- /dev/null +++ b/src/tracking/components/VesselListManager/VesselTooltip.scss @@ -0,0 +1,188 @@ +.vessel-tooltip { + position: fixed; + background: #ffffff; + border: 1px solid #e9ecef; + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); + z-index: 1400; // VesselListManager(1300)보다 높게 + max-width: 320px; + min-width: 280px; + pointer-events: none; + font-size: 13px; + + .tooltip-header { + padding: 12px 16px; + background: linear-gradient(135deg, #213079 0%, #2c4590 100%); + border-radius: 8px 8px 0 0; + color: white; + + .vessel-name { + font-size: 14px; + font-weight: 600; + margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .vessel-id { + font-size: 11px; + opacity: 0.8; + font-family: monospace; + } + } + + .tooltip-content { + padding: 12px 16px; + + .info-row { + display: flex; + align-items: flex-start; + margin-bottom: 8px; + gap: 8px; + + &:last-child { + margin-bottom: 0; + } + + label { + flex-shrink: 0; + width: 80px; + font-weight: 600; + color: #495057; + font-size: 12px; + } + + span { + flex: 1; + color: #212529; + word-break: break-word; + + &.ship-kind { + small { + display: block; + color: #6c757d; + font-size: 11px; + margin-top: 2px; + } + } + + &.country { + display: flex; + align-items: center; + gap: 4px; + } + + &.signal-source { + font-family: monospace; + font-size: 12px; + background: #f8f9fa; + padding: 2px 6px; + border-radius: 4px; + } + + &.position { + font-family: monospace; + font-size: 11px; + color: #495057; + } + + &.update-time { + font-size: 11px; + color: #6c757d; + } + + &.state { + display: inline-block; + padding: 2px 8px; + border-radius: 12px; + font-size: 11px; + font-weight: 500; + + &.normal { + background: #d4edda; + color: #155724; + } + + &.selected { + background: #d1ecf1; + color: #0c5460; + } + + &.deleted { + background: #f8d7da; + color: #721c24; + } + } + } + } + } + + .tooltip-arrow { + position: absolute; + bottom: -8px; + left: 50%; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 8px solid #ffffff; + + &::before { + content: ''; + position: absolute; + bottom: 1px; + left: -9px; + width: 0; + height: 0; + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-top: 9px solid #e9ecef; + } + } + + // 애니메이션 + animation: tooltipFadeIn 0.2s ease-out; + + @keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translate(-50%, -100%) scale(0.95); + } + to { + opacity: 1; + transform: translate(-50%, -100%) scale(1); + } + } + + // 반응형 조정 + @media (max-width: 480px) { + max-width: 280px; + min-width: 240px; + font-size: 12px; + + .tooltip-header { + padding: 10px 12px; + + .vessel-name { + font-size: 13px; + } + + .vessel-id { + font-size: 10px; + } + } + + .tooltip-content { + padding: 10px 12px; + + .info-row { + label { + width: 70px; + font-size: 11px; + } + } + } + } +} \ No newline at end of file diff --git a/src/tracking/hooks/useEquipmentFilter.js b/src/tracking/hooks/useEquipmentFilter.ts similarity index 79% rename from src/tracking/hooks/useEquipmentFilter.js rename to src/tracking/hooks/useEquipmentFilter.ts index f145eb85..4d044856 100644 --- a/src/tracking/hooks/useEquipmentFilter.js +++ b/src/tracking/hooks/useEquipmentFilter.ts @@ -15,24 +15,42 @@ import { SIGNAL_SOURCE_CODE_VPASS, SIGNAL_SOURCE_CODE_VTS_AIS, } from '../../types/constants'; +import type { ProcessedTrack } from '../stores/trackQueryStore'; + +/** 신호원 설정 항목 */ +interface SignalConfig { + key: string; + signalSourceCode: string; + name: string; + shortName: string; + background: string; + priority: number; + displayOrder: number; +} + +/** 장비 목록에 표시되는 확장 항목 */ +interface EquipmentItem extends SignalConfig { + isEnabled: boolean; + trackCount: number; +} /** * 신호 원천별 설정 */ -const SIGNAL_CONFIGS = { +const SIGNAL_CONFIGS: Record = { [SIGNAL_SOURCE_CODE_AIS]: { key: 'A', signalSourceCode: SIGNAL_SOURCE_CODE_AIS, name: 'AIS', shortName: 'AIS', background: '#C2A7DC', priority: 2, displayOrder: 1 }, [SIGNAL_SOURCE_CODE_VPASS]: { key: 'V', signalSourceCode: SIGNAL_SOURCE_CODE_VPASS, name: 'V-Pass', shortName: 'V-P', background: '#8FAEFC', priority: 3, displayOrder: 2 }, [SIGNAL_SOURCE_CODE_ENAV]: { key: 'E', signalSourceCode: SIGNAL_SOURCE_CODE_ENAV, name: 'E-Nav', shortName: 'E-N', background: '#74B2F0', priority: 4, displayOrder: 3 }, [SIGNAL_SOURCE_CODE_VTS_AIS]: { key: 'T', signalSourceCode: SIGNAL_SOURCE_CODE_VTS_AIS, name: 'VTS-AIS', shortName: 'VTS', background: '#4190DF', priority: 1, displayOrder: 4 }, [SIGNAL_SOURCE_CODE_D_MF_HF]: { key: 'D', signalSourceCode: SIGNAL_SOURCE_CODE_D_MF_HF, name: 'D-MF/HF', shortName: 'DMF', background: '#459EF6', priority: 5, displayOrder: 5 }, [SIGNAL_SOURCE_CODE_RADAR]: { key: 'R', signalSourceCode: SIGNAL_SOURCE_CODE_RADAR, name: 'VTS-RT', shortName: 'RT', background: '#4577F6', priority: 6, displayOrder: 6 }, -}; +} as const; /** * 항적 데이터에서 사용된 장비 목록 추출 */ -const extractEquipmentsFromTracks = (tracks) => { - const equipmentCounts = new Map(); +const extractEquipmentsFromTracks = (tracks: ProcessedTrack[]): Map => { + const equipmentCounts = new Map(); tracks.forEach(track => { const sigSrcCd = track.sigSrcCd; @@ -48,18 +66,18 @@ const extractEquipmentsFromTracks = (tracks) => { /** * 장비 필터 훅 * - * @param {Array} tracks 항적 데이터 배열 - * @returns {Object} 장비 필터 관련 상태 및 함수 + * @param tracks 항적 데이터 배열 + * @returns 장비 필터 관련 상태 및 함수 */ -export const useEquipmentFilter = (tracks) => { - const [enabledSet, setEnabledSet] = useState(new Set()); +export const useEquipmentFilter = (tracks: ProcessedTrack[]) => { + const [enabledSet, setEnabledSet] = useState>(new Set()); const equipmentData = useMemo(() => { return extractEquipmentsFromTracks(tracks); }, [tracks]); - const equipments = useMemo(() => { - const items = []; + const equipments = useMemo(() => { + const items: EquipmentItem[] = []; equipmentData.forEach((count, sigSrcCd) => { const config = SIGNAL_CONFIGS[sigSrcCd]; @@ -75,7 +93,7 @@ export const useEquipmentFilter = (tracks) => { return items.sort((a, b) => a.displayOrder - b.displayOrder); }, [equipmentData, enabledSet]); - const highestPriorityEquipment = useMemo(() => { + const highestPriorityEquipment = useMemo(() => { if (equipments.length === 0) return null; return equipments.reduce((prev, curr) => (curr.priority < prev.priority ? curr : prev)); }, [equipments]); @@ -95,7 +113,7 @@ export const useEquipmentFilter = (tracks) => { } }, [equipmentData]); - const toggleEquipment = useCallback((signalSourceCode) => { + const toggleEquipment = useCallback((signalSourceCode: string) => { setEnabledSet(prev => { const next = new Set(prev); if (next.has(signalSourceCode)) { diff --git a/src/tracking/hooks/useTrackHighlight.js b/src/tracking/hooks/useTrackHighlight.ts similarity index 91% rename from src/tracking/hooks/useTrackHighlight.js rename to src/tracking/hooks/useTrackHighlight.ts index ea72f710..69c8f610 100644 --- a/src/tracking/hooks/useTrackHighlight.js +++ b/src/tracking/hooks/useTrackHighlight.ts @@ -14,21 +14,21 @@ export const useTrackHighlight = () => { const setHighlightedVesselId = useTrackQueryStore(state => state.setHighlightedVesselId); const handleListItemHover = useCallback( - (vesselId) => { + (vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId], ); const handlePathHover = useCallback( - (vesselId) => { + (vesselId: string | null) => { setHighlightedVesselId(vesselId); }, [setHighlightedVesselId], ); const isHighlighted = useCallback( - (vesselId) => { + (vesselId: string) => { return highlightedVesselId === vesselId; }, [highlightedVesselId], diff --git a/src/tracking/services/trackQueryApi.js b/src/tracking/services/trackQueryApi.ts similarity index 70% rename from src/tracking/services/trackQueryApi.js rename to src/tracking/services/trackQueryApi.ts index a4e76c7f..71ad63b9 100644 --- a/src/tracking/services/trackQueryApi.js +++ b/src/tracking/services/trackQueryApi.ts @@ -5,6 +5,71 @@ * dark 프로젝트: axios 대신 fetch API 사용 */ import { SOURCE_PRIORITY_RANK, SOURCE_TO_ACTIVE_KEY } from '../../types/constants'; +import type { ProcessedTrack } from '../stores/trackQueryStore'; +import type { LonLat } from '../types/trackQuery.types'; + +// ========== 타입 정의 ========== + +/** 조회 대상 선박 */ +export interface VesselQueryTarget { + sigSrcCd: string; + targetId: string; +} + +/** 항적 조회 요청 파라미터 */ +export interface FetchVesselTracksParams { + startTime: string; + endTime: string; + vessels: VesselQueryTarget[]; + isIntegration?: string; +} + +/** API 응답 항적 데이터 (raw) */ +interface ApiTrackData { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode?: string; + nationalCode?: string; + integrationTargetId?: string; + geometry: LonLat[]; + timestamps: string[]; + speeds?: number[]; + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; +} + +/** 레이더 타겟 조회 결과 */ +interface RadarTargetCheckResult { + canQuery: boolean; + vessel?: VesselQueryTarget; + errorMessage?: string; +} + +/** 항적조회 선박 목록 빌드 결과 */ +export interface BuildVesselListResult { + canQuery: boolean; + vessels: VesselQueryTarget[]; + errorMessage?: string; +} + +/** shipStore의 선박 데이터 (필요한 프로퍼티만) */ +interface ShipFeature { + targetId: string; + signalSourceCode: string; + originalTargetId?: string; + integrate?: boolean; + ais?: string; + vpass?: string; + enav?: string; + vtsAis?: string; + dMfHf?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic properties from shipStore + [key: string]: any; +} /** API 엔드포인트 */ const API_ENDPOINT = '/api/v2/tracks/vessels'; @@ -12,15 +77,11 @@ const API_ENDPOINT = '/api/v2/tracks/vessels'; /** * 선박 항적 조회 (v2 API) * - * @param {Object} params 조회 파라미터 - * @param {string} params.startTime ISO 8601 형식 시작 시간 - * @param {string} params.endTime ISO 8601 형식 종료 시간 - * @param {Array<{sigSrcCd: string, targetId: string}>} params.vessels 조회 대상 선박 목록 - * @param {string} [params.isIntegration] 통합선박 조회 시 "1" - * @returns {Promise} 항적 데이터 배열 + * @param params 조회 파라미터 + * @returns 항적 데이터 배열 */ -export async function fetchVesselTracksV2(params) { - const request = { +export async function fetchVesselTracksV2(params: FetchVesselTracksParams): Promise { + const request: Record = { startTime: params.startTime, endTime: params.endTime, vessels: params.vessels, @@ -45,7 +106,13 @@ export async function fetchVesselTracksV2(params) { /** * 단일 선박 항적 조회 헬퍼 */ -export async function fetchSingleVesselTrackV2(sigSrcCd, targetId, startTime, endTime, isIntegration) { +export async function fetchSingleVesselTrackV2( + sigSrcCd: string, + targetId: string, + startTime: string, + endTime: string, + isIntegration?: string, +): Promise { const tracks = await fetchVesselTracksV2({ startTime, endTime, @@ -58,7 +125,7 @@ export async function fetchSingleVesselTrackV2(sigSrcCd, targetId, startTime, en /** * 유효한 선명인지 확인 */ -function isValidApiShipName(name) { +function isValidApiShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -72,10 +139,10 @@ function isValidApiShipName(name) { * - timestamps를 밀리초로 변환 * - 시간순 정렬 보장 * - * @param {Array} tracks API 응답 항적 배열 - * @returns {Array} ProcessedTrackV2 배열 + * @param tracks API 응답 항적 배열 + * @returns ProcessedTrackV2 배열 */ -export function convertToProcessedTracks(tracks) { +export function convertToProcessedTracks(tracks: ApiTrackData[]): ProcessedTrack[] { return tracks.map(track => { // timestamps를 밀리초로 변환 const timestampsMs = track.timestamps.map(ts => { @@ -114,15 +181,15 @@ export function convertToProcessedTracks(tracks) { * 통합선박 TARGET_ID 파싱 * 형식: AIS_VPASS_ENAV_VTSAIS_DMFHF * - * @param {string} targetId 통합 TARGET_ID - * @returns {Array<{sigSrcCd: string, targetId: string}>|null} + * @param targetId 통합 TARGET_ID + * @returns 선박 목록 또는 null */ -export function parseIntegratedTargetId(targetId) { +export function parseIntegratedTargetId(targetId: string): VesselQueryTarget[] | null { const parts = targetId.split('_'); if (parts.length !== 5) return null; const [ais, vpass, enav, vtsAis, dmfhf] = parts; - const vessels = []; + const vessels: VesselQueryTarget[] = []; if (ais) vessels.push({ sigSrcCd: '000001', targetId: ais }); if (vpass) vessels.push({ sigSrcCd: '000003', targetId: vpass }); @@ -134,23 +201,23 @@ export function parseIntegratedTargetId(targetId) { } /** 통합선박 여부 확인 */ -export function isIntegratedVessel(signalSourceCode) { +export function isIntegratedVessel(signalSourceCode: string): boolean { return signalSourceCode === '999999'; } /** 레이더 타겟 여부 확인 */ -export function isRadarTarget(signalSourceCode) { +export function isRadarTarget(signalSourceCode: string): boolean { return signalSourceCode === '000005'; } /** * 레이더 타겟 항적조회 가능 여부 확인 * - * @param {string} targetId 레이더 타겟의 TARGET_ID - * @param {string} [isPriority] IS_PRIORITY 값 - * @returns {{ canQuery: boolean, vessel?: Object, errorMessage?: string }} + * @param targetId 레이더 타겟의 TARGET_ID + * @param _isPriority IS_PRIORITY 값 + * @returns 조회 가능 여부 및 선박 정보 */ -export function checkRadarTargetForTrackQuery(targetId, isPriority) { +export function checkRadarTargetForTrackQuery(targetId: string, _isPriority?: string): RadarTargetCheckResult { const isIntegrated = targetId.includes('_'); if (!isIntegrated) { @@ -184,10 +251,10 @@ export function checkRadarTargetForTrackQuery(targetId, isPriority) { /** * 통합선박 전체 장비 조회용 선박 목록 반환 * - * @param {string} targetId 통합선박 TARGET_ID - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param targetId 통합선박 TARGET_ID + * @returns 선박 목록 */ -export function getAllVesselsForIntegratedShip(targetId) { +export function getAllVesselsForIntegratedShip(targetId: string): VesselQueryTarget[] { if (!targetId.includes('_')) return []; const vessels = parseIntegratedTargetId(targetId); return vessels || []; @@ -197,10 +264,10 @@ export function getAllVesselsForIntegratedShip(targetId) { * 통합선박의 활성화된 장비만 조회용 선박 목록 반환 * dark 프로젝트의 ship 객체 프로퍼티 기반 * - * @param {Object} ship shipStore의 선박 데이터 - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param ship shipStore의 선박 데이터 + * @returns 선박 목록 */ -export function getActiveVesselsForIntegratedShip(ship) { +export function getActiveVesselsForIntegratedShip(ship: ShipFeature): VesselQueryTarget[] { const targetId = ship.targetId; if (!targetId || !targetId.includes('_')) return []; @@ -208,7 +275,7 @@ export function getActiveVesselsForIntegratedShip(ship) { if (parts.length !== 5) return []; const [ais, vpass, enav, vtsAis, dmfhf] = parts; - const vessels = []; + const vessels: VesselQueryTarget[] = []; if (ais && ship.ais === '1') vessels.push({ sigSrcCd: '000001', targetId: ais }); if (vpass && ship.vpass === '1') vessels.push({ sigSrcCd: '000003', targetId: vpass }); @@ -222,18 +289,18 @@ export function getActiveVesselsForIntegratedShip(ship) { /** * 통합선박 TARGET_ID 여부 확인 */ -export function isIntegratedTargetId(targetId) { - return targetId && targetId.includes('_'); +export function isIntegratedTargetId(targetId: string): boolean { + return !!targetId && targetId.includes('_'); } /** * 통합선박에서 활성화된 장비 중 우선순위가 가장 높은 선박 반환 * dark 프로젝트의 ship 객체 프로퍼티 기반 * - * @param {Object} ship shipStore의 선박 데이터 - * @returns {{ sigSrcCd: string, targetId: string }|null} + * @param ship shipStore의 선박 데이터 + * @returns 최고 우선순위 선박 또는 null */ -export function getHighestPriorityActiveVessel(ship) { +export function getHighestPriorityActiveVessel(ship: ShipFeature): VesselQueryTarget | null { const targetId = ship.targetId; if (!targetId || !targetId.includes('_')) return null; @@ -269,13 +336,18 @@ export function getHighestPriorityActiveVessel(ship) { * - 레이더 타겟 → 제외 * - 나머지 → sigSrcCd + originalTargetId 직접 전달 * - * @param {Object} ship shipStore의 선박 데이터 - * @param {'modal'|'rightClick'} mode 조회 모드 - * @param {boolean} isIntegrate 통합선박 ON/OFF 상태 - * @param {Map} [features] shipStore.features (레이더+단독선박 통합 시 비레이더 탐색용) - * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + * @param ship shipStore의 선박 데이터 + * @param mode 조회 모드 + * @param isIntegrate 통합선박 ON/OFF 상태 + * @param features shipStore.features (레이더+단독선박 통합 시 비레이더 탐색용) + * @returns 조회 가능 여부 및 선박 목록 */ -export function buildVesselListForQuery(ship, mode, isIntegrate, features) { +export function buildVesselListForQuery( + ship: ShipFeature, + mode: 'modal' | 'rightClick', + isIntegrate: boolean, + features?: Map, +): BuildVesselListResult { const sigSrcCd = ship.signalSourceCode || ''; const targetId = ship.targetId || ''; const isRadar = isRadarTarget(sigSrcCd); @@ -336,23 +408,26 @@ export function buildVesselListForQuery(ship, mode, isIntegrate, features) { * 레이더 + 단독선박 통합 시 비레이더 feature를 탐색하여 요청 파라미터 생성 * shipStore.features에서 같은 targetId의 비레이더 신호원을 찾아 우선순위 기반 선택 * - * @param {Object} ship 레이더 타겟 ship 객체 - * @param {Map} features shipStore.features - * @returns {{ canQuery: boolean, vessels: Array, errorMessage?: string }} + * @param ship 레이더 타겟 ship 객체 + * @param features shipStore.features + * @returns 조회 가능 여부 및 선박 목록 */ -function resolveStandaloneForRadar(ship, features) { +function resolveStandaloneForRadar( + ship: ShipFeature, + features?: Map, +): BuildVesselListResult { if (!features) { return { canQuery: false, vessels: [], errorMessage: '통합된 단독선박 정보를 찾을 수 없습니다.' }; } const targetId = ship.targetId; - let bestVessel = null; + let bestVessel: VesselQueryTarget | null = null; let bestRank = 99; features.forEach((f) => { if (f.targetId !== targetId) return; if (isRadarTarget(f.signalSourceCode)) return; - const rank = SOURCE_PRIORITY_RANK[f.signalSourceCode] ?? 99; - const activeKey = SOURCE_TO_ACTIVE_KEY[f.signalSourceCode]; + const rank = (SOURCE_PRIORITY_RANK as Record)[f.signalSourceCode] ?? 99; + const activeKey = (SOURCE_TO_ACTIVE_KEY as Record)[f.signalSourceCode]; // is_active 확인 if (activeKey && f[activeKey] === '1' && rank < bestRank) { bestRank = rank; @@ -378,11 +453,11 @@ function resolveStandaloneForRadar(ship, features) { * 항적조회 요청 파라미터 중복 제거 * (sigSrcCd, targetId) 조합이 동일한 항목 제거 * - * @param {Array<{sigSrcCd: string, targetId: string}>} vessels - * @returns {Array<{sigSrcCd: string, targetId: string}>} + * @param vessels 선박 목록 + * @returns 중복 제거된 선박 목록 */ -export function deduplicateVessels(vessels) { - const seen = new Set(); +export function deduplicateVessels(vessels: VesselQueryTarget[]): VesselQueryTarget[] { + const seen = new Set(); return vessels.filter(v => { const key = `${v.sigSrcCd}_${v.targetId}`; if (seen.has(key)) return false; diff --git a/src/tracking/stores/trackQueryAnimationStore.js b/src/tracking/stores/trackQueryAnimationStore.ts similarity index 77% rename from src/tracking/stores/trackQueryAnimationStore.js rename to src/tracking/stores/trackQueryAnimationStore.ts index 79e19142..4ffd9286 100644 --- a/src/tracking/stores/trackQueryAnimationStore.js +++ b/src/tracking/stores/trackQueryAnimationStore.ts @@ -8,12 +8,38 @@ */ import { create } from 'zustand'; -// 애니메이션 프레임 관리용 변수 (스토어 외부) -let animationFrameId = null; -let lastFrameTime = null; +// ========== 타입 정의 ========== -export const useTrackQueryAnimationStore = create((set, get) => { - const animate = () => { +interface TrackQueryAnimationState { + isPlaying: boolean; + currentTime: number; + startTime: number; + endTime: number; + playbackSpeed: number; + loop: boolean; + loopStart: number; + loopEnd: number; + + play: () => void; + pause: () => void; + stop: () => void; + setCurrentTime: (time: number) => void; + setPlaybackSpeed: (speed: number) => void; + toggleLoop: () => void; + setLoopSection: (start: number, end: number) => void; + resetLoopSection: () => void; + setTimeRange: (start: number, end: number) => void; + getProgress: () => number; + getLoopProgress: () => { start: number; end: number }; + reset: () => void; +} + +// 애니메이션 프레임 관리용 변수 (스토어 외부) +let animationFrameId: number | null = null; +let lastFrameTime: number | null = null; + +export const useTrackQueryAnimationStore = create()((set, get) => { + const animate = (): void => { const state = get(); if (!state.isPlaying) return; @@ -93,17 +119,17 @@ export const useTrackQueryAnimationStore = create((set, get) => { set({ isPlaying: false, currentTime: get().startTime }); }, - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { const { startTime, endTime } = get(); const clampedTime = Math.max(startTime, Math.min(endTime, time)); set({ currentTime: clampedTime }); }, - setPlaybackSpeed: (speed) => set({ playbackSpeed: speed }), + setPlaybackSpeed: (speed: number) => set({ playbackSpeed: speed }), toggleLoop: () => set({ loop: !get().loop }), - setLoopSection: (start, end) => { + setLoopSection: (start: number, end: number) => { const { startTime, endTime } = get(); const clampedStart = Math.max(startTime, Math.min(end, start)); const clampedEnd = Math.max(start, Math.min(endTime, end)); @@ -115,7 +141,7 @@ export const useTrackQueryAnimationStore = create((set, get) => { set({ loopStart: startTime, loopEnd: endTime }); }, - setTimeRange: (start, end) => { + setTimeRange: (start: number, end: number) => { set({ startTime: start, endTime: end, @@ -162,4 +188,4 @@ export const useTrackQueryAnimationStore = create((set, get) => { }); /** 재생 가능한 배속 옵션 */ -export const PLAYBACK_SPEED_OPTIONS = [1, 5, 10, 50, 100, 1000]; +export const PLAYBACK_SPEED_OPTIONS: number[] = [1, 5, 10, 50, 100, 1000]; diff --git a/src/tracking/stores/trackQueryStore.js b/src/tracking/stores/trackQueryStore.ts similarity index 61% rename from src/tracking/stores/trackQueryStore.js rename to src/tracking/stores/trackQueryStore.ts index 19d56b1b..ef0b1722 100644 --- a/src/tracking/stores/trackQueryStore.js +++ b/src/tracking/stores/trackQueryStore.ts @@ -8,11 +8,109 @@ import { create } from 'zustand'; import { subscribeWithSelector } from 'zustand/middleware'; import useShipStore from '../../stores/shipStore'; +import type { LonLat } from '../types/trackQuery.types'; + +// ========== 타입 정의 ========== + +/** 가공된 항적 데이터 */ +export interface ProcessedTrack { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + integrationTargetId?: string; + geometry: LonLat[]; + timestampsMs: number[]; + speeds: number[]; + stats?: { + totalDistance: number; + avgSpeed: number; + maxSpeed: number; + pointCount: number; + }; +} + +/** 선박 현재 위치 */ +export interface VesselPosition { + vesselId: string; + targetId: string; + sigSrcCd: string; + shipName: string; + shipKindCode: string; + nationalCode: string; + position: LonLat; + heading: number; + speed: number; + timestamp: number; +} + +/** 호버된 포인트 정보 */ +export interface HoveredPointInfo { + vesselId: string; + position: LonLat; + timestamp: number; + speed: number; + index: number; +} + +/** 라이브 선박 정보 결과 */ +interface LiveShipInfo { + shipName: string | null; + shipKindCode: string | null; + integrationTargetId: string | null; +} + +/** 항적조회 스토어 상태 */ +interface TrackQueryState { + tracks: ProcessedTrack[]; + disabledVesselIds: Set; + dataStartTime: number; + dataEndTime: number; + requestedStartTime: number; + currentTime: number; + isLoading: boolean; + error: string | null; + showPoints: boolean; + showVirtualShip: boolean; + hideLiveShips: boolean; + showLabels: boolean; + isModalMode: boolean; + modalSourceId: string | null; + highlightedVesselId: string | null; + showPlayback: boolean; + + // 포인트 호버 정보 + hoveredPoint: HoveredPointInfo | null; + hoveredPointPosition: { x: number; y: number }; + + setTracks: (tracks: ProcessedTrack[], requestedStartTime: number, showPlayback?: boolean) => void; + setCurrentTime: (time: number) => void; + setProgressByRatio: (ratio: number) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setShowPoints: (show: boolean) => void; + setShowVirtualShip: (show: boolean) => void; + setHideLiveShips: (hide: boolean) => void; + setShowLabels: (show: boolean) => void; + setModalMode: (isModal: boolean, sourceId?: string | null) => void; + getModalSourceId: () => string | null; + setHighlightedVesselId: (vesselId: string | null) => void; + setHoveredPoint: (point: HoveredPointInfo | null, x?: number, y?: number) => void; + clearHoveredPoint: () => void; + toggleVesselEnabled: (vesselId: string) => void; + isVesselEnabled: (vesselId: string) => boolean; + getEnabledTracks: () => ProcessedTrack[]; + getProgress: () => number; + getCurrentPositions: () => VesselPosition[]; + reset: () => void; +} /** * 유효한 선명인지 확인 */ -function isValidShipName(name) { +function isValidShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -26,12 +124,13 @@ function isValidShipName(name) { * 라이브 선박 데이터 가져오기 * dark 프로젝트의 shipStore.features 기반 */ -function getLiveShipInfo(sigSrcCd, targetId) { +function getLiveShipInfo(sigSrcCd: string, targetId: string): LiveShipInfo { const { features } = useShipStore.getState(); const featureKey = `${sigSrcCd}${targetId}`; - const liveShip = features.get(featureKey); + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shipStore is not yet typed + const liveShip = (features as Map).get(featureKey); - const result = { shipName: null, shipKindCode: null, integrationTargetId: null }; + const result: LiveShipInfo = { shipName: null, shipKindCode: null, integrationTargetId: null }; if (liveShip) { if (isValidShipName(liveShip.shipName)) { @@ -51,10 +150,10 @@ function getLiveShipInfo(sigSrcCd, targetId) { /** * 항적 데이터에 라이브 선박 정보 병합 */ -function mergeWithLiveData(tracks) { +function mergeWithLiveData(tracks: ProcessedTrack[]): ProcessedTrack[] { return tracks.map(track => { const liveInfo = getLiveShipInfo(track.sigSrcCd, track.targetId); - let updated = { ...track }; + const updated = { ...track }; let hasChanges = false; if (liveInfo.shipName) { @@ -88,7 +187,7 @@ function mergeWithLiveData(tracks) { /** * 두 지점 사이의 선박 위치를 시간 기반으로 보간 */ -function interpolatePosition(p1, p2, t1, t2, currentTime) { +function interpolatePosition(p1: LonLat, p2: LonLat, t1: number, t2: number, currentTime: number): LonLat { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -103,7 +202,7 @@ function interpolatePosition(p1, p2, t1, t2, currentTime) { /** * 두 지점 간의 방향(heading) 계산 */ -function calculateHeading(p1, p2) { +function calculateHeading(p1: LonLat, p2: LonLat): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; @@ -120,9 +219,9 @@ function calculateHeading(p1, p2) { /** * 항적조회 스토어 */ -export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ +export const useTrackQueryStore = create()(subscribeWithSelector((set, get) => ({ tracks: [], - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, @@ -146,7 +245,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ * 항적 데이터 설정 * 라이브 선박 데이터와 병합하여 선명 정보 보완 */ - setTracks: (tracks, requestedStartTime, showPlayback = false) => { + setTracks: (tracks: ProcessedTrack[], requestedStartTime: number, showPlayback = false) => { if (tracks.length === 0) { set({ tracks: [], @@ -186,27 +285,27 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ }); }, - setCurrentTime: (time) => { + setCurrentTime: (time: number) => { const { dataStartTime, dataEndTime } = get(); const clampedTime = Math.max(dataStartTime, Math.min(dataEndTime, time)); set({ currentTime: clampedTime }); }, - setProgressByRatio: (ratio) => { + setProgressByRatio: (ratio: number) => { const { dataStartTime, dataEndTime } = get(); const clampedRatio = Math.max(0, Math.min(1, ratio)); const newTime = dataStartTime + (dataEndTime - dataStartTime) * clampedRatio; set({ currentTime: newTime }); }, - setLoading: (loading) => set({ isLoading: loading }), - setError: (error) => set({ error }), - setShowPoints: (show) => set({ showPoints: show }), - setShowVirtualShip: (show) => set({ showVirtualShip: show }), - setHideLiveShips: (hide) => set({ hideLiveShips: hide }), - setShowLabels: (show) => set({ showLabels: show }), + setLoading: (loading: boolean) => set({ isLoading: loading }), + setError: (error: string | null) => set({ error }), + setShowPoints: (show: boolean) => set({ showPoints: show }), + setShowVirtualShip: (show: boolean) => set({ showVirtualShip: show }), + setHideLiveShips: (hide: boolean) => set({ hideLiveShips: hide }), + setShowLabels: (show: boolean) => set({ showLabels: show }), - setModalMode: (isModal, sourceId) => { + setModalMode: (isModal: boolean, sourceId?: string | null) => { set({ isModalMode: isModal, modalSourceId: isModal ? (sourceId || null) : null, @@ -215,9 +314,9 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ getModalSourceId: () => get().modalSourceId, - setHighlightedVesselId: (vesselId) => set({ highlightedVesselId: vesselId }), + setHighlightedVesselId: (vesselId: string | null) => set({ highlightedVesselId: vesselId }), - setHoveredPoint: (point, x, y) => set({ + setHoveredPoint: (point: HoveredPointInfo | null, x?: number, y?: number) => set({ hoveredPoint: point, hoveredPointPosition: { x: x || 0, y: y || 0 }, }), @@ -227,7 +326,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ hoveredPointPosition: { x: 0, y: 0 }, }), - toggleVesselEnabled: (vesselId) => { + toggleVesselEnabled: (vesselId: string) => { const { disabledVesselIds } = get(); const newDisabled = new Set(disabledVesselIds); if (newDisabled.has(vesselId)) { @@ -238,7 +337,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ set({ disabledVesselIds: newDisabled }); }, - isVesselEnabled: (vesselId) => !get().disabledVesselIds.has(vesselId), + isVesselEnabled: (vesselId: string) => !get().disabledVesselIds.has(vesselId), getEnabledTracks: () => { const { tracks, disabledVesselIds } = get(); @@ -257,7 +356,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ */ getCurrentPositions: () => { const { tracks, currentTime, disabledVesselIds } = get(); - const positions = []; + const positions: VesselPosition[] = []; tracks.forEach(track => { if (disabledVesselIds.has(track.vesselId)) return; @@ -285,9 +384,9 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ const idx1 = Math.max(0, left - 1); const idx2 = Math.min(timestampsMs.length - 1, left); - let position; - let heading; - let speed; + let position: LonLat; + let heading: number; + let speed: number; if (idx1 === idx2 || timestampsMs[idx1] === timestampsMs[idx2]) { position = geometry[idx1]; @@ -328,7 +427,7 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ reset: () => { set({ tracks: [], - disabledVesselIds: new Set(), + disabledVesselIds: new Set(), dataStartTime: 0, dataEndTime: 0, requestedStartTime: 0, @@ -350,10 +449,10 @@ export const useTrackQueryStore = create(subscribeWithSelector((set, get) => ({ }))); // 편의 셀렉터 -export const useTrackQueryTracks = () => useTrackQueryStore(state => state.tracks); -export const useTrackQueryCurrentTime = () => useTrackQueryStore(state => state.currentTime); -export const useTrackQueryIsLoading = () => useTrackQueryStore(state => state.isLoading); -export const useTrackQueryShowPoints = () => useTrackQueryStore(state => state.showPoints); -export const useTrackQueryShowVirtualShip = () => useTrackQueryStore(state => state.showVirtualShip); +export const useTrackQueryTracks = (): ProcessedTrack[] => useTrackQueryStore(state => state.tracks); +export const useTrackQueryCurrentTime = (): number => useTrackQueryStore(state => state.currentTime); +export const useTrackQueryIsLoading = (): boolean => useTrackQueryStore(state => state.isLoading); +export const useTrackQueryShowPoints = (): boolean => useTrackQueryStore(state => state.showPoints); +export const useTrackQueryShowVirtualShip = (): boolean => useTrackQueryStore(state => state.showVirtualShip); export default useTrackQueryStore; diff --git a/src/tracking/types/trackQuery.types.js b/src/tracking/types/trackQuery.types.ts similarity index 62% rename from src/tracking/types/trackQuery.types.js rename to src/tracking/types/trackQuery.types.ts index 6c35ff35..4efc492a 100644 --- a/src/tracking/types/trackQuery.types.js +++ b/src/tracking/types/trackQuery.types.ts @@ -7,10 +7,24 @@ * TypeScript 타입은 JSDoc 주석으로 대체 */ +// ========== 공통 타입 ========== + +/** RGBA 색상 튜플 */ +export type RGBAColor = [number, number, number, number]; + +/** 좌표 [경도, 위도] */ +export type LonLat = [number, number]; + +/** 선종 코드 */ +export type ShipKindCode = '000020' | '000021' | '000022' | '000023' | '000024' | '000025' | '000027' | '000028'; + +/** 신호원 코드 */ +export type SignalSourceCode = '000001' | '000002' | '000003' | '000004' | '000005' | '000016' | '999999'; + // ========== 선종 색상 매핑 ========== /** 선종별 색상 정의 (RGBA, 60% 투명도) */ -export const SHIP_KIND_COLORS = { +export const SHIP_KIND_COLORS: Record = { '000020': [25, 116, 25, 150], // 어선 - 녹색 '000021': [0, 41, 255, 150], // 함정 - 파란색 '000022': [176, 42, 42, 150], // 여객선 - 빨간색 @@ -22,17 +36,17 @@ export const SHIP_KIND_COLORS = { }; /** 기본 색상 (선종 미확인 시) */ -export const DEFAULT_TRACK_COLOR = [128, 128, 128, 150]; +export const DEFAULT_TRACK_COLOR: RGBAColor = [128, 128, 128, 150]; /** 선종 코드로 색상 가져오기 */ -export function getShipKindColor(shipKindCode) { +export function getShipKindColor(shipKindCode: string | undefined | null): RGBAColor { if (!shipKindCode) return DEFAULT_TRACK_COLOR; return SHIP_KIND_COLORS[shipKindCode] || DEFAULT_TRACK_COLOR; } /** 선종 코드 → 선종명 */ -export function getShipKindName(shipKindCode) { - const names = { +export function getShipKindName(shipKindCode: string | undefined | null): string { + const names: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -46,8 +60,8 @@ export function getShipKindName(shipKindCode) { } /** 신호원 코드 → 신호원명 */ -export function getSignalSourceName(sigSrcCd) { - const names = { +export function getSignalSourceName(sigSrcCd: string | undefined | null): string { + const names: Record = { '000001': 'AIS', '000002': 'E-Nav', '000003': 'V-Pass', diff --git a/src/tracking/utils/resetTrackQuery.js b/src/tracking/utils/resetTrackQuery.ts similarity index 79% rename from src/tracking/utils/resetTrackQuery.js rename to src/tracking/utils/resetTrackQuery.ts index d439e11f..f0f266e9 100644 --- a/src/tracking/utils/resetTrackQuery.js +++ b/src/tracking/utils/resetTrackQuery.ts @@ -7,13 +7,14 @@ import { useTrackQueryStore } from '../stores/trackQueryStore'; import { unregisterTrackQueryLayers } from './trackQueryLayerUtils'; import { shipBatchRenderer } from '../../map/ShipBatchRenderer'; +import type maplibregl from 'maplibre-gl'; /** * 항적조회 상태 및 레이어 초기화 * - * @param {Object} [map] OpenLayers Map 인스턴스 + * @param map MapLibre GL JS Map 인스턴스 */ -export const resetTrackQuery = (map) => { +export const resetTrackQuery = (map?: maplibregl.Map): void => { // 스토어 초기화 useTrackQueryStore.getState().reset(); diff --git a/src/tracking/utils/shipIconUtil.js b/src/tracking/utils/shipIconUtil.ts similarity index 56% rename from src/tracking/utils/shipIconUtil.js rename to src/tracking/utils/shipIconUtil.ts index f76e4eb9..dfd063ca 100644 --- a/src/tracking/utils/shipIconUtil.js +++ b/src/tracking/utils/shipIconUtil.ts @@ -9,27 +9,27 @@ import { ICON_MAPPING_KIND_MOVING, ICON_MAPPING_KIND_STOPPING } from '../../type /** * 선박 종류 코드와 항해 상태로 아이콘 이름 가져오기 * - * @param {string} shipKindCode 선박 종류 코드 (000020-000028) - * @param {boolean} [isMoving=true] 항해 중 여부 - * @returns {string} 아이콘 매핑 키 + * @param shipKindCode 선박 종류 코드 (000020-000028) + * @param isMoving 항해 중 여부 + * @returns 아이콘 매핑 키 */ -export function getShipIconUrl(shipKindCode, isMoving = true) { +export function getShipIconUrl(shipKindCode: string, isMoving = true): string { const iconMapping = isMoving ? ICON_MAPPING_KIND_MOVING : ICON_MAPPING_KIND_STOPPING; - return iconMapping[shipKindCode] || iconMapping['000027']; + return (iconMapping as Record)[shipKindCode] || (iconMapping as Record)['000027']; } /** * 속도 기반 선박 아이콘 가져오기 * 0.5 knots를 기준으로 항해 중 여부를 결정 * - * @param {string} shipKindCode 선박 종류 코드 - * @param {number} [speed=0] 선박 속도 (knots) - * @returns {string} 아이콘 매핑 키 + * @param shipKindCode 선박 종류 코드 + * @param speed 선박 속도 (knots) + * @returns 아이콘 매핑 키 */ -export function getV2ShipIconUrl(shipKindCode, speed = 0) { +export function getV2ShipIconUrl(shipKindCode: string, speed = 0): string { const isMoving = speed > 0.5; return getShipIconUrl(shipKindCode, isMoving); } @@ -37,11 +37,11 @@ export function getV2ShipIconUrl(shipKindCode, speed = 0) { /** * 선박 종류 코드를 한글 이름으로 변환 * - * @param {string} shipKindCode 선박 종류 코드 - * @returns {string} 선박 타입 한글 이름 + * @param shipKindCode 선박 종류 코드 + * @returns 선박 타입 한글 이름 */ -export function getShipKindName(shipKindCode) { - const kindNames = { +export function getShipKindName(shipKindCode: string): string { + const kindNames: Record = { '000020': '어선', '000021': '함정', '000022': '여객선', @@ -58,11 +58,11 @@ export function getShipKindName(shipKindCode) { /** * 신호 소스 코드를 이름으로 변환 * - * @param {string} signalSourceCode 신호 소스 코드 - * @returns {string} 신호 소스 이름 + * @param signalSourceCode 신호 소스 코드 + * @returns 신호 소스 이름 */ -export function getSignalSourceName(signalSourceCode) { - const sourceNames = { +export function getSignalSourceName(signalSourceCode: string): string { + const sourceNames: Record = { '000001': 'AIS', '000002': 'E-NAV', '000003': 'V-PASS', diff --git a/src/tracking/utils/trackQueryLayerUtils.js b/src/tracking/utils/trackQueryLayerUtils.ts similarity index 65% rename from src/tracking/utils/trackQueryLayerUtils.js rename to src/tracking/utils/trackQueryLayerUtils.ts index 0078fe4b..22556cdb 100644 --- a/src/tracking/utils/trackQueryLayerUtils.js +++ b/src/tracking/utils/trackQueryLayerUtils.ts @@ -9,8 +9,8 @@ import { PathLayer, ScatterplotLayer, IconLayer, TextLayer } from '@deck.gl/layers'; import { PathStyleExtension } from '@deck.gl/extensions'; -import { getShipIconUrl } from './shipIconUtil'; -import { getShipKindColor } from '../types/trackQuery.types'; +import type { Layer, PickingInfo } from '@deck.gl/core'; +import { getShipKindColor, type RGBAColor } from '../types/trackQuery.types'; import useShipStore from '../../stores/shipStore'; import { useMapStore, THEME_COLORS, THEME_TYPES } from '../../stores/mapStore'; import { @@ -19,6 +19,130 @@ import { } from '../../types/constants'; import atlasImg from '../../assets/img/icon/atlas.png'; +// ========== 인터페이스 ========== + +/** 항적 트랙 데이터 */ +interface TrackData { + vesselId: string; + geometry: [number, number][]; + timestampsMs: number[]; + speeds: number[]; + shipKindCode: string; + shipName: string; + sigSrcCd: string; + targetId: string; +} + +/** PathLayer 데이터 항목 */ +interface PathDataItem { + path: [number, number][]; + color: RGBAColor; + width: number; + vesselId: string; + shipKindCode: string; + shipName: string; +} + +/** 포인트 데이터 항목 */ +interface PointDataItem { + position: [number, number]; + color: [number, number, number, number]; + vesselId: string; + timestamp: number; + speed: number; + index: number; +} + +/** 가상 선박 위치 정보 */ +interface VirtualShipPosition { + position: [number, number]; + vesselId: string; + heading: number; + shipName: string; + speed: number; + shipKindCode: string; + targetId: string; +} + +/** 가상 선박 IconLayer 데이터 */ +interface VirtualShipIconData { + position: [number, number]; + icon: string; + size: number; + vesselId: string; + heading: number; + shipName: string; + speed: number; + shipKindCode: string; +} + +/** 가상 선박 글로우 데이터 */ +interface VirtualShipGlowData { + position: [number, number]; + color: [number, number, number, number]; + vesselId: string; +} + +/** 선명 라벨 데이터 */ +interface LabelData { + position: [number, number]; + text: string; + vesselId: string; +} + +/** 연결선 데이터 */ +interface ConnectionData { + path: [number, number][]; + color: [number, number, number, number]; + vesselId: string; +} + +/** 포인트 밀도 설정 */ +interface PointDensityConfig { + gridSizeMultiplier: number; + maxPointsPerCell: number; + minPointRadius: number; + maxPointRadius: number; +} + +/** 경로 레이어 옵션 */ +interface PathLayerOptions { + updateTrigger: number; + highlightedVesselId?: string | null; + loop?: boolean; + loopStart?: number; + loopEnd?: number; +} + +/** 포인트 레이어 옵션 */ +interface PointsLayerOptions { + updateTrigger: number; + zoomLevel?: number; +} + +/** 동적 레이어 옵션 */ +interface DynamicLayerOptions { + showVirtualShip: boolean; + showLabels?: boolean; + updateTrigger: number; +} + +/** 포인트 호버 정보 */ +interface PointHoverInfo { + vesselId: string; + position: [number, number]; + timestamp: number; + speed: number; + index: number; +} + +/** 포맷된 포인트 정보 */ +interface FormattedPointInfo { + time: string; + position: string; + speed: string; +} + /** 현재 테마 색상 가져오기 */ function getCurrentThemeColors() { const { getTheme } = useMapStore.getState(); @@ -28,7 +152,7 @@ function getCurrentThemeColors() { // ========== 줌 레벨별 포인트 밀도 설정 ========== -const POINT_DENSITY_CONFIGS = { +const POINT_DENSITY_CONFIGS: Record = { 5: { gridSizeMultiplier: 400, maxPointsPerCell: 1, minPointRadius: 2, maxPointRadius: 3 }, 6: { gridSizeMultiplier: 300, maxPointsPerCell: 2, minPointRadius: 2, maxPointRadius: 3 }, 7: { gridSizeMultiplier: 200, maxPointsPerCell: 3, minPointRadius: 2, maxPointRadius: 4 }, @@ -41,7 +165,7 @@ const POINT_DENSITY_CONFIGS = { 14: { gridSizeMultiplier: 20, maxPointsPerCell: Infinity, minPointRadius: 4, maxPointRadius: 8 }, }; -const getPointDensityConfig = (zoomLevel) => { +const getPointDensityConfig = (zoomLevel: number): PointDensityConfig => { const clampedZoom = Math.max(5, Math.min(14, Math.floor(zoomLevel))); return POINT_DENSITY_CONFIGS[clampedZoom] || POINT_DENSITY_CONFIGS[10]; }; @@ -57,12 +181,12 @@ export const LAYER_IDS = { VIRTUAL_SHIP_LABEL: 'track-query-virtual-ship-label-layer', LIVE_CONNECTION: 'track-query-live-connection-layer', TOOLTIP: 'track-query-tooltip-layer', -}; +} as const; // ========== 데이터 생성 함수 ========== /** 항적 라인 PathLayer 데이터 생성 */ -export function createPathLayerData(tracks) { +export function createPathLayerData(tracks: TrackData[]): PathDataItem[] { return tracks.map(track => ({ path: track.geometry, color: getShipKindColor(track.shipKindCode), @@ -74,12 +198,12 @@ export function createPathLayerData(tracks) { } /** 포인트 ScatterplotLayer 데이터 생성 (클러스터링 적용) */ -export function createPointsLayerData(tracks, zoomLevel) { - const points = []; +export function createPointsLayerData(tracks: TrackData[], zoomLevel?: number): PointDataItem[] { + const points: PointDataItem[] = []; tracks.forEach(track => { const color = getShipKindColor(track.shipKindCode); - const pointColor = [ + const pointColor: [number, number, number, number] = [ Math.min(255, color[0] + 30), Math.min(255, color[1] + 30), Math.min(255, color[2] + 30), @@ -106,7 +230,7 @@ export function createPointsLayerData(tracks, zoomLevel) { } /** 항적 포인트 클러스터링 */ -function clusterTrackPoints(points, zoomLevel) { +function clusterTrackPoints(points: PointDataItem[], zoomLevel: number): PointDataItem[] { const config = getPointDensityConfig(zoomLevel); if (config.maxPointsPerCell === Infinity) { @@ -114,7 +238,7 @@ function clusterTrackPoints(points, zoomLevel) { } const gridSize = Math.pow(2, -zoomLevel) * config.gridSizeMultiplier; - const gridCells = new Map(); + const gridCells = new Map(); for (const point of points) { const gridX = Math.floor(point.position[0] / gridSize); @@ -124,10 +248,10 @@ function clusterTrackPoints(points, zoomLevel) { if (!gridCells.has(gridKey)) { gridCells.set(gridKey, []); } - gridCells.get(gridKey).push(point); + gridCells.get(gridKey)!.push(point); } - const result = []; + const result: PointDataItem[] = []; gridCells.forEach(cellPoints => { if (cellPoints.length <= config.maxPointsPerCell) { @@ -146,7 +270,7 @@ function clusterTrackPoints(points, zoomLevel) { } /** 가상 선박 IconLayer 데이터 생성 */ -export function createVirtualShipData(positions) { +export function createVirtualShipData(positions: VirtualShipPosition[]): VirtualShipIconData[] { return positions.map(pos => ({ position: pos.position, icon: ICON_MAPPING_KIND_MOVING[pos.shipKindCode] || 'etcImg', @@ -160,19 +284,19 @@ export function createVirtualShipData(positions) { } /** 가상 선박 글로우 효과 데이터 생성 */ -export function createVirtualShipGlowData(positions) { +export function createVirtualShipGlowData(positions: VirtualShipPosition[]): VirtualShipGlowData[] { return positions.map(pos => { const color = getShipKindColor(pos.shipKindCode); return { position: pos.position, - color: [color[0], color[1], color[2], 120], + color: [color[0], color[1], color[2], 120] as [number, number, number, number], vesselId: pos.vesselId, }; }); } /** 유효한 선명인지 확인 */ -function isValidLabelShipName(name) { +function isValidLabelShipName(name: string | undefined | null): boolean { if (!name) return false; const trimmed = name.trim(); if (!trimmed) return false; @@ -182,7 +306,7 @@ function isValidLabelShipName(name) { } /** 선명 라벨 데이터 생성 */ -export function createVirtualShipLabelData(positions) { +export function createVirtualShipLabelData(positions: VirtualShipPosition[]): LabelData[] { return positions.map(pos => ({ position: pos.position, text: isValidLabelShipName(pos.shipName) ? pos.shipName : pos.targetId, @@ -195,25 +319,29 @@ export function createVirtualShipLabelData(positions) { /** * 경로 레이어 생성 */ -export function createPathLayers(tracks, options, onPathHover) { - const layers = []; +export function createPathLayers( + tracks: TrackData[], + options: PathLayerOptions, + onPathHover?: ((vesselId: string | null) => void) | null, +): Layer[] { + const layers: Layer[] = []; const { updateTrigger, highlightedVesselId = null } = options; if (tracks.length === 0) return layers; const pathData = createPathLayerData(tracks); - const pathLayer = new PathLayer({ + const pathLayer = new PathLayer({ id: LAYER_IDS.PATH, data: pathData, - getPath: d => d.path, - getColor: d => { + getPath: (d: PathDataItem) => d.path, + getColor: (d: PathDataItem) => { if (highlightedVesselId && highlightedVesselId === d.vesselId) { - return [255, 255, 0, 255]; + return [255, 255, 0, 255] as [number, number, number, number]; } return d.color; }, - getWidth: d => { + getWidth: (d: PathDataItem) => { if (highlightedVesselId && highlightedVesselId === d.vesselId) { return 6; } @@ -224,7 +352,7 @@ export function createPathLayers(tracks, options, onPathHover) { pickable: true, autoHighlight: true, highlightColor: [255, 255, 0, 220], - onHover: info => { + onHover: (info: PickingInfo) => { if (onPathHover) { onPathHover(info.object?.vesselId ?? null); } @@ -242,7 +370,11 @@ export function createPathLayers(tracks, options, onPathHover) { /** * 포인트 레이어 생성 (클러스터링 포함) */ -export function createPointsLayerOnly(tracks, options, onPointHover) { +export function createPointsLayerOnly( + tracks: TrackData[], + options: PointsLayerOptions, + onPointHover?: ((info: PointHoverInfo | null, x: number, y: number) => void) | null, +): ScatterplotLayer | null { const { updateTrigger, zoomLevel } = options; if (tracks.length === 0) return null; @@ -250,16 +382,16 @@ export function createPointsLayerOnly(tracks, options, onPointHover) { const pointsData = createPointsLayerData(tracks, zoomLevel); const pointConfig = zoomLevel ? getPointDensityConfig(zoomLevel) : { minPointRadius: 3, maxPointRadius: 6 }; - return new ScatterplotLayer({ + return new ScatterplotLayer({ id: LAYER_IDS.POINTS, data: pointsData, - getPosition: d => d.position, - getFillColor: d => d.color, + getPosition: (d: PointDataItem) => d.position, + getFillColor: (d: PointDataItem) => d.color, getRadius: 4, radiusMinPixels: pointConfig.minPointRadius, radiusMaxPixels: pointConfig.maxPointRadius, pickable: true, - onHover: info => { + onHover: (info: PickingInfo) => { if (onPointHover) { if (info.object) { const point = info.object; @@ -289,19 +421,24 @@ export function createPointsLayerOnly(tracks, options, onPointHover) { * 동적 레이어 생성 (가상 선박 아이콘, 라벨) * currentTime 변경 시마다 재생성 */ -export function createDynamicTrackLayers(positions, tracks, options, onIconHover) { - const layers = []; +export function createDynamicTrackLayers( + positions: VirtualShipPosition[], + _tracks: TrackData[], + options: DynamicLayerOptions, + onIconHover?: ((data: VirtualShipIconData | null, x: number, y: number) => void) | null, +): Layer[] { + const layers: Layer[] = []; const { showVirtualShip, showLabels = false, updateTrigger } = options; // 1. 가상 선박 글로우 효과 if (showVirtualShip && positions.length > 0) { const glowData = createVirtualShipGlowData(positions); - const glowLayer = new ScatterplotLayer({ + const glowLayer = new ScatterplotLayer({ id: LAYER_IDS.VIRTUAL_SHIP_GLOW, data: glowData, - getPosition: d => d.position, - getFillColor: d => d.color, + getPosition: (d: VirtualShipGlowData) => d.position, + getFillColor: (d: VirtualShipGlowData) => d.color, getRadius: 20, radiusMinPixels: 18, radiusMaxPixels: 28, @@ -313,18 +450,18 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover // 2. IconLayer - 가상 선박 const iconData = createVirtualShipData(positions); - const iconLayer = new IconLayer({ + const iconLayer = new IconLayer({ id: LAYER_IDS.VIRTUAL_SHIP, data: iconData, iconAtlas: atlasImg, iconMapping: ICON_ATLAS_MAPPING, - getIcon: d => d.icon, - getPosition: d => d.position, - getSize: d => d.size, + getIcon: (d: VirtualShipIconData) => d.icon, + getPosition: (d: VirtualShipIconData) => d.position, + getSize: (d: VirtualShipIconData) => d.size, sizeUnits: 'pixels', - getAngle: d => d.heading || 0, + getAngle: (d: VirtualShipIconData) => d.heading || 0, pickable: true, - onHover: info => { + onHover: (info: PickingInfo) => { if (onIconHover) { if (info.object) { onIconHover(info.object, info.x, info.y); @@ -345,11 +482,11 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover const labelData = createVirtualShipLabelData(positions); const themeColors = getCurrentThemeColors(); - const labelLayer = new TextLayer({ + const labelLayer = new TextLayer({ id: LAYER_IDS.VIRTUAL_SHIP_LABEL, data: labelData, - getPosition: d => d.position, - getText: d => d.text, + getPosition: (d: LabelData) => d.position, + getText: (d: LabelData) => d.text, getSize: 12, getColor: themeColors.shipLabel, getAngle: 0, @@ -374,7 +511,7 @@ export function createDynamicTrackLayers(positions, tracks, options, onIconHover // ========== 포인트 정보 포맷팅 ========== /** 포인트 정보 포맷팅 (호버 툴팁용) */ -export function formatPointInfo(info) { +export function formatPointInfo(info: PointHoverInfo): FormattedPointInfo { const date = new Date(info.timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -391,18 +528,15 @@ export function formatPointInfo(info) { }; } -// ========== 라이브 연결선 (항적 끝점 ↔ 라이브 선박) ========== +// ========== 라이브 연결선 (항적 끝점 <-> 라이브 선박) ========== /** * 마지막 항적점과 라이브 선박 사이 연결 데이터 생성 * 항적의 마지막 점과 실시간 선박 위치를 점선으로 연결 - * - * @param {Array} tracks - ProcessedTrack 배열 - * @returns {Array} 연결선 데이터 배열 [{ path, color, vesselId }] */ -export function createLiveConnectionData(tracks) { +export function createLiveConnectionData(tracks: TrackData[]): ConnectionData[] { const { features } = useShipStore.getState(); - const connections = []; + const connections: ConnectionData[] = []; if (tracks.length === 0) return connections; @@ -441,22 +575,20 @@ export function createLiveConnectionData(tracks) { /** * 라이브 선박 연결선 레이어 생성 (점선 스타일) * 항적 마지막 점과 라이브 선박 위치를 점선으로 연결 - * - * @param {Array} tracks - ProcessedTrack 배열 - * @param {number} updateTrigger - 업데이트 트리거 - * @returns {PathLayer|null} 연결선 레이어 */ -export function createLiveConnectionLayer(tracks, updateTrigger) { +export function createLiveConnectionLayer(tracks: TrackData[], updateTrigger: number): PathLayer | null { if (tracks.length === 0) return null; const connectionData = createLiveConnectionData(tracks); if (connectionData.length === 0) return null; - return new PathLayer({ + // getDashArray, dashJustified는 PathStyleExtension이 주입하는 prop이라 PathLayer 기본 타입 정의에 없음 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- PathStyleExtension의 dash prop은 런타임에서만 인식됨 + const props: Record = { id: LAYER_IDS.LIVE_CONNECTION, data: connectionData, - getPath: d => d.path, - getColor: d => d.color, + getPath: (d: ConnectionData) => d.path, + getColor: (d: ConnectionData) => d.color, getWidth: 2, widthMinPixels: 2, widthMaxPixels: 3, @@ -467,22 +599,23 @@ export function createLiveConnectionLayer(tracks, updateTrigger) { updateTriggers: { getPath: updateTrigger, }, - }); + }; + return new PathLayer(props as any); } // ========== 전역 레이어 레지스트리 ========== /** 전역 레이어 레지스트리에 레이어 등록 */ -export function registerTrackQueryLayers(layers) { +export function registerTrackQueryLayers(layers: Layer[]): void { window.__trackQueryLayers__ = layers; } /** 전역 레이어 레지스트리에서 레이어 제거 */ -export function unregisterTrackQueryLayers() { +export function unregisterTrackQueryLayers(): void { window.__trackQueryLayers__ = []; } /** 등록된 TrackQuery 레이어 가져오기 */ -export function getTrackQueryLayers() { +export function getTrackQueryLayers(): Layer[] { return window.__trackQueryLayers__ || []; } diff --git a/src/tracking/utils/tracking.utils.js b/src/tracking/utils/tracking.utils.ts similarity index 66% rename from src/tracking/utils/tracking.utils.js rename to src/tracking/utils/tracking.utils.ts index 511a02ca..0e5a5575 100644 --- a/src/tracking/utils/tracking.utils.js +++ b/src/tracking/utils/tracking.utils.ts @@ -7,17 +7,19 @@ * - 날짜시간 포맷팅 */ +import type { LonLat } from '../types/trackQuery.types'; + /** * 두 지점 사이의 선박 위치를 시간 기반으로 보간 * - * @param {[number, number]} p1 시작 지점 좌표 [경도, 위도] - * @param {[number, number]} p2 종료 지점 좌표 [경도, 위도] - * @param {number} t1 시작 시간 (밀리초) - * @param {number} t2 종료 시간 (밀리초) - * @param {number} currentTime 현재 시간 (밀리초) - * @returns {[number, number]} 보간된 위치 [경도, 위도] + * @param p1 시작 지점 좌표 [경도, 위도] + * @param p2 종료 지점 좌표 [경도, 위도] + * @param t1 시작 시간 (밀리초) + * @param t2 종료 시간 (밀리초) + * @param currentTime 현재 시간 (밀리초) + * @returns 보간된 위치 [경도, 위도] */ -export function interpolatePosition(p1, p2, t1, t2, currentTime) { +export function interpolatePosition(p1: LonLat, p2: LonLat, t1: number, t2: number, currentTime: number): LonLat { if (t1 === t2) return p1; if (currentTime <= t1) return p1; if (currentTime >= t2) return p2; @@ -33,11 +35,11 @@ export function interpolatePosition(p1, p2, t1, t2, currentTime) { * 두 지점 간의 방향(heading) 계산 * 북쪽을 0도로 하여 시계방향으로 각도를 계산 * - * @param {[number, number]} p1 시작 지점 [경도, 위도] - * @param {[number, number]} p2 종료 지점 [경도, 위도] - * @returns {number} 방향 각도 (도 단위) + * @param p1 시작 지점 [경도, 위도] + * @param p2 종료 지점 [경도, 위도] + * @returns 방향 각도 (도 단위) */ -export function calculateHeading(p1, p2) { +export function calculateHeading(p1: LonLat, p2: LonLat): number { const [lon1, lat1] = p1; const [lon2, lat2] = p2; @@ -54,10 +56,10 @@ export function calculateHeading(p1, p2) { /** * 날짜시간 포맷팅 (밀리초 -> "YYYY-MM-DD HH:mm:ss") * - * @param {number} timestamp 타임스탬프 (밀리초) - * @returns {string} 포맷된 날짜시간 문자열 + * @param timestamp 타임스탬프 (밀리초) + * @returns 포맷된 날짜시간 문자열 */ -export function formatDateTime(timestamp) { +export function formatDateTime(timestamp: number): string { const date = new Date(timestamp); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); @@ -71,10 +73,10 @@ export function formatDateTime(timestamp) { /** * 시간 차이를 사람이 읽기 쉬운 형태로 변환 * - * @param {number} milliseconds 밀리초 - * @returns {string} 포맷된 시간 문자열 (예: "2시간 30분") + * @param milliseconds 밀리초 + * @returns 포맷된 시간 문자열 (예: "2시간 30분") */ -export function formatDuration(milliseconds) { +export function formatDuration(milliseconds: number): string { const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); diff --git a/src/types/assets.d.ts b/src/types/assets.d.ts new file mode 100644 index 00000000..a2601b34 --- /dev/null +++ b/src/types/assets.d.ts @@ -0,0 +1,25 @@ +/* 정적 에셋 모듈 타입 선언 */ +declare module '*.svg' { + const src: string; + export default src; +} +declare module '*.png' { + const src: string; + export default src; +} +declare module '*.jpg' { + const src: string; + export default src; +} +declare module '*.gif' { + const src: string; + export default src; +} +declare module '*.scss' { + const classes: Record; + export default classes; +} +declare module '*.css' { + const classes: Record; + export default classes; +} diff --git a/src/types/constants.js b/src/types/constants.ts similarity index 85% rename from src/types/constants.js rename to src/types/constants.ts index 73af0141..82557916 100644 --- a/src/types/constants.js +++ b/src/types/constants.ts @@ -14,31 +14,40 @@ export const SIGNAL_SOURCE_CODE_VTS_AIS = '000004'; export const SIGNAL_SOURCE_CODE_RADAR = '000005'; export const SIGNAL_SOURCE_CODE_D_MF_HF = '000016'; +/** 신호원 코드 유니온 타입 */ +export type SignalSourceCode = + | typeof SIGNAL_SOURCE_CODE_AIS + | typeof SIGNAL_SOURCE_CODE_ENAV + | typeof SIGNAL_SOURCE_CODE_VPASS + | typeof SIGNAL_SOURCE_CODE_VTS_AIS + | typeof SIGNAL_SOURCE_CODE_RADAR + | typeof SIGNAL_SOURCE_CODE_D_MF_HF; + // ===================== // 신호원 우선순위 (동적 대표 선정용, 숫자가 작을수록 높은 우선순위) // 참조: mda-react-front/docs/dynamic-priority.md §1 // ===================== -export const SOURCE_PRIORITY_RANK = { +export const SOURCE_PRIORITY_RANK: Record = { '000005': 0, // VTS-Radar '000004': 1, // VTS-AIS '000001': 2, // AIS '000003': 3, // V-Pass '000002': 4, // E-Nav '000016': 5, // D-MF/HF -}; +} as const; // ===================== // 신호원 코드 → ship 객체의 is_active 프로퍼티 키 매핑 // 참조: mda-react-front/docs/dynamic-priority.md §3 // ===================== -export const SOURCE_TO_ACTIVE_KEY = { +export const SOURCE_TO_ACTIVE_KEY: Record = { '000001': 'ais', '000003': 'vpass', '000002': 'enav', '000004': 'vtsAis', '000016': 'dMfHf', '000005': 'vtsRadar', -}; +} as const; // ===================== // 선박 종류 코드 (Ship Kind Code) @@ -52,6 +61,17 @@ export const SIGNAL_KIND_CODE_GOV = '000025'; // 관공선 export const SIGNAL_KIND_CODE_NORMAL = '000027'; // 일반 export const SIGNAL_KIND_CODE_BUOY = '000028'; // 부이 +/** 선박 종류 코드 유니온 타입 */ +export type SignalKindCode = + | typeof SIGNAL_KIND_CODE_FISHING + | typeof SIGNAL_KIND_CODE_KCGV + | typeof SIGNAL_KIND_CODE_PASSENGER + | typeof SIGNAL_KIND_CODE_CARGO + | typeof SIGNAL_KIND_CODE_TANKER + | typeof SIGNAL_KIND_CODE_GOV + | typeof SIGNAL_KIND_CODE_NORMAL + | typeof SIGNAL_KIND_CODE_BUOY; + // ===================== // STOMP 메시지 배열 인덱스 // 참조: mda-react-front/src/feature/commonFeature.ts - deckSplitCommonTarget() @@ -95,12 +115,12 @@ export const SHIP_MSG_INDEX = { NATIONAL_CODE: 35, // 국적 코드 IS_PRIORITY: 36, // 1=priority (통합선박에서 대표 신호원) ORIGINAL_TARGET_ID: 37, // 개별 장비 고유 TARGET_ID -}; +} as const; // ===================== // 선박 종류별 한글 라벨 // ===================== -export const SHIP_KIND_LABELS = { +export const SHIP_KIND_LABELS: Record = { [SIGNAL_KIND_CODE_FISHING]: '어선', [SIGNAL_KIND_CODE_KCGV]: '경비함정', [SIGNAL_KIND_CODE_PASSENGER]: '여객선', @@ -114,7 +134,7 @@ export const SHIP_KIND_LABELS = { // ===================== // 신호원별 한글 라벨 // ===================== -export const SIGNAL_SOURCE_LABELS = { +export const SIGNAL_SOURCE_LABELS: Record = { [SIGNAL_SOURCE_CODE_AIS]: 'AIS', [SIGNAL_SOURCE_CODE_ENAV]: 'e-Nav', [SIGNAL_SOURCE_CODE_VPASS]: 'V-PASS', @@ -128,7 +148,18 @@ export const SIGNAL_SOURCE_LABELS = { // A=AIS, V=VPASS, E=ENAV, T=VTS_AIS, D=D_MF_HF, R=RADAR // 참조: mda-react-front/src/util/realTimeLayerUtil.ts (라인 733-776) // ===================== -export const SIGNAL_FLAG_CONFIGS = [ + +/** AVETDR 플래그 설정 아이템 인터페이스 */ +export interface SignalFlagConfig { + key: string; + name: string; + activeColor: string; + inactiveColor: string; + signalSourceCode: string; + dataKey: string; +} + +export const SIGNAL_FLAG_CONFIGS: readonly SignalFlagConfig[] = [ { key: 'A', name: 'AIS', @@ -177,10 +208,10 @@ export const SIGNAL_FLAG_CONFIGS = [ signalSourceCode: SIGNAL_SOURCE_CODE_RADAR, dataKey: 'vtsRadar', }, -]; +] as const; // 레거시 호환 -export const AVETDR_COLORS = { +export const AVETDR_COLORS: Record = { A: { active: '#C2A7DC', inactive: '#444' }, V: { active: '#8FAEFC', inactive: '#444' }, E: { active: '#74B2F0', inactive: '#444' }, @@ -192,7 +223,7 @@ export const AVETDR_COLORS = { // ===================== // 선박 종류별 색상 (범례용) // ===================== -export const SHIP_KIND_COLORS = { +export const SHIP_KIND_COLORS: Record = { [SIGNAL_KIND_CODE_FISHING]: '#00C853', // 녹색 - 어선 [SIGNAL_KIND_CODE_KCGV]: '#FF5722', // 주황 - 경비함정 [SIGNAL_KIND_CODE_PASSENGER]: '#2196F3', // 파랑 - 여객선 @@ -212,7 +243,16 @@ export const SPEED_THRESHOLD = 1; // knots (메인 프로젝트 기준) // 아이콘 아틀라스 매핑 (atlas.png 스프라이트 시트) // 참조: mda-react-front/src/types/constants.ts // ===================== -export const ICON_ATLAS_MAPPING = { + +/** 아이콘 아틀라스 매핑 아이템 인터페이스 */ +export interface IconAtlasEntry { + x: number; + y: number; + width: number; + height: number; +} + +export const ICON_ATLAS_MAPPING: Record = { // 이동 중인 선박 아이콘 (화살표 형태) fishingImg: { x: 1, y: 518, width: 16, height: 27 }, kcgvImg: { x: 45, y: 115, width: 17, height: 27 }, @@ -255,7 +295,7 @@ export const ICON_ATLAS_MAPPING = { // ===================== // 선종별 이동 아이콘 매핑 // ===================== -export const ICON_MAPPING_KIND_MOVING = { +export const ICON_MAPPING_KIND_MOVING: Record = { [SIGNAL_KIND_CODE_FISHING]: 'fishingImg', [SIGNAL_KIND_CODE_KCGV]: 'kcgvImg', [SIGNAL_KIND_CODE_PASSENGER]: 'passImg', @@ -269,7 +309,7 @@ export const ICON_MAPPING_KIND_MOVING = { // ===================== // 선종별 정지 아이콘 매핑 // ===================== -export const ICON_MAPPING_KIND_STOPPING = { +export const ICON_MAPPING_KIND_STOPPING: Record = { [SIGNAL_KIND_CODE_FISHING]: 'fishingStopImg', [SIGNAL_KIND_CODE_KCGV]: 'kcgvStopImg', [SIGNAL_KIND_CODE_PASSENGER]: 'passStopImg', @@ -297,12 +337,19 @@ export const STOMP_TOPICS = { SHIP_THROTTLED: '/topic/ship-throttled-', // + {N}s COUNT: '/topic/count', SHIP_DELETE: '/topic/ship-delete', -}; +} as const; // ===================== // 기본 선박 종류 목록 (필터/범례용) // ===================== -export const SHIP_KIND_LIST = [ + +/** 선박 종류 목록 아이템 인터페이스 */ +export interface ShipKindItem { + code: string; + label: string; +} + +export const SHIP_KIND_LIST: readonly ShipKindItem[] = [ { code: SIGNAL_KIND_CODE_FISHING, label: '어선' }, { code: SIGNAL_KIND_CODE_KCGV, label: '경비함정' }, { code: SIGNAL_KIND_CODE_PASSENGER, label: '여객선' }, @@ -311,19 +358,26 @@ export const SHIP_KIND_LIST = [ { code: SIGNAL_KIND_CODE_GOV, label: '관공선' }, { code: SIGNAL_KIND_CODE_NORMAL, label: '일반' }, { code: SIGNAL_KIND_CODE_BUOY, label: '부이' }, -]; +] as const; // ===================== // 신호원 목록 (필터용) // ===================== -export const SIGNAL_SOURCE_LIST = [ + +/** 신호원 목록 아이템 인터페이스 */ +export interface SignalSourceItem { + code: string; + label: string; +} + +export const SIGNAL_SOURCE_LIST: readonly SignalSourceItem[] = [ { code: SIGNAL_SOURCE_CODE_AIS, label: 'AIS' }, { code: SIGNAL_SOURCE_CODE_VPASS, label: 'V-PASS' }, { code: SIGNAL_SOURCE_CODE_ENAV, label: 'e-Nav' }, { code: SIGNAL_SOURCE_CODE_VTS_AIS, label: 'VTS AIS' }, { code: SIGNAL_SOURCE_CODE_D_MF_HF, label: 'D MF/HF' }, { code: SIGNAL_SOURCE_CODE_RADAR, label: 'RADAR' }, -]; +] as const; // ===================== // 항적/리플레이 조회기간 설정 @@ -376,4 +430,4 @@ export const USER_SETTING_CODES = { NORTH_KOREA_AI: '000077', // 북한선박 // 위험물 HAZARD: '000027', -}; +} as const; diff --git a/src/types/global.d.ts b/src/types/global.d.ts new file mode 100644 index 00000000..4d5c27bf --- /dev/null +++ b/src/types/global.d.ts @@ -0,0 +1,19 @@ +/** + * Window 인터페이스 확장 - 레이어 레지스트리 전역 프로퍼티 + * + * 각 레이어 레지스트리 모듈(replayLayerRegistry, areaSearchLayerRegistry, + * stsLayerRegistry, trackQueryLayerUtils)이 window 객체에 레이어 배열을 저장 + */ + +import type { Layer } from '@deck.gl/core'; + +declare global { + interface Window { + __replayLayers__?: Layer[]; + __areaSearchLayers__?: Layer[]; + __stsLayers__?: Layer[]; + __trackQueryLayers__?: Layer[]; + } +} + +export {}; diff --git a/src/types/ol-ext.d.ts b/src/types/ol-ext.d.ts new file mode 100644 index 00000000..b215f96f --- /dev/null +++ b/src/types/ol-ext.d.ts @@ -0,0 +1,2 @@ +/* ol-ext 타입 선언 (공식 타입 미제공) */ +declare module 'ol-ext/*'; diff --git a/src/types/ship.ts b/src/types/ship.ts new file mode 100644 index 00000000..692cc551 --- /dev/null +++ b/src/types/ship.ts @@ -0,0 +1,81 @@ +/** 선박 feature 객체 (shipStore에서 관리하는 단위) */ +export interface ShipFeature { + /** 고유 식별자 (signalSourceCode + originalTargetId) */ + featureId: string; + /** 통합 TARGET_ID (AIS_VPASS_ENAV_VTSAIS_DMFHF 형식) */ + targetId: string; + /** 개별 장비 고유 TARGET_ID */ + originalTargetId: string; + /** 신호원 코드 (000001=AIS, 000002=ENAV, ...) */ + signalSourceCode: string; + /** 선종 코드 (000020=어선, 000021=경비함정, ...) */ + signalKindCode: string; + /** 경도 */ + longitude: number; + /** 위도 */ + latitude: number; + /** 속력 (knots) */ + sog: number; + /** 침로 (degrees) */ + cog: number; + /** 선박명 */ + shipName: string; + /** 국적 코드 */ + nationalCode: string; + /** AIS 통합 플래그 */ + ais: string; + /** VPASS 통합 플래그 */ + vpass: string; + /** ENAV 통합 플래그 */ + enav: string; + /** VTS_AIS 통합 플래그 */ + vtsAis: string; + /** D_MF_HF 통합 플래그 */ + dMfHf: string; + /** VTS_RADAR 통합 플래그 */ + vtsRadar: string; + /** 통합 여부 */ + integrate: boolean; + /** 통합선박에서 대표 신호원 여부 */ + isPriority: boolean; + /** 수신 시간 (YYYYMMDDHHmmss 형식) */ + receivedTime: string; + /** 수신 타임스탬프 (ms) - mergeFeatures에서 계산 */ + receivedTimestamp?: number; + /** 소실 신호 여부 */ + lost?: boolean; + /** 위험물 카테고리 */ + hazardousCategory?: string; + /** IMO 번호 */ + imo?: string; + /** 흘수 */ + draught?: string; + /** 선박 크기 A */ + dimA?: string; + /** 선박 크기 B */ + dimB?: string; + /** 선박 크기 C */ + dimC?: string; + /** 선박 크기 D */ + dimD?: string; + /** 선박 타입 */ + shipType?: string; + /** 콜사인 */ + callsign?: string; + /** 선수 방위 */ + heading?: number; + /** 목적지 */ + destination?: string; + /** AIS 상태 */ + status?: string; + /** 선체 길이 */ + length?: number; + /** 선체 폭 */ + width?: number; + /** 원시 데이터 */ + _raw?: unknown; + /** CSV 다운로드용 targetId */ + downloadTargetId?: string; + /** 동적 키 접근 허용 */ + [key: string]: unknown; +} diff --git a/src/utils/assetPath.js b/src/utils/assetPath.ts similarity index 91% rename from src/utils/assetPath.js rename to src/utils/assetPath.ts index 7b286ff7..91d21587 100644 --- a/src/utils/assetPath.js +++ b/src/utils/assetPath.ts @@ -17,7 +17,7 @@ * @param {string} path - '/'로 시작하는 에셋 경로 (예: '/images/icon.svg') * @returns {string} base URL이 적용된 전체 경로 */ -export function assetPath(path) { +export function assetPath(path: string): string { // import.meta.env.BASE_URL은 항상 '/'로 끝남 (예: '/', '/kcgv/') const base = import.meta.env.BASE_URL; @@ -32,7 +32,7 @@ export function assetPath(path) { * @param {string} filename - 이미지 파일명 (예: 'icon.svg', 'photo.png') * @returns {string} base URL이 적용된 전체 이미지 경로 */ -export function imagePath(filename) { +export function imagePath(filename: string): string { return assetPath(`/images/${filename}`); } diff --git a/src/utils/csvDownload.js b/src/utils/csvDownload.ts similarity index 64% rename from src/utils/csvDownload.js rename to src/utils/csvDownload.ts index 69f6acf8..c198f140 100644 --- a/src/utils/csvDownload.js +++ b/src/utils/csvDownload.ts @@ -2,23 +2,50 @@ * CSV 다운로드 유틸리티 * 참조: mda-react-front/src/widgets/rightMenu/ui/RightMenu.tsx (512-579) */ -import { Polygon } from 'ol/geom'; +import * as turf from '@turf/turf'; import { shipTypeMap } from '../assets/data/shiptype'; import { SHIP_KIND_LABELS, SIGNAL_SOURCE_LABELS } from '../types/constants'; +/** 다운로드용 선박 데이터 */ +interface DownloadShip { + downloadTargetId: string; + shipName: string; + signalKindCode: string; + shipType: string; + signalSourceCode: string; + sog: number; + cog: number; + longitude: string | number; + latitude: string | number; + draught: string; + receivedTime: string; +} + +/** 해구도 캐시 엔트리 */ +interface TrenchEntry { + zoneName: string; + polygon: number[][][]; // GeoJSON Polygon coordinates +} + +/** GeoJSON Feature 형태 (largeTrench.json) */ +interface TrenchFeature { + properties: { zone_name: string }; + geometry: { coordinates: number[][][] }; +} + // 해구도 데이터 캐시 (첫 호출 시만 로딩) -let trenchCache = null; +let trenchCache: TrenchEntry[] | null = null; /** * 해구도 폴리곤 데이터 로딩 (동적 import, 캐시) */ -async function loadTrenchData() { +async function loadTrenchData(): Promise { if (trenchCache) return trenchCache; const data = await import('../assets/data/largeTrench.json'); const geojson = data.default || data; - trenchCache = geojson.features.map((f) => ({ + trenchCache = (geojson as { features: TrenchFeature[] }).features.map((f: TrenchFeature) => ({ zoneName: f.properties.zone_name, - polygon: new Polygon(f.geometry.coordinates), + polygon: f.geometry.coordinates, })); return trenchCache; } @@ -28,13 +55,13 @@ async function loadTrenchData() { * @param {Array} ships - 선박 배열 (longitude, latitude 필드 필요) * @returns {Map} index → zone_name 매핑 */ -async function lookupTrenchNumbers(ships) { +async function lookupTrenchNumbers(ships: DownloadShip[]): Promise> { const trenchData = await loadTrenchData(); - const result = new Map(); + const result = new Map(); ships.forEach((ship, idx) => { - const lon = parseFloat(ship.longitude); - const lat = parseFloat(ship.latitude); + const lon = parseFloat(String(ship.longitude)); + const lat = parseFloat(String(ship.latitude)); if (isNaN(lon) || isNaN(lat)) { result.set(idx, 'X'); return; @@ -42,7 +69,8 @@ async function lookupTrenchNumbers(ships) { let found = false; for (const { zoneName, polygon } of trenchData) { - if (polygon.intersectsCoordinate([lon, lat])) { + // Turf.js booleanPointInPolygon으로 좌표 검사 + if (turf.booleanPointInPolygon([lon, lat], turf.polygon(polygon))) { result.set(idx, zoneName); found = true; break; @@ -60,7 +88,7 @@ async function lookupTrenchNumbers(ships) { * 수신시간 포맷 변환 * "YYYYMMDDHHmmss" → "YYYY-MM-DD HH:mm:ss" */ -function formatRecvDateTime(raw) { +function formatRecvDateTime(raw: string | undefined | null): string { if (!raw || raw.length < 14) return raw || ''; return `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)} ${raw.slice(8, 10)}:${raw.slice(10, 12)}:${raw.slice(12, 14)}`; } @@ -68,7 +96,7 @@ function formatRecvDateTime(raw) { /** * CSV 안전 필드 (쌍따옴표 감싸기) */ -function csvField(val) { +function csvField(val: string | number | null | undefined): string { const str = val == null ? '' : String(val); return `"${str.replace(/"/g, '""')}"`; } @@ -79,7 +107,7 @@ function csvField(val) { * @param {Map} trenchMap - index → zone_name 매핑 * @returns {string} CSV 문자열 (BOM 포함) */ -function buildCsvString(ships, trenchMap) { +function buildCsvString(ships: DownloadShip[], trenchMap: Map): string { const BOM = '\uFEFF'; const headers = [ '타겟ID', '선박명', '선종/기종', '선종/기종-유형', '신호', @@ -90,9 +118,9 @@ function buildCsvString(ships, trenchMap) { const fields = [ csvField(ship.downloadTargetId), csvField(ship.shipName), - csvField(SHIP_KIND_LABELS[ship.signalKindCode] || ship.signalKindCode), + csvField((SHIP_KIND_LABELS as Record)[ship.signalKindCode] || ship.signalKindCode), csvField(shipTypeMap.get(String(ship.shipType)) || ship.shipType), - csvField(SIGNAL_SOURCE_LABELS[ship.signalSourceCode] || ship.signalSourceCode), + csvField((SIGNAL_SOURCE_LABELS as Record)[ship.signalSourceCode] || ship.signalSourceCode), csvField(ship.sog), csvField(ship.cog), csvField(ship.longitude), @@ -111,7 +139,7 @@ function buildCsvString(ships, trenchMap) { * CSV 다운로드 트리거 * @param {Array} ships - getDownloadShips()에서 반환된 선박 배열 */ -export async function downloadShipCsv(ships) { +export async function downloadShipCsv(ships: DownloadShip[]): Promise { const trenchMap = await lookupTrenchNumbers(ships); const csvString = buildCsvString(ships, trenchMap); @@ -119,7 +147,7 @@ export async function downloadShipCsv(ships) { const url = URL.createObjectURL(blob); const now = new Date(); - const pad = (n) => String(n).padStart(2, '0'); + const pad = (n: number): string => String(n).padStart(2, '0'); const fileName = `ship_download_${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}_${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}.csv`; const link = document.createElement('a'); diff --git a/src/utils/liveControl.js b/src/utils/liveControl.ts similarity index 86% rename from src/utils/liveControl.js rename to src/utils/liveControl.ts index 477dcc1a..0ebf37d3 100644 --- a/src/utils/liveControl.js +++ b/src/utils/liveControl.ts @@ -10,7 +10,7 @@ import { shipBatchRenderer } from '../map/ShipBatchRenderer'; /** * 라이브 선박 숨기기 */ -export function hideLiveShips() { +export function hideLiveShips(): void { useTrackQueryStore.getState().setHideLiveShips(true); shipBatchRenderer.immediateRender(); } @@ -18,7 +18,7 @@ export function hideLiveShips() { /** * 라이브 선박 표시 */ -export function showLiveShips() { +export function showLiveShips(): void { useTrackQueryStore.getState().setHideLiveShips(false); shipBatchRenderer.immediateRender(); } @@ -27,7 +27,7 @@ export function showLiveShips() { * 라이브 선박 표시 토글 * @returns {boolean} 토글 후 hideLiveShips 상태 */ -export function toggleLiveShips() { +export function toggleLiveShips(): boolean { const currentState = useTrackQueryStore.getState().hideLiveShips; const newState = !currentState; useTrackQueryStore.getState().setHideLiveShips(newState); @@ -39,6 +39,6 @@ export function toggleLiveShips() { * 라이브 선박 숨김 상태 확인 * @returns {boolean} true면 라이브 선박 숨김 상태 */ -export function isLiveShipsHidden() { +export function isLiveShipsHidden(): boolean { return useTrackQueryStore.getState().hideLiveShips; } diff --git a/src/utils/projection.ts b/src/utils/projection.ts new file mode 100644 index 00000000..20a366bd --- /dev/null +++ b/src/utils/projection.ts @@ -0,0 +1,33 @@ +/** + * 좌표 투영 변환 유틸리티 + * OL fromLonLat/toLonLat 대체 — 순수 수학 구현 + * + * WGS84 (EPSG:4326) ↔ Web Mercator (EPSG:3857) 변환 + * shipLayer 속도벡터·DIM 폴리곤 계산에서 사용 + */ + +const EARTH_RADIUS = 6378137; // WGS84 반장축 (미터) +const DEG_TO_RAD = Math.PI / 180; +const RAD_TO_DEG = 180 / Math.PI; + +/** + * LngLat (EPSG:4326) → Web Mercator (EPSG:3857) + * OL `fromLonLat()` 동등 + */ +export function lngLatToMercator(lngLat: [number, number]): [number, number] { + const [lng, lat] = lngLat; + const x = lng * DEG_TO_RAD * EARTH_RADIUS; + const y = Math.log(Math.tan(Math.PI / 4 + (lat * DEG_TO_RAD) / 2)) * EARTH_RADIUS; + return [x, y]; +} + +/** + * Web Mercator (EPSG:3857) → LngLat (EPSG:4326) + * OL `toLonLat()` 동등 + */ +export function mercatorToLngLat(mercator: [number, number]): [number, number] { + const [x, y] = mercator; + const lng = (x / EARTH_RADIUS) * RAD_TO_DEG; + const lat = (2 * Math.atan(Math.exp(y / EARTH_RADIUS)) - Math.PI / 2) * RAD_TO_DEG; + return [lng, lat]; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..1d326854 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,14 @@ +/// + +interface ImportMetaEnv { + readonly VITE_BASE_URL: string; + readonly VITE_API_URL: string; + readonly VITE_SNP_API_TARGET: string; + readonly VITE_DEV_SKIP_AUTH: string; + readonly VITE_SHIP_THROTTLE: string; + readonly VITE_MAIN_APP_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/src/workers/signalWorker.js b/src/workers/signalWorker.ts similarity index 73% rename from src/workers/signalWorker.js rename to src/workers/signalWorker.ts index 2c710cc3..cb8d924e 100644 --- a/src/workers/signalWorker.js +++ b/src/workers/signalWorker.ts @@ -35,14 +35,49 @@ const IDX = { NATIONAL_CODE: 35, IS_PRIORITY: 36, ORIGINAL_TARGET_ID: 37, -}; +} as const; + +/** Worker에서 생성하는 선박 객체 */ +interface WorkerShipObject { + featureId: string; + receivedTimestamp: number; + targetId: string; + originalTargetId: string; + signalSourceCode: string; + shipName: string; + shipType: string; + longitude: number; + latitude: number; + sog: number; + cog: number; + receivedTime: string; + signalKindCode: string; + lost: boolean; + integrate: boolean; + isPriority: boolean; + hazardousCategory: string; + nationalCode: string; + imo: string; + draught: string; + dimA: string; + dimB: string; + dimC: string; + dimD: string; + ais: string | undefined; + vpass: string | undefined; + enav: string | undefined; + vtsAis: string | undefined; + dMfHf: string | undefined; + vtsRadar: string | undefined; + _raw: string[]; +} /** * 파이프 구분 문자열을 선박 객체로 변환 * @param {string[]} row - 파싱된 배열 - * @returns {Object} 선박 데이터 객체 + * @returns {WorkerShipObject} 선박 데이터 객체 */ -function rowToShipObject(row) { +function rowToShipObject(row: string[]): WorkerShipObject { const targetId = row[IDX.TARGET_ID] || ''; const originalTargetId = row[IDX.ORIGINAL_TARGET_ID] || ''; const signalSourceCode = row[IDX.SIGNAL_SOURCE_CODE] || ''; @@ -120,19 +155,22 @@ function rowToShipObject(row) { // Worker 초기화 로그 console.log('[SignalWorker] Initialized'); -self.onmessage = (e) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Worker global scope 타입 캐스팅 +const workerSelf = self as any; + +workerSelf.onmessage = (e: MessageEvent): void => { const rawMessages = e.data; - const ships = []; + const ships: WorkerShipObject[] = []; for (let i = 0; i < rawMessages.length; i++) { try { const row = rawMessages[i].split('|'); const ship = rowToShipObject(row); ships.push(ship); - } catch (err) { + } catch { // 파싱 에러는 무시하고 계속 진행 } } - self.postMessage(ships); + workerSelf.postMessage(ships); }; diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 00000000..9c40c3c5 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting — strict mode */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 00000000..1e67612a --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.js b/vite.config.js deleted file mode 100644 index ed419745..00000000 --- a/vite.config.js +++ /dev/null @@ -1,148 +0,0 @@ -import { defineConfig, loadEnv } from 'vite'; -import react from '@vitejs/plugin-react'; -import path from 'path'; - -export default ({ mode, command }) => { - const env = loadEnv(mode, process.cwd(), ''); - - // 환경 판별 - // - development: 로컬 개발 환경 (yarn dev) - // - dev: 개발서버 배포 환경 (yarn build:dev) - // - qa: QA 환경 (yarn build:qa) - // - production: 프로덕션 환경 (yarn build, yarn build:prod) - const isLocalDev = mode === 'development'; - const isDev = mode === 'dev'; - const isQA = mode === 'qa'; - const isProd = !isLocalDev && !isDev && !isQA; // production 또는 기타 모드 - const isBuild = command === 'build'; - - // 배포 경로 설정 (예: '/kcgnv/', '/' 등) - // 모든 모드에서 VITE_BASE_URL 사용 (로컬 개발 프록시 모드 지원) - const base = env.VITE_BASE_URL || '/'; - - console.log(`[Vite] Mode: ${mode}, Command: ${command}, Base: ${base}, isLocalDev: ${isLocalDev}`); - - // 빌드 시 제외할 폴더 패턴 (로컬 개발 모드 제외) - // - publish: 퍼블리싱 미리보기 (개발용) - // - component/wrap: 레거시 퍼블리시 컴포넌트 - // 참고: tracking 폴더의 TS 파일은 JS 파일이 우선 사용되므로 자동 제외됨 - const excludePatterns = isBuild && !isLocalDev - ? [ - /[/\\]publish[/\\]/, - /[/\\]component[/\\]wrap[/\\]/, - ] - : []; - - return defineConfig({ - base, - define: { - global: 'globalThis', // sockjs-client/stompjs용 global polyfill - }, - server: { - host: true, - port: 3000, - proxy: { - // 지도 타일 서버 - '/MAPS': { - target: env.VITE_MAP_TILE_URL || 'http://10.26.252.39:9090', - changeOrigin: true, - secure: false, - }, - // GeoJSON 데이터 - '/geo': { - target: env.VITE_MAP_TILE_URL || 'http://10.26.252.39:9090', - changeOrigin: true, - secure: false, - }, - // 선박 신호 API (signal-api) - // 참조: mda-react-front/vite.config.ts - '/signal-api': { - target: env.VITE_SIGNAL_API || 'http://10.26.252.39:9090/signal-api', - changeOrigin: true, - secure: false, - rewrite: (path) => path.replace(/^\/signal-api/, ''), - }, - // 선박 이미지 (국기, 선종 아이콘) - // 참조: mda-react-front/vite.config.ts - /ship/image 프록시 - '/ship/image': { - target: env.VITE_API_URL || 'http://10.26.252.39:9090', - changeOrigin: true, - secure: false, - }, - // 항적 조회 API (별도 서버) - // 참조: mda-react-front/vite.config.ts - /api/v2/tracks 프록시 - '/api/v2/tracks': { - target: env.VITE_TRACK_API || 'http://10.26.252.51:8090', - changeOrigin: true, - secure: false, - }, - // 공통 API (개인설정, 공통코드 등) — 메인 API 서버로 라우팅 - '/api/cmn': { - target: env.VITE_API_URL || 'http://10.26.252.39:9090', - changeOrigin: true, - secure: false, - }, - // 기상/위성 등 GIS API — 메인 API 서버로 라우팅 - '/api/gis': { - target: env.VITE_API_URL || 'http://10.26.252.39:9090', - changeOrigin: true, - secure: false, - }, - // API 서버 (기타) - '/api': { - target: env.VITE_TRACK_API || 'http://localhost:8090', - changeOrigin: true, - secure: false, - }, - }, - }, - plugins: [ - react(), - // 빌드 시 개발용 폴더 제외 플러그인 (로컬 개발 모드 제외) - isBuild && !isLocalDev && { - name: 'exclude-dev-folders', - resolveId(source, importer) { - // 제외 패턴에 매칭되는 import를 빈 모듈로 대체 - const normalizedSource = source.replace(/\\/g, '/'); - for (const pattern of excludePatterns) { - if (pattern.test(normalizedSource)) { - return { id: 'virtual:empty-module', moduleSideEffects: false }; - } - } - return null; - }, - load(id) { - if (id === 'virtual:empty-module') { - return 'export default null;'; - } - return null; - }, - }, - ].filter(Boolean), - resolve: { - alias: { - '@': '/src', - }, - }, - // 빌드 시 console.log, debugger 제거 (로컬 개발 모드 제외) - // 개발서버(dev), QA(qa), 프로덕션(prod) 빌드에서만 적용 - esbuild: isBuild && !isLocalDev - ? { - drop: ['console', 'debugger'], - } - : {}, - build: { - outDir: 'dist', - cssCodeSplit: true, - rollupOptions: { - output: { - manualChunks: { - vendor: ['react', 'react-dom', 'react-router-dom'], - map: ['ol', 'ol-ext'], - state: ['zustand'], - }, - }, - }, - }, - }); -}; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..66bbca2f --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,83 @@ +import { defineConfig, loadEnv, type Plugin } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig(({ mode, command }) => { + const env = loadEnv(mode, process.cwd(), ''); + + const isLocalDev = mode === 'development'; + const isBuild = command === 'build'; + + const base = env.VITE_BASE_URL || '/'; + + console.log(`[Vite] Mode: ${mode}, Command: ${command}, Base: ${base}, isLocalDev: ${isLocalDev}`); + + const excludePatterns: RegExp[] = isBuild && !isLocalDev + ? [ + /[/\\]publish[/\\]/, + /[/\\]component[/\\]wrap[/\\]/, + ] + : []; + + return { + base, + define: { + global: 'globalThis', + }, + server: { + host: true, + port: 3000, + proxy: { + '/snp-api': { + target: env.VITE_SNP_API_TARGET || 'http://211.208.115.83:8041', + changeOrigin: true, + secure: false, + }, + }, + }, + plugins: [ + react(), + isBuild && !isLocalDev && ({ + name: 'exclude-dev-folders', + resolveId(source: string) { + const normalizedSource = source.replace(/\\/g, '/'); + for (const pattern of excludePatterns) { + if (pattern.test(normalizedSource)) { + return { id: 'virtual:empty-module', moduleSideEffects: false }; + } + } + return null; + }, + load(id: string) { + if (id === 'virtual:empty-module') { + return 'export default null;'; + } + return null; + }, + } satisfies Plugin), + ].filter(Boolean), + resolve: { + alias: { + '@': '/src', + }, + }, + esbuild: isBuild && !isLocalDev + ? { + drop: ['console', 'debugger'], + } + : {}, + build: { + target: 'es2022', + outDir: 'dist', + cssCodeSplit: true, + rollupOptions: { + output: { + manualChunks: { + vendor: ['react', 'react-dom', 'react-router-dom'], + map: ['maplibre-gl'], + state: ['zustand'], + }, + }, + }, + }, + }; +}); diff --git a/yarn.lock b/yarn.lock index f3508fef..9b65f428 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16,7 +16,7 @@ resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== -"@babel/core@^7.28.0": +"@babel/core@^7.24.4", "@babel/core@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.0.tgz#5286ad785df7f79d656e88ce86e650d16ca5f322" integrity sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA== @@ -109,7 +109,7 @@ "@babel/template" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.24.4", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": version "7.29.0" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== @@ -229,6 +229,14 @@ "@math.gl/web-mercator" "^4.1.0" earcut "^2.2.4" +"@deck.gl/mapbox@^9.2.7": + version "9.2.7" + resolved "https://registry.yarnpkg.com/@deck.gl/mapbox/-/mapbox-9.2.7.tgz#714df011a67ec6acd075db2e7c223e29f9ac82c0" + integrity sha512-kcTMavoM9RqGbDXg78U/DGlR3dCQMR5+9ctc83qy0aNP57zQ62okomnq9DVCfxvcQjYb1uMqAt3HaBespInRcA== + dependencies: + "@luma.gl/constants" "^9.2.6" + "@math.gl/web-mercator" "^4.1.0" + "@deck.gl/mesh-layers@^9.2.6": version "9.2.6" resolved "https://registry.yarnpkg.com/@deck.gl/mesh-layers/-/mesh-layers-9.2.6.tgz#ad0f2cac13609d7032edd7588a85a3b7105e9369" @@ -239,171 +247,226 @@ "@luma.gl/gltf" "^9.2.6" "@luma.gl/shadertools" "^9.2.6" -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== +"@esbuild/aix-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2" + integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg== -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== +"@esbuild/android-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8" + integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg== -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== +"@esbuild/android-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b" + integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA== -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== +"@esbuild/android-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac" + integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ== -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== +"@esbuild/darwin-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd" + integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg== -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== +"@esbuild/darwin-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a" + integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg== -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== +"@esbuild/freebsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b" + integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w== -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== +"@esbuild/freebsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead" + integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA== -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== +"@esbuild/linux-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6" + integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg== -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== +"@esbuild/linux-arm@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11" + integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw== -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== +"@esbuild/linux-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29" + integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg== -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== +"@esbuild/linux-loong64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed" + integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA== -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== +"@esbuild/linux-mips64el@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1" + integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw== -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== +"@esbuild/linux-ppc64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78" + integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA== -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== +"@esbuild/linux-riscv64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d" + integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ== -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== +"@esbuild/linux-s390x@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d" + integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw== -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== +"@esbuild/linux-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5" + integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA== -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== +"@esbuild/netbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7" + integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA== -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== +"@esbuild/netbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b" + integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA== -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== +"@esbuild/openbsd-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5" + integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw== -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== +"@esbuild/openbsd-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b" + integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ== -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== +"@esbuild/openharmony-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e" + integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g== -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== +"@esbuild/sunos-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537" + integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA== -"@eslint-community/eslint-utils@^4.2.0": +"@esbuild/win32-arm64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e" + integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA== + +"@esbuild/win32-ia32@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c" + integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q== + +"@esbuild/win32-x64@0.27.3": + version "0.27.3" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17" + integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.1.4.tgz#388a269f0f25c1b6adc317b5a2c55714894c70ad" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== + dependencies: + "@eslint/object-schema" "^2.1.7" + debug "^4.3.1" + minimatch "^3.1.2" + +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== + dependencies: + "@types/json-schema" "^7.0.15" + +"@eslint/eslintrc@^3.3.1": + version "3.3.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" + integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" + espree "^10.0.1" + globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.0" + js-yaml "^4.1.1" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.57.1": - version "8.57.1" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.57.1.tgz#de633db3ec2ef6a3c89e2f19038063e8a122e2c2" - integrity sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q== +"@eslint/js@9.39.2", "@eslint/js@^9.39.2": + version "9.39.2" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599" + integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== -"@humanwhocodes/config-array@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.13.0.tgz#fb907624df3256d04b9aa2df50d7aa97ec648748" - integrity sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw== +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== + +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== dependencies: - "@humanwhocodes/object-schema" "^2.0.3" - debug "^4.3.1" - minimatch "^3.0.5" + "@eslint/core" "^0.17.0" + levn "^0.4.1" + +"@humanfs/core@^0.19.1": + version "0.19.1" + resolved "https://registry.yarnpkg.com/@humanfs/core/-/core-0.19.1.tgz#17c55ca7d426733fe3c561906b8173c336b40a77" + integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== + +"@humanfs/node@^0.16.6": + version "0.16.7" + resolved "https://registry.yarnpkg.com/@humanfs/node/-/node-0.16.7.tgz#822cb7b3a12c5a240a24f621b5a2413e27a45f26" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== + dependencies: + "@humanfs/core" "^0.19.1" + "@humanwhocodes/retry" "^0.4.0" "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.3": - version "2.0.3" - resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" - integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== "@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": version "0.3.13" @@ -702,21 +765,44 @@ "@math.gl/types" "^4.1.0" "@probe.gl/env" "^4.0.8" +"@mapbox/geojson-rewind@^0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz#591a5d71a9cd1da1a0bf3420b3bea31b0fc7946a" + integrity sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA== + dependencies: + get-stream "^6.0.1" + minimist "^1.2.6" + +"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" + integrity sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ== + "@mapbox/martini@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@mapbox/martini/-/martini-0.2.0.tgz#1af70211fbe994abf26e37f1388ca69c02cd43b4" integrity sha512-7hFhtkb0KTLEls+TRw/rWayq5EeHtTaErgm/NskVoXmtgAQu/9D299aeyj6mzAR/6XUnYRp2lU+4IcrYRFjVsQ== +"@mapbox/point-geometry@^1.1.0", "@mapbox/point-geometry@~1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz#3328fb54b3a1273bc619bf0a6baad8de37181749" + integrity sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ== + "@mapbox/point-geometry@~0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz#8a83f9335c7860effa2eeeca254332aa0aeed8f2" integrity sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ== -"@mapbox/tiny-sdf@^2.0.5": +"@mapbox/tiny-sdf@^2.0.5", "@mapbox/tiny-sdf@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz#0d67d65a43195003b282764f2297c619736bbc6e" integrity sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug== +"@mapbox/unitbezier@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz#d32deb66c7177e9e9dfc3bbd697083e2e657ff01" + integrity sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw== + "@mapbox/vector-tile@^1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz#d3a74c90402d06e89ec66de49ec817ff53409666" @@ -724,6 +810,58 @@ dependencies: "@mapbox/point-geometry" "~0.1.0" +"@mapbox/vector-tile@^2.0.4": + version "2.0.4" + resolved "https://registry.yarnpkg.com/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz#59c5ca80a84c210e61226367b0f9c8fd1737a437" + integrity sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg== + dependencies: + "@mapbox/point-geometry" "~1.1.0" + "@types/geojson" "^7946.0.16" + pbf "^4.0.1" + +"@mapbox/whoots-js@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" + integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== + +"@maplibre/geojson-vt@^5.0.4": + version "5.0.4" + resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz#c5f301a5d227cecf0bf4d1ab9239b8b0b13e78fe" + integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ== + +"@maplibre/maplibre-gl-style-spec@^24.4.1": + version "24.4.1" + resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz#600a1dbb2912831564cc6ba6e96c22cf34ffdc0c" + integrity sha512-UKhA4qv1h30XT768ccSv5NjNCX+dgfoq2qlLVmKejspPcSQTYD4SrVucgqegmYcKcmwf06wcNAa/kRd0NHWbUg== + dependencies: + "@mapbox/jsonlint-lines-primitives" "~2.0.2" + "@mapbox/unitbezier" "^0.0.1" + json-stringify-pretty-compact "^4.0.0" + minimist "^1.2.8" + quickselect "^3.0.0" + rw "^1.3.3" + tinyqueue "^3.0.0" + +"@maplibre/mlt@^1.1.6": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.6.tgz#49d91b54e4ae6cd03b19d6c3b342a7e8e2c6246d" + integrity sha512-rgtY3x65lrrfXycLf6/T22ZnjTg5WgIOsptOIoCaMZy4O4UAKTyZlYY0h6v8le721pTptF94U65yMDQkug+URw== + dependencies: + "@mapbox/point-geometry" "^1.1.0" + +"@maplibre/vt-pbf@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz#395d97bd5de68b5efabf0d56c535163bb88f75c7" + integrity sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA== + dependencies: + "@mapbox/point-geometry" "^1.1.0" + "@mapbox/vector-tile" "^2.0.4" + "@maplibre/geojson-vt" "^5.0.4" + "@types/geojson" "^7946.0.16" + "@types/supercluster" "^7.1.3" + pbf "^4.0.1" + supercluster "^8.0.1" + "@math.gl/core@4.1.0", "@math.gl/core@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@math.gl/core/-/core-4.1.0.tgz#2f4a1644c6f8fb50aacae57a02f1297f933aefbd" @@ -771,27 +909,6 @@ dependencies: "@math.gl/core" "4.1.0" -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.8": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - "@parcel/watcher-android-arm64@2.5.6": version "2.5.6" resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" @@ -881,11 +998,6 @@ "@parcel/watcher-win32-ia32" "2.5.6" "@parcel/watcher-win32-x64" "2.5.6" -"@petamoriken/float16@^3.4.7": - version "3.9.3" - resolved "https://registry.yarnpkg.com/@petamoriken/float16/-/float16-3.9.3.tgz#84acef4816db7e4c2fe1c4e8cf902bcbc0440ac3" - integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g== - "@probe.gl/env@4.1.0", "@probe.gl/env@^4.0.8", "@probe.gl/env@^4.1.0": version "4.1.0" resolved "https://registry.yarnpkg.com/@probe.gl/env/-/env-4.1.0.tgz#c2af9030a8711f2d98590850aa47a5f58feef211" @@ -908,15 +1020,10 @@ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.2.tgz#156c4b481c0bee22a19f7924728a67120de06971" integrity sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w== -"@repeaterjs/repeater@3.0.6": - version "3.0.6" - resolved "https://registry.yarnpkg.com/@repeaterjs/repeater/-/repeater-3.0.6.tgz#be23df0143ceec3c69f8b6c2517971a5578fdaa2" - integrity sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA== - -"@rolldown/pluginutils@1.0.0-beta.27": - version "1.0.0-beta.27" - resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz#47d2bf4cef6d470b22f5831b420f8964e0bf755f" - integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== +"@rolldown/pluginutils@1.0.0-rc.3": + version "1.0.0-rc.3" + resolved "https://registry.yarnpkg.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz#8a88cc92a0f741befc7bc109cb1a4c6b9408e1c5" + integrity sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q== "@rollup/rollup-android-arm-eabi@4.57.1": version "4.57.1" @@ -1048,6 +1155,100 @@ resolved "https://registry.yarnpkg.com/@stomp/stompjs/-/stompjs-7.3.0.tgz#5655b93e086a0be684291424c5bc8c92949b33ee" integrity sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ== +"@turf/along@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/along/-/along-7.3.4.tgz#16f8da76ce962b8c63eb5cdb8883cede6a5a5f49" + integrity sha512-PvIoXin0I1t3nRwJz7uqR6fsxDMqdGwJq90qGOeqkNwlZqlF+5o2wKHPwYwi0RXZhLvxRP5qlbNIvV8ADdbWxw== + dependencies: + "@turf/bearing" "7.3.4" + "@turf/destination" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/angle@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/angle/-/angle-7.3.4.tgz#136f9861ea63f2b7c2170a79b57d3c753f774f13" + integrity sha512-235JAfbrNMjHQXQfd/p+fYnlfCHsQsKHda5Eeyc+/jIY0s5mKvhcxgFaOEnigA2q1n+PrVOExs3BViGTKnWhAg== + dependencies: + "@turf/bearing" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/area@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/area/-/area-7.3.4.tgz#7506ff1c64e2d9fb25d5e43707075a92770dcca3" + integrity sha512-UEQQFw2XwHpozSBAMEtZI3jDsAad4NnHL/poF7/S6zeDCjEBCkt3MYd6DSGH/cvgcOozxH/ky3/rIVSMZdx4vA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/bbox-clip@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/bbox-clip/-/bbox-clip-7.3.4.tgz#65e2b5e51aab4c2e150a5741b2260aba762e45c9" + integrity sha512-HCn0q/WPVEE9Dztg7tCvClOPrrh9MoxNUk73byHvcZLBcvziN6F84f/ZbFcbQSh8hgOeVMs/keeqWMqsICcNLg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/bbox-polygon@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/bbox-polygon/-/bbox-polygon-7.3.4.tgz#f2bf36583d0cca9688ba8f2cb67bbca6bad82bd9" + integrity sha512-XCDYQwCA41Bum3R1xX0Na1nR4ozoe/pCYy5bxqrzyMs87kPJUIfBrD5IWxjnZyLqFpfEpolMHJz5ed1uA2PanQ== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/bbox@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.3.4.tgz#75673871c2d7e086bef085cce7b1daa4bed66aae" + integrity sha512-D5ErVWtfQbEPh11yzI69uxqrcJmbPU/9Y59f1uTapgwAwQHQztDWgsYpnL3ns8r1GmPWLP8sGJLVTIk2TZSiYA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/bearing@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-7.3.4.tgz#a6bf6d227daa11eaf484df00740361ab779b59d4" + integrity sha512-zvFjapyFaOrM8nBtAND7f4yb0BJV0jyj6cyoXyTYqLY+3Hn0eHgL0M8lwxDLbTom5KfqYDHDVDQC3+VSfypoEA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/bezier-spline@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/bezier-spline/-/bezier-spline-7.3.4.tgz#90ce6dd5734103199291c758e88bf17f7eb01d4a" + integrity sha512-+iDUeiBKByIs/6K5WW8pG6IDxrRLJHFLM80zSpzk2xBtgy3mq36NZwwt67Pu7EJAkc9GUXKIm9SkspoKue9aYQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-clockwise@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-clockwise/-/boolean-clockwise-7.3.4.tgz#1a77a96f396baf89d05efd8fe9af3ff01dbc8e79" + integrity sha512-X/O+u/OsoJ99mujhlqviuB7HX0tdJ5931TBjNSseps43XtROVuB5PwBDgwKfu5lY1B4DSGAxbbxJ795RmPnguQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/boolean-clockwise@^5.1.5": version "5.1.5" resolved "https://registry.yarnpkg.com/@turf/boolean-clockwise/-/boolean-clockwise-5.1.5.tgz#3302b7dac62c5e291a0789e29af7283387fa9deb" @@ -1056,6 +1257,272 @@ "@turf/helpers" "^5.1.5" "@turf/invariant" "^5.1.5" +"@turf/boolean-concave@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-concave/-/boolean-concave-7.3.4.tgz#6d7e3d82b6d52a141ada69d8ce1bac9aae5914c2" + integrity sha512-SHuAzjqaAes6ELDZcN/FKZWCQZsqwYv3gMosoLRFWTwKyBQe8i29e4y6XnXakDr1uklVUeRRcdhZ5oKtX9ABPQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-contains@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-7.3.4.tgz#d5aad7efbb5280e96f708572f9ffd14c026a1107" + integrity sha512-AJMGbtC6HiXgHvq0RNlTfsDB58Qf9Js45MP/APbhGTH4AiLZ8VMDISywVFNd7qN6oppNlDd3xApVR28+ti8bNg== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/boolean-point-on-line" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-split" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-crosses@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-crosses/-/boolean-crosses-7.3.4.tgz#d858d4a9c4bac4e77633eba3cad6c862a86b9c9e" + integrity sha512-v/U3SuGdkexfLTMhho6Vj0OjqPUeYdThxp8zggGJ1VHow27fvLLez0DjUR3AftHjjHM6bRzZoNsu2qUlEe5hjw== + dependencies: + "@turf/boolean-equal" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@turf/polygon-to-line" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-disjoint@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-disjoint/-/boolean-disjoint-7.3.4.tgz#1887b564ca7899ce9a00dd87d1e7c5e583f459db" + integrity sha512-Dl4O27ygi2NqskGQuvSlDLJYlJ2SPkHb3A9T/v6eAudjlMiKdEY6bMxKUfU5y+Px1WiCZxd+9rXGXJgGC3WiQg== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/polygon-to-line" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-equal@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-equal/-/boolean-equal-7.3.4.tgz#353530600e4b0861a4e1734a32cb7915d37a49f9" + integrity sha512-AhWqe7D1o0wp3d3QQRSqgWDI8s1JfTFKFe9rU5mrSxYPGlmaQsJC07RCaYfFiGym9lACd1lxBJiPidCbLaPOfw== + dependencies: + "@turf/clean-coords" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + geojson-equality-ts "^1.0.2" + tslib "^2.8.1" + +"@turf/boolean-intersects@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-intersects/-/boolean-intersects-7.3.4.tgz#a8477fa69ea7ca1357690e36586c0ad5ff8ab043" + integrity sha512-sxi41NXkb5hrJgOvpm32hyBLhW8fem0vn2XxR4+jyRg1rM/v3ziF10/VqC9KDZuDNZkt9JjL9B0825Cf7AN6Lg== + dependencies: + "@turf/boolean-disjoint" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-overlap@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-overlap/-/boolean-overlap-7.3.4.tgz#27d0d62cb3c2755686b84d64f23da7fb5bb70f76" + integrity sha512-Q3dlswIuqffSiMfln7xa36YDnN1TWtERMF/155rzjglm4NTUG/6S+gNsb8s6qpLjc+hN6btCq1ZjxAWurPf8Vg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@turf/line-overlap" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + geojson-equality-ts "^1.0.2" + tslib "^2.8.1" + +"@turf/boolean-parallel@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-parallel/-/boolean-parallel-7.3.4.tgz#3b22b3aec4770aebed10055c4b1f5141b5130955" + integrity sha512-sTNMqsUkLPnSJEqc2IZ5ig3nHRoubyOH2HW1LILqOybCJI630FEM9UoYP1pZniF5nwTyCjQWnXA1FxusVILuFQ== + dependencies: + "@turf/clean-coords" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/line-segment" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-point-in-polygon@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.4.tgz#654a940939fecddf1887ca4c95bd5a2f07a42de8" + integrity sha512-v/4hfyY90Vz9cDgs2GwjQf+Lft8o7mNCLJOTz/iv8SHAIgMMX0czEoIaNVOJr7tBqPqwin1CGwsncrkf5C9n8Q== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + point-in-polygon-hao "^1.1.0" + tslib "^2.8.1" + +"@turf/boolean-point-on-line@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-7.3.4.tgz#6a8174fb866ed6cad3b7b223bab9470ddfbb9de4" + integrity sha512-70gm5x6YQOZKcw0b/O4jjMwVWnFj+Zb6TXozLgZFDZShc8pgTQtZku7K+HKZ7Eya+7usHIB4IimZauomOMa+iw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-touches@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-touches/-/boolean-touches-7.3.4.tgz#d66685333206aa58ba80146c7dd697ee4327855e" + integrity sha512-XOwhjc0oCWhnBUB+l4drpXcg7mkNXPX3SuSz/Xv7gvLH/yRrBwzVGllzK1AHlGU9BVkGVBJIZGYX7jgTM681NQ== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/boolean-point-on-line" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/boolean-valid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-valid/-/boolean-valid-7.3.4.tgz#a2430cb637da6d4fda537b57b992e97a2a14d75e" + integrity sha512-P6M9BtRvzFF2N5g+1/DTIbYGpEbwQ2sv/Pw+uj11P3NYAA9VE8mvrxFYf+CowFdSfY6bY4ejhuqKhrTmAMv7wA== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/boolean-crosses" "7.3.4" + "@turf/boolean-disjoint" "7.3.4" + "@turf/boolean-overlap" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/boolean-point-on-line" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@types/geojson" "^7946.0.10" + geojson-polygon-self-intersections "^1.2.1" + tslib "^2.8.1" + +"@turf/boolean-within@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/boolean-within/-/boolean-within-7.3.4.tgz#c26e308990d3158628cb27e60a015de82571f06c" + integrity sha512-eLgi803gz0KcYkyxnnqnz9Vd6tw2/0eAExe/Rq8sO0dqypaSiomSumxjqu89d/yo24Qz8gW7c0kJ6YihNbMYxA== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/boolean-point-on-line" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-split" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/buffer@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/buffer/-/buffer-7.3.4.tgz#d458326ab00bf86b1e2a3109f8b660d487b3ea0e" + integrity sha512-MVOCBDuOl3KGDsh2stW12RmiFaFeSkVjeUbZ+ADUtIVnv+jlFsmjBpFtsEw8s9YQn5g0667QppOshm0FBHA57Q== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/center" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/jsts" "^2.7.1" + "@turf/meta" "7.3.4" + "@turf/projection" "7.3.4" + "@types/geojson" "^7946.0.10" + d3-geo "1.7.1" + +"@turf/center-mean@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/center-mean/-/center-mean-7.3.4.tgz#a949905c2e1fa977685dd57979fde83b52524c30" + integrity sha512-6foVk5HLjlSPr48EI686Eis6/bYrJiHjKQlwY/7YlJc1uDitsIjPw2LjUCGIUZDEd6PdNUgg1+LgI7klXYvW3A== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/center-median@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/center-median/-/center-median-7.3.4.tgz#a42b2e2d3e1b6d128cbca24df88392c7fbc394e5" + integrity sha512-Bz6rDr0plQOGSXgT3X3t941pYd44a5vIY8OEt4Y11H1BsgpmzFc6g7L5mr7FXW/uiYGxOewAfNcVUYUdJf9kMg== + dependencies: + "@turf/center-mean" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/center-of-mass@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/center-of-mass/-/center-of-mass-7.3.4.tgz#ef4bc3009aad75a10e194a08908acc44e8c04a5a" + integrity sha512-mOSupDF5qxQTA/kOWYletHcBJQ3S2gVl/IRgrBH/YY9yiFq6UGRpZ0sNcIML4H06u/1DY/jqqG+d1nc/1yIA6Q== + dependencies: + "@turf/centroid" "7.3.4" + "@turf/convex" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/center@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/center/-/center-7.3.4.tgz#e4895ea3348aecbe690fe151f8b216a8be2d8ffc" + integrity sha512-4SsLMDHWthXbyIHsczgFCo4fx+8tC8w2+B5HdEuY+P+cSOOL4T+6QQzd7WWjuN/Y3ndowFssUmwRrvXuwVRxQA== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/centroid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/centroid/-/centroid-7.3.4.tgz#8c2722075721a04940fa0492287b54fadbca4bb6" + integrity sha512-6c3kyTSKBrmiPMe75UkHw6MgedroZ6eR5usEvdlDhXgA3MudFPXIZkMFmMd1h9XeJ9xFfkmq+HPCdF0cOzvztA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/circle@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/circle/-/circle-7.3.4.tgz#4cf89cdd17c12855e54cba4158e9c803d9ab1691" + integrity sha512-6ccr5iT51/XONF+pbpkqoRxKX4ZVWLubXb1frGCnClv2suo1UIY9SIlINNctVDupXd2P9PpqZCbrXATrcrokPg== + dependencies: + "@turf/destination" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/clean-coords@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/clean-coords/-/clean-coords-7.3.4.tgz#0df35d2e2ef50a829ba5b6edb3a4ac4690792aa6" + integrity sha512-S61aJXLvPN/uZHtjzmJbLv7xhi28Sq3PshCIZSvno4Mo45bvl79Vg4aZskrG05AaSSbipplqfH+MZrkW9Xboeg== + dependencies: + "@turf/boolean-point-on-line" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/clone@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-7.3.4.tgz#ae2d9ccd77730181aaa76874308140515e55ddaa" + integrity sha512-pwQ+RyQw986uu7IulY/18NRAebwZZScb084bvVqVkTrllwLSv4oVBqUxmUMiwtp+PNdiRGRFOvNyZqtRsiD+Jw== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/clone@^5.1.5": version "5.1.5" resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-5.1.5.tgz#253e8d35477181976e33adfab50a0f02a7f0e367" @@ -1063,11 +1530,290 @@ dependencies: "@turf/helpers" "^5.1.5" +"@turf/clusters-dbscan@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/clusters-dbscan/-/clusters-dbscan-7.3.4.tgz#c013bfeb6b3cdd816ff126da88789f9b9877df76" + integrity sha512-RkuXf767Shk0AfY+fh0PASVw8YR4H8zYR7XQrCgWd/bCuh6CXs7rWZ6UTLu/PiA6y6WsIhyAQv4LhNH5kCzpbA== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + "@types/geokdbush" "^1.1.5" + geokdbush "^2.0.1" + kdbush "^4.0.2" + tslib "^2.8.1" + +"@turf/clusters-kmeans@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/clusters-kmeans/-/clusters-kmeans-7.3.4.tgz#9854f03a48dec68049742b045d2eb6f95f5328ac" + integrity sha512-89mlwhcb+vyZAKX0eBa3LQ8VyIKLayrzJpKGb90sEkIu0hDua9JCE+zlbaPoUAvAqflEiX+poFFuh7pngtsBMg== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + skmeans "0.9.7" + tslib "^2.8.1" + +"@turf/clusters@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/clusters/-/clusters-7.3.4.tgz#ece710095aaf6e19aa213350351354d3ac08bb4c" + integrity sha512-+zoSyiF0LilXy4Tr0/lC7IgqbTMZZ2wwP3iSrqre58b61pUtdhCnBcjA2r8FkcW7z3GMbGf5XkIWhO+b+vDSsw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/collect@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/collect/-/collect-7.3.4.tgz#aeafdc4421a18f05fbeaee0b6f010cc189d49c62" + integrity sha512-fG28oDZK4HCXC/AhF0pmHKLtI9DWwdJr/ktuWolrqzA5b1G7eawrXwDu8B5I3sXhdWonNRMcuLbIuz+XQscHKw== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + rbush "^3.0.1" + tslib "^2.8.1" + +"@turf/combine@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/combine/-/combine-7.3.4.tgz#2da684b1fb828bec4fdcb8dd4b403398ebffa0de" + integrity sha512-wNp9ar4FfpTfQXLZWXQ/jUBBoUFOwRN/mmlv5xrhoYFpP/F5SNy7GVDMZXaBfHdUUplfJUPF5hIKQlCUR8+k3A== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/concave@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/concave/-/concave-7.3.4.tgz#cd7792d226193c932645676911730df09872cce0" + integrity sha512-HZa1CV2pv4Xpcoe3t5S3ZW6j9jVbc27exzKwZWF7MlFxSz4BKRirWiME8Fku8nvQcGafpfLc+Lwpma+nGvg06w== + dependencies: + "@turf/clone" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/tin" "7.3.4" + "@types/geojson" "^7946.0.10" + topojson-client "3.x" + topojson-server "3.x" + tslib "^2.8.1" + +"@turf/convex@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/convex/-/convex-7.3.4.tgz#e3bd5dd9d9eeef3de646bb0ce4b5de2a2da1fb31" + integrity sha512-zeNv0fFdOoHuOQB7nl6OLb0DyjvzDvm0e3zlFkph50GF9pEKOmkCSmlniw681aWL2aRBdWZBnON3rRzOS+9C7Q== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + concaveman "^1.2.1" + tslib "^2.8.1" + +"@turf/destination@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-7.3.4.tgz#bfd0262be1a70c24a1efed02a391e3a968e1b41a" + integrity sha512-YxoUJwkKmTHiRFQxMQOP0tz8Vy+ga5EXl+C+F/WubjDLwT1AJu5y8CNIjLvWyjPWckj/vZG4u/1js5bx6MLADA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/difference@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/difference/-/difference-7.3.4.tgz#ef514f8e0141d30f27068695aaf8edd96748b19b" + integrity sha512-kIxizNQrYLO2rtqUIeed0tPycicrXoipy/g9d4mjv91kzBEbwpyojz9zi8U9G1ISBfCEgA7wsViQD0r+8qzxXw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + polyclip-ts "^0.16.8" + tslib "^2.8.1" + +"@turf/dissolve@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/dissolve/-/dissolve-7.3.4.tgz#40b90e89fcf0946f50463e81e7df678702e5d716" + integrity sha512-xjGY1gQ4icWhDgsW0YfU2KQtij1+ru34AfvtkVMQEgI86O9EwjW2r9Jq5DJY2PMKPbor3kz9yM/RTOiDP7f3Jg== + dependencies: + "@turf/flatten" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + polyclip-ts "^0.16.8" + tslib "^2.8.1" + +"@turf/distance-weight@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/distance-weight/-/distance-weight-7.3.4.tgz#1856259f9ec0ecb9e4f66b621f49ee7cd719e282" + integrity sha512-dVMNEmIluKgn7iQTmzJJOe0UASRNmmSdFX1boAev5MISaW3AvPiURCCOV+lTIeoaQbWRpEAESbAp6JIimXFr8Q== + dependencies: + "@turf/centroid" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/distance@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.3.4.tgz#6552ccf9addf7f7d4f74ffaf1686142f8d787f9a" + integrity sha512-9drWgd46uHPPyzgrcRQLgSvdS/SjVlQ6ZIBoRQagS5P2kSjUbcOXHIMeOSPwfxwlKhEtobLyr+IiR2ns1TfF8w== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/ellipse@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/ellipse/-/ellipse-7.3.4.tgz#d8f535355e073c7bb21821d7aca0956d95cd4472" + integrity sha512-SMgbERZl12j7H8YaIofmnf0NwAvdF5Wly4tjI/eUhj/sFOKrKXOS1lvCSBJ6uSV9tFijl3ecGOVOlTpURdZ30g== + dependencies: + "@turf/destination" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/transform-rotate" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/envelope@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/envelope/-/envelope-7.3.4.tgz#0daa18730d6be00de6fc95cb886dcde2a628cede" + integrity sha512-anXSjYMXGAyXT7rpO74VyRI0q/rPAbKE/MYvou+QvG0U/Oa7el0yF4JNNi9wKEAxXg/10aWm9kHp8s2caeLg6A== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/bbox-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/explode@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/explode/-/explode-7.3.4.tgz#c7b6189123c9494026f58a8ea2933df692a9b2be" + integrity sha512-7QWhp3f8jhrWjvArhJ74hXBFHMaiJr/2Y1PzHCWue2/pC5MbbTV0o7peehwrrrJC/1uD6CVb3hlcb77IxtMQkw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/flatten@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/flatten/-/flatten-7.3.4.tgz#ee74260cd7247a998f6f1576eca56fca7effceae" + integrity sha512-Yt3HCh/qeNaXS4LYhXczFhBfTeaKlTBoxEw1OICb9RT3SiGU0XCxuK7H0W26OLo7XxB0qP7GPs2L3FZbiri6wQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/flip@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/flip/-/flip-7.3.4.tgz#08508e40e5ed17368e79f89a74fb134ca1329c80" + integrity sha512-HME+kVMTyvcsYVY6dC6DTvuzq8vvDpw+C7PviEqpuT3KcVlBCoGPAqlWRdyWYOb9MDciOqNxvvJF/okpb/GQcg== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/geojson-rbush@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/geojson-rbush/-/geojson-rbush-7.3.4.tgz#f03d34c5d1a1ee22760ba353d712c34b06c36fe8" + integrity sha512-aDG/5mMCgKduqBwZ3XpLOdlE2hizV3fM+5dHCWyrBepCQLeM/QRvvpBDCdQKDWKpoIBmrGGYDNiOofnf3QmGhg== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + rbush "^3.0.1" + tslib "^2.8.1" + +"@turf/great-circle@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/great-circle/-/great-circle-7.3.4.tgz#9a827e979e0ec34208eeef4c40f46affc610c29a" + integrity sha512-JvfzWFL9efP+xKtOnKzGvwEIXfaN0CLZoPPxNnWa/cVisLs9FVMlC9PWnuL3/3aqH5VhBHPddmU8ipzNE6KIIA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + arc "^0.2.0" + tslib "^2.8.1" + +"@turf/helpers@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.4.tgz#a8c918981599dddcf452421c7b307c5832d05f02" + integrity sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g== + dependencies: + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/helpers@^5.1.5": version "5.1.5" resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-5.1.5.tgz#153405227ab933d004a5bb9641a9ed999fcbe0cf" integrity sha512-/lF+JR+qNDHZ8bF9d+Cp58nxtZWJ3sqFe6n3u3Vpj+/0cqkjk4nXKYBSY0azm+GIYB5mWKxUXvuP/m0ZnKj1bw== +"@turf/hex-grid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/hex-grid/-/hex-grid-7.3.4.tgz#850e4b135550f5890790f58c9c10dbfa5774987c" + integrity sha512-TDCgBykFdsrP3IOOfToiiLpYkbUb3eEEhM9riIqWht0ubKUY61LN7qVs9bxZD83hG6XaDB6uY7SWkxK1zIEopQ== + dependencies: + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/intersect" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/interpolate@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/interpolate/-/interpolate-7.3.4.tgz#ab5b0f59ddea1077ab0b7c3283bd05223e152bab" + integrity sha512-lwYSMbHxsXYEWObv0tyBCjwTLXyfsTvOLn/NFhlsGrNCYEXn8I1VPtLGwuxbSdF3hVRgurn8qftkB1npHrNs6Q== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/hex-grid" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/point-grid" "7.3.4" + "@turf/square-grid" "7.3.4" + "@turf/triangle-grid" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/intersect@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/intersect/-/intersect-7.3.4.tgz#b01b7e5418e848b3260f6bdfe33a16278ae81f3e" + integrity sha512-VsqMEMeRWWs2mjwI7sTlUgH1cEfugTGhQ0nF8ncHG7YKd9HUUTzIKpn9FJeoguPWIYITcy1ar4yJEOU/hteBVw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + polyclip-ts "^0.16.8" + tslib "^2.8.1" + +"@turf/invariant@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.4.tgz#d81f448aa4fdda36047337a688517581e91c12f0" + integrity sha512-88Eo4va4rce9sNZs6XiMJowWkikM3cS2TBhaCKlU+GFHdNf8PFEpiU42VDU8q5tOF6/fu21Rvlke5odgOGW4AQ== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/invariant@^5.1.5": version "5.2.0" resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-5.2.0.tgz#f0150ff7290b38577b73d088b7932c1ee0aa90a7" @@ -1075,6 +1821,203 @@ dependencies: "@turf/helpers" "^5.1.5" +"@turf/isobands@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/isobands/-/isobands-7.3.4.tgz#af29f5fbf55351f8319b489b65ef0f28f3430fdd" + integrity sha512-SFYefwjQdQfF0MV0zfaSwNg9J1wD7mfPP8scGcScKGM3admbwS2A3V8rqPADBfYLD2eCPBDFnySxcl9SHbPung== + dependencies: + "@turf/area" "7.3.4" + "@turf/bbox" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/explode" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/isolines@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/isolines/-/isolines-7.3.4.tgz#b82191d3e230c7230534beca6c40804f39915c27" + integrity sha512-UFRIULkIgkZOmrhLxExWvguixbzfoCgVcXIqo2Cp68do4v+nwc3pTM7MTt4DBVFloIdX0Usrn4K44LQ/V05gxg== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/jsts@^2.7.1": + version "2.7.2" + resolved "https://registry.yarnpkg.com/@turf/jsts/-/jsts-2.7.2.tgz#b327aa54ef065cbf1768ad69bd6ae1087385d32b" + integrity sha512-zAezGlwWHPyU0zxwcX2wQY3RkRpwuoBmhhNE9HY9kWhFDkCxZ3aWK5URKwa/SWKJbj9aztO+8vtdiBA28KVJFg== + dependencies: + jsts "2.7.1" + +"@turf/kinks@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-7.3.4.tgz#f1aa088945978519308a6aafdb8805a0b5eabf5d" + integrity sha512-LZTKELWxvXl0vc9ZxVgi0v07fO9+2FrZOam2B10fz/eGjy3oKNazU5gjggbnc499wEIcJS4hN+VyjQZrmsJAdQ== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/length@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/length/-/length-7.3.4.tgz#c35aca9695d783c0fdd34b46b5e13119fb2aee13" + integrity sha512-Dg1GnQ/B2go5NIWXt91N4L7XTjIgIWCftBSYIXkrpIM7QGjItzglek0Z5caytvb8ZRWXzZOGs8//+Q5we91WuQ== + dependencies: + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-arc@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-arc/-/line-arc-7.3.4.tgz#07bd23c779e7e887cff1ca4577d4babfeca56368" + integrity sha512-nqZ+JKjDVIrvREFHgtJIP9Ps4WbWw3eStqdIzAPolrzoXyAZnpIKquyfRTxpJFYUUjDmf+uQ/SFWsPP4SOWAqQ== + dependencies: + "@turf/circle" "7.3.4" + "@turf/destination" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-chunk@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-chunk/-/line-chunk-7.3.4.tgz#23c803e3d81e548016f1dc7c2451aece9e854b7c" + integrity sha512-xWEHR99EpUO5ZPEZhMfa0QvnFZC0W+QLxB1GcJcSeJAQ5ZMXUXY8doKF1Nztk0eppawMprEEO3nQWLvQoR4z2g== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/length" "7.3.4" + "@turf/line-slice-along" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-intersect@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-7.3.4.tgz#79dc5025aa350a866cbfee932fea9fad04ff48e4" + integrity sha512-XygbTvHa6A+v6l2ZKYtS8AAWxwmrPxKxfBbdH75uED1JvdytSLWYTKGlcU3soxd9sYb4x/g9sDvRIVyU6Lucrg== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + sweepline-intersections "^1.5.0" + tslib "^2.8.1" + +"@turf/line-offset@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-offset/-/line-offset-7.3.4.tgz#6d886135dddafa2f8c8e1157d0864fd58e73f662" + integrity sha512-CSrg3njde9Tx+C0oL+BHUpZYpgD+PEmzp0ldDNis5ZQiTe5tUrwiIyG7A/QXf9eDnGhtV1WhCAycX0Wjged4pg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-overlap@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-overlap/-/line-overlap-7.3.4.tgz#e0d49511ae18c1fc31ba172c014bea085598ecde" + integrity sha512-3GBECiwNAQ2MmSwiqAHMweIl+EiePK0Jx4fXxF1KFE+NGCDv/MbGcEYfAbmsTg8mg6oRI9D8fJZzrT44DHpHXA== + dependencies: + "@turf/boolean-point-on-line" "7.3.4" + "@turf/geojson-rbush" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-segment" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/nearest-point-on-line" "7.3.4" + "@types/geojson" "^7946.0.10" + fast-deep-equal "^3.1.3" + tslib "^2.8.1" + +"@turf/line-segment@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-7.3.4.tgz#2bd7967553b76e1fcdcbedc5b7d1b7b2325e2dd7" + integrity sha512-UeISzf/JHoWEY5yeoyvKwA5epWcvJMCpCwbIMolvfTC5pp+IVozjHPVCRvRWuzmbmAvetcW0unL5bjqi0ADmuQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-slice-along@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-slice-along/-/line-slice-along-7.3.4.tgz#e454bbaea4dde2436b8d39d6899b8ab6e9ce65fc" + integrity sha512-RT5HydNy8+m9Y3u39USeYZauG2EyMqCYoLnTpWcAxbZGdq9WjIwdzAwYir3d8eJkOzjlR6Khz071VM4Ufqs0Kg== + dependencies: + "@turf/bearing" "7.3.4" + "@turf/destination" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-slice@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-slice/-/line-slice-7.3.4.tgz#59fc2912f8c75cc1e10596dcb7afd63ca64f4366" + integrity sha512-6Vt4Eptdr2C5T+jtpbo8D4v8b6X7KqYonPPyMB6huv+Kcg3nz4JRI9OQCDCaon9rWvU3ffWwjsjcbJCQS9o0sA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/nearest-point-on-line" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-split@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-7.3.4.tgz#9f33fcc070605629f152ca20c55595a8c406bddf" + integrity sha512-l1zmCSUnGsiN4gf22Aw91a2VnYs5DZS67FdkYqKgr+wPEAL/gpQgIBBWSTmhwY8zb3NEqty+f/gMEe8EJAWYng== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/geojson-rbush" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@turf/line-segment" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/nearest-point-on-line" "7.3.4" + "@turf/truncate" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-to-polygon@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/line-to-polygon/-/line-to-polygon-7.3.4.tgz#6a92a97f0d914425f56fada225cfbbc4cdecef10" + integrity sha512-vRnDHjzwOroC74/fsJEU+dUeGhiR/B2bG0/HeEWRBplAjmwVPptRBmDGtXKTz8sbA6or17/XtOITp3zTU0lBZw== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/mask@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/mask/-/mask-7.3.4.tgz#0ed4772f42ee16f1f2f00e4d1f609cce40c2a251" + integrity sha512-FJIlSk8m0AiqzNoLSMdYuhDRif6aeOYVdW/WxjEjpUoMalwy2w5MMlZqJB9zxt/xSrMq6lvTWJgZfZfGL2s4ZQ== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + polyclip-ts "^0.16.8" + tslib "^2.8.1" + +"@turf/meta@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.3.4.tgz#8e917d29de9da96a0f95f3f16119ba9abde7dee6" + integrity sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/meta@^5.1.5": version "5.2.0" resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-5.2.0.tgz#3b1ad485ee0c3b0b1775132a32c384d53e4ba53d" @@ -1082,6 +2025,266 @@ dependencies: "@turf/helpers" "^5.1.5" +"@turf/midpoint@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/midpoint/-/midpoint-7.3.4.tgz#e4034c6c85cd8f505153d472576319a17e28e3e7" + integrity sha512-/XAeGvsz8l5HaqcP7TUlexzGfibqXozQgBZ8rH7az6op2Dfm3pL/Z7bKLHoVavM0ccBg0Pt7g6j9NM54kZWdKA== + dependencies: + "@turf/bearing" "7.3.4" + "@turf/destination" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/moran-index@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/moran-index/-/moran-index-7.3.4.tgz#22331153e27d71322a3f6d9c80c1e9a91d01471d" + integrity sha512-SNb16szwEG0OiyNn3z9zvSnk3M3tfwvvN8i//9UIC32APEApI+MRXCl93H/qZkKMhhh/cHA0pF0pjYZwl5z8Ow== + dependencies: + "@turf/distance-weight" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/nearest-neighbor-analysis@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/nearest-neighbor-analysis/-/nearest-neighbor-analysis-7.3.4.tgz#c72b10ea865e0510374db40a8dc77c75d63e831b" + integrity sha512-8EZlDy5poU0t7BDy8KTzOmfiGsAs2kWuB3/kgI4sMdbThKVk2P4hHKuToCSGvqAzwSy3B2qKYM1N6JeVWytu+w== + dependencies: + "@turf/area" "7.3.4" + "@turf/bbox" "7.3.4" + "@turf/bbox-polygon" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/nearest-point" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/nearest-point-on-line@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.4.tgz#0e6ef812a0deacb0aee15fb924b1246027cd0f61" + integrity sha512-DQrP3lRju83rIXFN68tUEpc7ki/eRwdwBkK2CTT4RAcyCxbcH2NGJPQv8dYiww/Ar77u1WLVn+aINXZH904dWw== + dependencies: + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/nearest-point-to-line@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-to-line/-/nearest-point-to-line-7.3.4.tgz#c3eac23734433b18fd3944a07534207c9e1ad9ea" + integrity sha512-Nzp3ojQt0gDACNYG+oNWymRXAUCey0LzdiSezYtRwdA0/+FQCtuxP8Lbc8FftV10JL8D78/CRlmt7omaXLLXCg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/point-to-line-distance" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/nearest-point@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/nearest-point/-/nearest-point-7.3.4.tgz#ca80e255df4879d913c6fc2ad13a9f4ca1df4894" + integrity sha512-WfI09f2bX0nKx/jkO7zCt3tUrJulyAlUYQtZHP7lWYMCOmZ6Pq26D6lKWjpfs2it0OHbhlx1XF/UupEUaz830w== + dependencies: + "@turf/clone" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/planepoint@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/planepoint/-/planepoint-7.3.4.tgz#ead27d6c3968624aa729e67301a7cebe87a0e070" + integrity sha512-KAhMAnddbuWIEZuk2bK//g+xTeKn8aV9N2AaE27x6JMJyV/wqvatIuVVqEIXI3SkAFbhiVBpVuarvPYhrJ+fhg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/point-grid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/point-grid/-/point-grid-7.3.4.tgz#e9701976d86e881f4d417515149ff90b5ea1db1b" + integrity sha512-9CL3OJ4dEt266+fxYlOQeRFqAY3XtsAuak2Gpk+K8k+Y3yGv8pvyn3QaAQ6P2npbiKt0zfG8Md/+HBAPOMPQ0A== + dependencies: + "@turf/boolean-within" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/point-on-feature@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/point-on-feature/-/point-on-feature-7.3.4.tgz#a03dc04741f636725c1c2595590fe5d2bbd76124" + integrity sha512-tQfIxsJUxZqyO7OeJC25y3DqN9i4fmrAt4TBrPvZcIIwymgN7aMrElJKlg/dfi7JDihKp3h/CkWMjtMQA14Vwg== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/center" "7.3.4" + "@turf/explode" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/nearest-point" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/point-to-line-distance@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/point-to-line-distance/-/point-to-line-distance-7.3.4.tgz#0044680c4cda4f612cb9555c2819864fc6760bf0" + integrity sha512-IdPAxlAQZj7FCZg+ObyVHlNdqwLL/oxYoQjpxMNJ511gNxokCtEv0aeRZQjYOYIxr9Ss97v3yo3ILJaF9V2kPw== + dependencies: + "@turf/bearing" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/nearest-point-on-line" "7.3.4" + "@turf/projection" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@turf/rhumb-distance" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/point-to-polygon-distance@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/point-to-polygon-distance/-/point-to-polygon-distance-7.3.4.tgz#101d4ea4041263b0a665bcd66a70f85322452f2e" + integrity sha512-VxbkgHyzCkYWSxirqSUqw+lzbYmTf2qFhVZ/T5dprhwyXWcgalpupvgRzmZmjKkgsoJ017vrvCNKZRaCCn+Z7Q== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/point-to-line-distance" "7.3.4" + "@turf/polygon-to-line" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/points-within-polygon@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/points-within-polygon/-/points-within-polygon-7.3.4.tgz#ba90e024738c6a5a83fb1be5ca1b90b19e3e39e0" + integrity sha512-HfT83Iw99zywDfCp+nJwS+JDzH+GdNug0sut9WDjGEznHKoZyAcOk+hGKL/ja8TeCLx9VsZHOiVCQFm+NTgvgA== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/polygon-smooth@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/polygon-smooth/-/polygon-smooth-7.3.4.tgz#10b0ebbd4823194550779b075e0fad40aece8cc1" + integrity sha512-AnpaGgNYVvP/dfz10id3AotDrUh9O+4unXCk3es1ff51VrpUhVgH3H+zyTSbVL4zAXN/ejPb8UnKCxDvNOQs4g== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/polygon-tangents@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/polygon-tangents/-/polygon-tangents-7.3.4.tgz#ea515149ed0a133a54ede7d1b7554b5f98913ef7" + integrity sha512-D1IFocXJYF8PUMZ+BmnOstyRrzklqC86FgakYVk9O61F9Ki8LhMGaRfF+6reKMD473KvHvEf1M2EgmGt+OHDRw== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/boolean-within" "7.3.4" + "@turf/explode" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/nearest-point" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/polygon-to-line@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/polygon-to-line/-/polygon-to-line-7.3.4.tgz#76fda6577278aed73126bbc17384bf0f52839bcc" + integrity sha512-xhmOZ5rHZAKLUDLeYKWMsX84ip8CCGOcGLBHtPPYOjdIDHddMV6Sxt5kVgkmlZpK6NEWEmOD6lYR4obxHcHlGA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/polygonize@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/polygonize/-/polygonize-7.3.4.tgz#22f72bf68ed6c866a3c8af8a2b1d155f5b8edef2" + integrity sha512-kmj05rkJ4tE8LvbQ4GVsL5GOrRiX/F5W4RIdxo8gPGTw1Y5oLG/1vFk6Hg6x63L1WcdNtF0sq6AdEI0G9BXWXA== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/envelope" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/projection@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/projection/-/projection-7.3.4.tgz#05483cf34e711bf139fc22e236bed2068f1b5461" + integrity sha512-p91zOaLmzoBHzU/2H6Ot1tOhTmAom85n1P7I4Oo0V9xU8hmJXWfNnomLFf/6rnkKDIFZkncLQIBz4iIecZ61sA== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/quadrat-analysis@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/quadrat-analysis/-/quadrat-analysis-7.3.4.tgz#35e480feaa403cee3c17afb6ba13520722ef0f97" + integrity sha512-Yxqq8wgrDiXIX+s0uOZ2exmYfRwTIcUX8J7j4P+sbyLVbyN8W3AjN2s5ZX21P0aFf3v24FBd2fNWlm5VmMUAdg== + dependencies: + "@turf/area" "7.3.4" + "@turf/bbox" "7.3.4" + "@turf/bbox-polygon" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/point-grid" "7.3.4" + "@turf/random" "7.3.4" + "@turf/square-grid" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/random@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/random/-/random-7.3.4.tgz#8d2a075e7c81ae8b0f14a3cfe24a0cb930e49020" + integrity sha512-CXMS5XDoI5x0zc1aCYbn3t603k8hjaFHNsSOvGBW20z68cwP0UwMQQr0KLqFPqI4J1O7dMX+urn8IHH27RXFYg== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/rectangle-grid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/rectangle-grid/-/rectangle-grid-7.3.4.tgz#d49ef69d3b6fb4c51a44489a272998a001eb6574" + integrity sha512-qM7vujJ4wndB4MKZlEcnUSawgvs5wXpSEFf4f+LWRIfmGhtv6serzDqFzWcmy8kF8hg5J465PMktRmAFWq/a+w== + dependencies: + "@turf/boolean-intersects" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/rewind@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/rewind/-/rewind-7.3.4.tgz#544d7aaafd2189f523ab2c61f12de294fb61620f" + integrity sha512-4BZ8MHMujl4NAT7XnIs7JoOuDhpR96oDTB0RtqTeIP4onioIedVnw1ZA3Uq08sILGpR0qKLuDsvdz4x9jtbptg== + dependencies: + "@turf/boolean-clockwise" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + "@turf/rewind@^5.1.5": version "5.1.5" resolved "https://registry.yarnpkg.com/@turf/rewind/-/rewind-5.1.5.tgz#9ea3db4a68b73c1fd1dd11f57631b143cfefa1c9" @@ -1093,6 +2296,379 @@ "@turf/invariant" "^5.1.5" "@turf/meta" "^5.1.5" +"@turf/rhumb-bearing@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/rhumb-bearing/-/rhumb-bearing-7.3.4.tgz#777a7e7b865e378c10c3127fd4ee3cefa94103e6" + integrity sha512-tvX1toSo80q0iL0cUMMXpSKsCCfOjRqDGCmOdR6B9shhk6xP1ZM2PLQDr+MFPBFeGyQuyY4CNFkV2+3DF49vYw== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/rhumb-destination@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/rhumb-destination/-/rhumb-destination-7.3.4.tgz#83ff56ba0a03930ca604deb3f2f13f38587071ae" + integrity sha512-6HikEb5nm2A18FQWk6vVLMQkc099I/7c69j47RYM27xQK8J8uBCNk1zLYyMPcZTh24xcNSbZ1iPHDsDOqw6wWQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/rhumb-distance@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/rhumb-distance/-/rhumb-distance-7.3.4.tgz#88a6213a384ebf149d07532c33952eeb80ddbc55" + integrity sha512-phwskeijdgYMsR3qDQmytfsg2iZcp3uWK7UFc76wKTEpxozbDGFI4enX5gXvZPpyI1iD7gsktGqHsO33AjnFDA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/sample@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/sample/-/sample-7.3.4.tgz#b579fc601ffa5c35c2af406524bbc81aa3dcecaa" + integrity sha512-XzAATg09c2XYAXkIBbg8lktSrU1tXNjJYXtbVwF6jLp1q2wTRpwb+mZpTEPAwzZwVF81uR5c0CsdQyr5UHINVw== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/sector@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/sector/-/sector-7.3.4.tgz#8acb168f15d6611594921680fde73f138e167f68" + integrity sha512-x2tNAXl21HRcF302ghU5ohE/vmmfDcXpQKgoWHyi7o5Q9kDRBwy7kbvr5YxbT3vwW/kAWUDYM7FoXNH42bXgCw== + dependencies: + "@turf/circle" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/line-arc" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/shortest-path@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/shortest-path/-/shortest-path-7.3.4.tgz#6412d17b04b6687b18ddcd94728db1ff1ac7d107" + integrity sha512-xbK/oM+JRL+lJCHkAdZ3QPgoivT40J9WKJ0d1Ddt8LXTpzX2YeJVgcwOZaBPG9ncZUzHfHIWS1rUjc54clnZcg== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/bbox-polygon" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/clean-coords" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/transform-scale" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/simplify@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/simplify/-/simplify-7.3.4.tgz#ff854ad444c7374c7ae2b14044bbdd9843ce5241" + integrity sha512-OoSwu3vI0H9P+GzLDaOJIL9v0V8ubeP8wQjM8GeMEZrq6U2uh9JWQnAU+jviT3ODcKF5H+88snpiMik585L0wA== + dependencies: + "@turf/clean-coords" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/square-grid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/square-grid/-/square-grid-7.3.4.tgz#83e67822fc9c340eaebd073c1a42bd20a37fda27" + integrity sha512-MgjlVRklQYFfQm9yJNha9kXothLPliVdeycNdmn4lWLH3SOZe1rqJPB5Z9+dhmJELT3BJraDq3W5ik5taEpKyQ== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/rectangle-grid" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/square@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/square/-/square-7.3.4.tgz#d103eda381914301bbaf5c13214b095fb271977d" + integrity sha512-vJ+NeiEaOVsb8YiUExtyIgvH+ZybthHszl2TASZn5q340ioKHPb2JeHGlbgrB2x8pEMh3MVhoqxAbXDuND/cnw== + dependencies: + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/standard-deviational-ellipse@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-7.3.4.tgz#5aa17d052fe2eba64298d078d6b29c78ea492ab6" + integrity sha512-+BaetOKN8zA2mQCVTcRWMcfidNR3JkjmYj0r5iGRncK0J+pdxIjX2q6sF6yBMOOxMoEMy393P7j07HdBIPbibw== + dependencies: + "@turf/center-mean" "7.3.4" + "@turf/ellipse" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/points-within-polygon" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/tag@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/tag/-/tag-7.3.4.tgz#8ab39b6c01849d943a3d661893ed431e4c287362" + integrity sha512-ienLhLzBLeChtKhbJMmU3/vGg0hWzi6Wh/q0n39W4CmdNb+yAoGQhlYjcCbPOJT4IcdFlWE3OhbP9EmH/xPgfg== + dependencies: + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/tesselate@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/tesselate/-/tesselate-7.3.4.tgz#e1e80b38720b7ea6d94f1998cc0e91865f732b1a" + integrity sha512-NnDgVb5ZchJEhEpq1je2hktS5UhnHMfeeumxZQgnIoMeGILpJtcOL//b/1biBBUVSJ0ZZg5zxiHdQc1PgK2gxA== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + earcut "^2.2.4" + tslib "^2.8.1" + +"@turf/tin@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/tin/-/tin-7.3.4.tgz#511681dd5b74f500867932ade329a10e6977a531" + integrity sha512-tuegrGlbKPp6Dm8r5SuYDtQ2EVzdXVVxelqI1agnzj9N+l8oTBIKLRxRbBkLsizeVIDnlmVHCQB6cRc3v+u8JQ== + dependencies: + "@turf/helpers" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/transform-rotate@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/transform-rotate/-/transform-rotate-7.3.4.tgz#f1cfb9fa4c90b3488c925ef57611754835c2229b" + integrity sha512-pbUG6QLwyJvvitq4aAq4IQH79X8T0NmEPUGDUEEP69yW7t4+UZjDBAVbCKwpOc8gtsK0K5yvxlZ0e2CdtpNmEw== + dependencies: + "@turf/centroid" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@turf/rhumb-destination" "7.3.4" + "@turf/rhumb-distance" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/transform-scale@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/transform-scale/-/transform-scale-7.3.4.tgz#33039230cefca661376e3f2d7346dcbf535c7fc3" + integrity sha512-7gUIFFHaU3Ewj3rCzIu5Yo7Zjfv4R2ypjh6UWiMJnDavb7RQ8fn0AKKcNMA/vF/yxuncp2l3zoa2gygv4AKM8A== + dependencies: + "@turf/bbox" "7.3.4" + "@turf/center" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@turf/rhumb-destination" "7.3.4" + "@turf/rhumb-distance" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/transform-translate@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/transform-translate/-/transform-translate-7.3.4.tgz#96d1d91ab80d7a11c5afbf0d54aa55ca0dcfb103" + integrity sha512-qbSIEueOR8mNB7p4EB88vHvUAyuSBM8zxP68UiiTNV3Gh+OZF2VXTFiu3EFYMTaD9sE6Lxmzvv3fjW8N2q82pw== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/rhumb-destination" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/triangle-grid@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/triangle-grid/-/triangle-grid-7.3.4.tgz#417f64dd8867e9e5d2d784722a0aad49c5ddb22c" + integrity sha512-0bki10XwYvNcPzDcSs5kUh3niOogdVeFtawJEz5FdlyTAUohbNlC+Vb40K//OqEyTrGII+q1/dE4q+1J6ZCmDA== + dependencies: + "@turf/distance" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/intersect" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/truncate@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-7.3.4.tgz#1ba37c8ce1a538310b4c99a39da2bef68f16c145" + integrity sha512-VPXdae9+RLLM19FMrJgt7QANBikm7DxPbfp/dXgzE4Ca7v+mJ4T1fYc7gCZDaqOrWMccHKbvv4iSuW7YZWdIIA== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/turf@^7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/turf/-/turf-7.3.4.tgz#930c771391a439750640c41596e56185f846bd0c" + integrity sha512-uMAKLYt2tWJj8xIepq4vExF1r8fzJviP/5l/elDHuRyauI2mASy/Gox6kSFlrN0t0p8AT4Cs8o//4GuJTXyC+Q== + dependencies: + "@turf/along" "7.3.4" + "@turf/angle" "7.3.4" + "@turf/area" "7.3.4" + "@turf/bbox" "7.3.4" + "@turf/bbox-clip" "7.3.4" + "@turf/bbox-polygon" "7.3.4" + "@turf/bearing" "7.3.4" + "@turf/bezier-spline" "7.3.4" + "@turf/boolean-clockwise" "7.3.4" + "@turf/boolean-concave" "7.3.4" + "@turf/boolean-contains" "7.3.4" + "@turf/boolean-crosses" "7.3.4" + "@turf/boolean-disjoint" "7.3.4" + "@turf/boolean-equal" "7.3.4" + "@turf/boolean-intersects" "7.3.4" + "@turf/boolean-overlap" "7.3.4" + "@turf/boolean-parallel" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/boolean-point-on-line" "7.3.4" + "@turf/boolean-touches" "7.3.4" + "@turf/boolean-valid" "7.3.4" + "@turf/boolean-within" "7.3.4" + "@turf/buffer" "7.3.4" + "@turf/center" "7.3.4" + "@turf/center-mean" "7.3.4" + "@turf/center-median" "7.3.4" + "@turf/center-of-mass" "7.3.4" + "@turf/centroid" "7.3.4" + "@turf/circle" "7.3.4" + "@turf/clean-coords" "7.3.4" + "@turf/clone" "7.3.4" + "@turf/clusters" "7.3.4" + "@turf/clusters-dbscan" "7.3.4" + "@turf/clusters-kmeans" "7.3.4" + "@turf/collect" "7.3.4" + "@turf/combine" "7.3.4" + "@turf/concave" "7.3.4" + "@turf/convex" "7.3.4" + "@turf/destination" "7.3.4" + "@turf/difference" "7.3.4" + "@turf/dissolve" "7.3.4" + "@turf/distance" "7.3.4" + "@turf/distance-weight" "7.3.4" + "@turf/ellipse" "7.3.4" + "@turf/envelope" "7.3.4" + "@turf/explode" "7.3.4" + "@turf/flatten" "7.3.4" + "@turf/flip" "7.3.4" + "@turf/geojson-rbush" "7.3.4" + "@turf/great-circle" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/hex-grid" "7.3.4" + "@turf/interpolate" "7.3.4" + "@turf/intersect" "7.3.4" + "@turf/invariant" "7.3.4" + "@turf/isobands" "7.3.4" + "@turf/isolines" "7.3.4" + "@turf/kinks" "7.3.4" + "@turf/length" "7.3.4" + "@turf/line-arc" "7.3.4" + "@turf/line-chunk" "7.3.4" + "@turf/line-intersect" "7.3.4" + "@turf/line-offset" "7.3.4" + "@turf/line-overlap" "7.3.4" + "@turf/line-segment" "7.3.4" + "@turf/line-slice" "7.3.4" + "@turf/line-slice-along" "7.3.4" + "@turf/line-split" "7.3.4" + "@turf/line-to-polygon" "7.3.4" + "@turf/mask" "7.3.4" + "@turf/meta" "7.3.4" + "@turf/midpoint" "7.3.4" + "@turf/moran-index" "7.3.4" + "@turf/nearest-neighbor-analysis" "7.3.4" + "@turf/nearest-point" "7.3.4" + "@turf/nearest-point-on-line" "7.3.4" + "@turf/nearest-point-to-line" "7.3.4" + "@turf/planepoint" "7.3.4" + "@turf/point-grid" "7.3.4" + "@turf/point-on-feature" "7.3.4" + "@turf/point-to-line-distance" "7.3.4" + "@turf/point-to-polygon-distance" "7.3.4" + "@turf/points-within-polygon" "7.3.4" + "@turf/polygon-smooth" "7.3.4" + "@turf/polygon-tangents" "7.3.4" + "@turf/polygon-to-line" "7.3.4" + "@turf/polygonize" "7.3.4" + "@turf/projection" "7.3.4" + "@turf/quadrat-analysis" "7.3.4" + "@turf/random" "7.3.4" + "@turf/rectangle-grid" "7.3.4" + "@turf/rewind" "7.3.4" + "@turf/rhumb-bearing" "7.3.4" + "@turf/rhumb-destination" "7.3.4" + "@turf/rhumb-distance" "7.3.4" + "@turf/sample" "7.3.4" + "@turf/sector" "7.3.4" + "@turf/shortest-path" "7.3.4" + "@turf/simplify" "7.3.4" + "@turf/square" "7.3.4" + "@turf/square-grid" "7.3.4" + "@turf/standard-deviational-ellipse" "7.3.4" + "@turf/tag" "7.3.4" + "@turf/tesselate" "7.3.4" + "@turf/tin" "7.3.4" + "@turf/transform-rotate" "7.3.4" + "@turf/transform-scale" "7.3.4" + "@turf/transform-translate" "7.3.4" + "@turf/triangle-grid" "7.3.4" + "@turf/truncate" "7.3.4" + "@turf/union" "7.3.4" + "@turf/unkink-polygon" "7.3.4" + "@turf/voronoi" "7.3.4" + "@types/geojson" "^7946.0.10" + "@types/kdbush" "^3.0.5" + tslib "^2.8.1" + +"@turf/union@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/union/-/union-7.3.4.tgz#c7f48399e6f3104cdb89b6d7b77a2b2f3a545f23" + integrity sha512-JJYyPMmGcrTa9sPv2ief2QU9Hb//cEAU1zgKu/OfoCMa9a8Imp5QVm9UTAkhGlc+4qm/N/X16iJ+cvVWaxPjkg== + dependencies: + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + polyclip-ts "^0.16.8" + tslib "^2.8.1" + +"@turf/unkink-polygon@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/unkink-polygon/-/unkink-polygon-7.3.4.tgz#5b0a6c2711377e7713572d98fce2608b9f77c8e7" + integrity sha512-dFIqTLAnLL5D3OANPJtRb5OvmOM81GlNCjwgjlLQy0xdpYgKwGdE+gNXjygDrPUUXNc22xnaj3EfAfC3Pq7W4Q== + dependencies: + "@turf/area" "7.3.4" + "@turf/boolean-point-in-polygon" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/meta" "7.3.4" + "@types/geojson" "^7946.0.10" + rbush "^3.0.1" + tslib "^2.8.1" + +"@turf/voronoi@7.3.4": + version "7.3.4" + resolved "https://registry.yarnpkg.com/@turf/voronoi/-/voronoi-7.3.4.tgz#5b457f8fbfb7ffbae01f8a74f29c8c9be01ff1db" + integrity sha512-cwKSiDzDHRnA7yafQ1zOhWxRuMzp+fYFFzadCdByBAG1jAD7UlFwKhS1fjNPBNs67Fl5X3LL5ahCLW5gEdFgmg== + dependencies: + "@turf/clone" "7.3.4" + "@turf/helpers" "7.3.4" + "@turf/invariant" "7.3.4" + "@types/d3-voronoi" "^1.1.12" + "@types/geojson" "^7946.0.10" + d3-voronoi "1.1.2" + tslib "^2.8.1" + "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -1138,16 +2714,43 @@ resolved "https://registry.yarnpkg.com/@types/crypto-js/-/crypto-js-4.2.2.tgz#771c4a768d94eb5922cc202a3009558204df0cea" integrity sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ== -"@types/estree@1.0.8": +"@types/d3-voronoi@^1.1.12": + version "1.1.12" + resolved "https://registry.yarnpkg.com/@types/d3-voronoi/-/d3-voronoi-1.1.12.tgz#99d1bbf5438ac222727493bef2283da62ffc0aa3" + integrity sha512-DauBl25PKZZ0WVJr42a6CNvI6efsdzofl9sajqZr2Gf5Gu733WkDdUGiPkUHXiUvYGzNNlFQde2wdZdfQPG+yw== + +"@types/estree@1.0.8", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== -"@types/geojson@^7946.0.7", "@types/geojson@^7946.0.8": +"@types/geojson@*", "@types/geojson@^7946.0.10", "@types/geojson@^7946.0.14", "@types/geojson@^7946.0.16", "@types/geojson@^7946.0.7", "@types/geojson@^7946.0.8": version "7946.0.16" resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== +"@types/geokdbush@^1.1.5": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@types/geokdbush/-/geokdbush-1.1.5.tgz#d71e881e1f595002541843cbd39bbebf2fc993c5" + integrity sha512-jIsYnXY+RQ/YCyBqeEHxYN9mh+7PqKJUJUp84wLfZ7T2kqyVPNaXwZuvf1A2uQUkrvVqEbsG94ff8jH32AlLvA== + dependencies: + "@types/kdbush" "^1" + +"@types/json-schema@^7.0.15": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/kdbush@^1": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@types/kdbush/-/kdbush-1.0.7.tgz#5401ece18756d915bc5cc989a35c9e628d0acf04" + integrity sha512-QM5iB8m/0mnGOjUKshErIZQ0LseyTieRSYc3yaOpmrRM0xbWiOuJUWlduJx+TPNK7/VFMWphUGwx3nus7eT1Wg== + +"@types/kdbush@^3.0.5": + version "3.0.5" + resolved "https://registry.yarnpkg.com/@types/kdbush/-/kdbush-3.0.5.tgz#7c308f18d364ac5623a15b85d7da0a635cf5516e" + integrity sha512-tdJz7jaWFu4nR+8b2B+CdPZ6811ighYylWsu2hpsivapzW058yP0KdfZuNY89IiRe5jbKvBGXN3LQdN2KPXVdQ== + "@types/node@*": version "25.2.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.2.0.tgz#015b7d228470c1dcbfc17fe9c63039d216b4d782" @@ -1155,6 +2758,13 @@ dependencies: undici-types "~7.16.0" +"@types/node@^22.10.5": + version "22.19.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.11.tgz#7e1feaad24e4e36c52fa5558d5864bb4b272603e" + integrity sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w== + dependencies: + undici-types "~6.21.0" + "@types/offscreencanvas@^2019.6.4": version "2019.7.3" resolved "https://registry.yarnpkg.com/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz#90267db13f64d6e9ccb5ae3eac92786a7c77a516" @@ -1165,35 +2775,132 @@ resolved "https://registry.yarnpkg.com/@types/pako/-/pako-1.0.7.tgz#aa0e4af9855d81153a29ff84cc44cce25298eda9" integrity sha512-YBtzT2ztNF6R/9+UXj2wTGFnC9NklAnASt3sC0h2m1bbH7G6FyBIkt4AN8ThZpNfxUo1b2iMVO0UawiJymEt8A== -"@types/rbush@4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-4.0.0.tgz#b327bf54952e9c924ea6702c36904c2ce1d47f35" - integrity sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ== +"@types/react-dom@^19.2.3": + version "19.2.3" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" + integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== -"@ungap/structured-clone@^1.2.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" - integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== - -"@vitejs/plugin-react@^4.0.1": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz#647af4e7bb75ad3add578e762ad984b90f4a24b9" - integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== +"@types/react@^19.2.14": + version "19.2.14" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.14.tgz#39604929b5e3957e3a6fa0001dafb17c7af70bad" + integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== dependencies: - "@babel/core" "^7.28.0" + csstype "^3.2.2" + +"@types/supercluster@^7.1.3": + version "7.1.3" + resolved "https://registry.yarnpkg.com/@types/supercluster/-/supercluster-7.1.3.tgz#1a1bc2401b09174d9c9e44124931ec7874a72b27" + integrity sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA== + dependencies: + "@types/geojson" "*" + +"@typescript-eslint/eslint-plugin@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.55.0.tgz#086d2ef661507b561f7b17f62d3179d692a0765f" + integrity sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.55.0" + "@typescript-eslint/type-utils" "8.55.0" + "@typescript-eslint/utils" "8.55.0" + "@typescript-eslint/visitor-keys" "8.55.0" + ignore "^7.0.5" + natural-compare "^1.4.0" + ts-api-utils "^2.4.0" + +"@typescript-eslint/parser@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.55.0.tgz#6eace4e9e95f178d3447ed1f17f3d6a5dfdb345c" + integrity sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw== + dependencies: + "@typescript-eslint/scope-manager" "8.55.0" + "@typescript-eslint/types" "8.55.0" + "@typescript-eslint/typescript-estree" "8.55.0" + "@typescript-eslint/visitor-keys" "8.55.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.55.0.tgz#b8a71c06a625bdad481c24d5614b68e252f3ae9b" + integrity sha512-zRcVVPFUYWa3kNnjaZGXSu3xkKV1zXy8M4nO/pElzQhFweb7PPtluDLQtKArEOGmjXoRjnUZ29NjOiF0eCDkcQ== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.55.0" + "@typescript-eslint/types" "^8.55.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.55.0.tgz#8a0752c31c788651840dc98f840b0c2ebe143b8c" + integrity sha512-fVu5Omrd3jeqeQLiB9f1YsuK/iHFOwb04bCtY4BSCLgjNbOD33ZdV6KyEqplHr+IlpgT0QTZ/iJ+wT7hvTx49Q== + dependencies: + "@typescript-eslint/types" "8.55.0" + "@typescript-eslint/visitor-keys" "8.55.0" + +"@typescript-eslint/tsconfig-utils@8.55.0", "@typescript-eslint/tsconfig-utils@^8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.55.0.tgz#62f1d005419985e09d37a040b2f1450e4e805afa" + integrity sha512-1R9cXqY7RQd7WuqSN47PK9EDpgFUK3VqdmbYrvWJZYDd0cavROGn+74ktWBlmJ13NXUQKlZ/iAEQHI/V0kKe0Q== + +"@typescript-eslint/type-utils@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.55.0.tgz#195d854b3e56308ce475fdea2165313bb1190200" + integrity sha512-x1iH2unH4qAt6I37I2CGlsNs+B9WGxurP2uyZLRz6UJoZWDBx9cJL1xVN/FiOmHEONEg6RIufdvyT0TEYIgC5g== + dependencies: + "@typescript-eslint/types" "8.55.0" + "@typescript-eslint/typescript-estree" "8.55.0" + "@typescript-eslint/utils" "8.55.0" + debug "^4.4.3" + ts-api-utils "^2.4.0" + +"@typescript-eslint/types@8.55.0", "@typescript-eslint/types@^8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.55.0.tgz#8449c5a7adac61184cac92dbf6315733569708c2" + integrity sha512-ujT0Je8GI5BJWi+/mMoR0wxwVEQaxM+pi30xuMiJETlX80OPovb2p9E8ss87gnSVtYXtJoU9U1Cowcr6w2FE0w== + +"@typescript-eslint/typescript-estree@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.55.0.tgz#c83ac92c11ce79bedd984937c7780a65e7f7b2e3" + integrity sha512-EwrH67bSWdx/3aRQhCoxDaHM+CrZjotc2UCCpEDVqfCE+7OjKAGWNY2HsCSTEVvWH2clYQK8pdeLp42EVs+xQw== + dependencies: + "@typescript-eslint/project-service" "8.55.0" + "@typescript-eslint/tsconfig-utils" "8.55.0" + "@typescript-eslint/types" "8.55.0" + "@typescript-eslint/visitor-keys" "8.55.0" + debug "^4.4.3" + minimatch "^9.0.5" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.4.0" + +"@typescript-eslint/utils@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.55.0.tgz#c1744d94a3901deb01f58b09d3478d811f96d619" + integrity sha512-BqZEsnPGdYpgyEIkDC1BadNY8oMwckftxBT+C8W0g1iKPdeqKZBtTfnvcq0nf60u7MkjFO8RBvpRGZBPw4L2ow== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.55.0" + "@typescript-eslint/types" "8.55.0" + "@typescript-eslint/typescript-estree" "8.55.0" + +"@typescript-eslint/visitor-keys@8.55.0": + version "8.55.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.55.0.tgz#3d9a40fd4e3705c63d8fae3af58988add3ed464d" + integrity sha512-AxNRwEie8Nn4eFS1FzDMJWIISMGoXMb037sgCBJ3UR6o0fQTzr2tqN9WT+DkWJPhIdQCfV7T6D387566VtnCJA== + dependencies: + "@typescript-eslint/types" "8.55.0" + eslint-visitor-keys "^4.2.1" + +"@vitejs/plugin-react@^5.1.4": + version "5.1.4" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz#5b477e060bf612a7394c4febacc5de33a219b0e4" + integrity sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA== + dependencies: + "@babel/core" "^7.29.0" "@babel/plugin-transform-react-jsx-self" "^7.27.1" "@babel/plugin-transform-react-jsx-source" "^7.27.1" - "@rolldown/pluginutils" "1.0.0-beta.27" + "@rolldown/pluginutils" "1.0.0-rc.3" "@types/babel__core" "^7.20.5" - react-refresh "^0.17.0" - -"@zarrita/storage@^0.1.4": - version "0.1.4" - resolved "https://registry.yarnpkg.com/@zarrita/storage/-/storage-0.1.4.tgz#05d7d1d43fc0163d22a17356b619ffeb1ed223d7" - integrity sha512-qURfJAQcQGRfDQ4J9HaCjGaj3jlJKc66bnRk6G/IeLUsM7WKyG7Bzsuf1EZurSXyc0I4LVcu6HaeQQ4d3kZ16g== - dependencies: - reference-spec-reader "^0.2.0" - unzipit "1.4.3" + react-refresh "^0.18.0" a5-js@^0.5.0: version "0.5.0" @@ -1207,7 +2914,7 @@ acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.9.0: +acorn@^8.15.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -1222,11 +2929,6 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-regex@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" @@ -1234,6 +2936,11 @@ ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" +arc@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/arc/-/arc-0.2.0.tgz#afce1bffa736c857c3b00acd274040e5303e9339" + integrity sha512-8NFOo126uYKQJyXNSLY/jSklgfLQL+XWAcPXGo876JwEQ8nSOPXWNI3TV2jLZMN8QEw8uksJ1ZwS4npjBca8MA== + argparse@^1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" @@ -1246,101 +2953,11 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" - integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== - dependencies: - call-bound "^1.0.3" - is-array-buffer "^3.0.5" - -array-includes@^3.1.6, array-includes@^3.1.8: - version "3.1.9" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" - integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - define-properties "^1.2.1" - es-abstract "^1.24.0" - es-object-atoms "^1.1.1" - get-intrinsic "^1.3.0" - is-string "^1.1.1" - math-intrinsics "^1.1.0" - -array.prototype.findlast@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz#3e4fbcb30a15a7f5bf64cf2faae22d139c2e4904" - integrity sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-shim-unscopables "^1.0.2" - -array.prototype.flat@^1.3.1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" - integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-shim-unscopables "^1.0.2" - -array.prototype.flatmap@^1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" - integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-shim-unscopables "^1.0.2" - -array.prototype.tosorted@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz#fe954678ff53034e717ea3352a03f0b0b86f7ffc" - integrity sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.3" - es-errors "^1.3.0" - es-shim-unscopables "^1.0.2" - -arraybuffer.prototype.slice@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" - integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== - dependencies: - array-buffer-byte-length "^1.0.1" - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-errors "^1.3.0" - get-intrinsic "^1.2.6" - is-array-buffer "^3.0.4" - -async-function@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" - integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== - asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -available-typed-arrays@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" - integrity sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ== - dependencies: - possible-typed-array-names "^1.0.0" - axios@^1.4.0: version "1.13.4" resolved "https://registry.yarnpkg.com/axios/-/axios-1.13.4.tgz#15d109a4817fb82f73aea910d41a2c85606076bc" @@ -1370,6 +2987,11 @@ baseline-browser-mapping@^2.9.0: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz#3e508c43c46d961eb4d7d2e5b8d1dd0f9ee4f488" integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg== +bignumber.js@^9.1.0: + version "9.3.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.3.1.tgz#759c5aaddf2ffdc4f154f7b493e1c8770f88c4d7" + integrity sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ== + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" @@ -1378,6 +3000,13 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" +brace-expansion@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== + dependencies: + balanced-match "^1.0.0" + brotli@^1.3.2: version "1.3.3" resolved "https://registry.yarnpkg.com/brotli/-/brotli-1.3.3.tgz#7365d8cc00f12cf765d2b2c898716bcf4b604d48" @@ -1401,7 +3030,7 @@ buf-compare@^1.0.0: resolved "https://registry.yarnpkg.com/buf-compare/-/buf-compare-1.0.1.tgz#fef28da8b8113a0a0db4430b0b6467b69730b34a" integrity sha512-Bvx4xH00qweepGc43xFvMs5BKASXTbHaHm6+kDYIK9p/4iFwjATQkmPKHQSgJZzKbAymhztRbXUf1Nqhzl73/Q== -call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: +call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== @@ -1409,24 +3038,6 @@ call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply- es-errors "^1.3.0" function-bind "^1.1.2" -call-bind@^1.0.7, call-bind@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" - integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== - dependencies: - call-bind-apply-helpers "^1.0.0" - es-define-property "^1.0.0" - get-intrinsic "^1.2.4" - set-function-length "^1.2.2" - -call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" - integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== - dependencies: - call-bind-apply-helpers "^1.0.2" - get-intrinsic "^1.3.0" - callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1464,36 +3075,11 @@ color-convert@^2.0.1: dependencies: color-name "~1.1.4" -color-name@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-2.1.0.tgz#0b677385c1c4b4edfdeaf77e38fa338e3a40b693" - integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== - color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -color-parse@^2.0.0: - version "2.0.2" - resolved "https://registry.yarnpkg.com/color-parse/-/color-parse-2.0.2.tgz#37b46930424924060988edf25b24e6ffb4a1dc3f" - integrity sha512-eCtOz5w5ttWIUcaKLiktF+DxZO1R9KLNY/xhbV6CkhM7sR3GhVghmt6X6yOnzeaM24po+Z9/S1apbXMwA3Iepw== - dependencies: - color-name "^2.0.0" - -color-rgba@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/color-rgba/-/color-rgba-3.0.0.tgz#77090bdcdb2951c1735e20099ddd50401675375b" - integrity sha512-PPwZYkEY3M2THEHHV6Y95sGUie77S7X8v+h1r6LSAPF3/LL2xJ8duUXSrkic31Nzc4odPwHgUbiX/XuTYzQHQg== - dependencies: - color-parse "^2.0.0" - color-space "^2.0.0" - -color-space@^2.0.0, color-space@^2.0.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/color-space/-/color-space-2.3.2.tgz#d8c72bab09ef26b98abebc58bc1586ce3073033d" - integrity sha512-BcKnbOEsOarCwyoLstcoEztwT0IJxqqQkNwDuA3a65sICvvHL2yoeV13psoDFh5IuiOMnIOKdQDwB4Mk3BypiA== - combined-stream@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" @@ -1501,11 +3087,26 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" +commander@2: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concaveman@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/concaveman/-/concaveman-1.2.1.tgz#47d20b4521125c15fabf453653c2696d9ee41e0b" + integrity sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw== + dependencies: + point-in-polygon "^1.1.0" + rbush "^3.0.1" + robust-predicates "^2.0.4" + tinyqueue "^2.0.3" + convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" @@ -1524,7 +3125,7 @@ core-util-is@~1.0.0: resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== -cross-spawn@^7.0.2: +cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -1545,46 +3146,34 @@ css-line-break@^2.1.0: dependencies: utrie "^1.0.2" -data-view-buffer@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" - integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-data-view "^1.0.2" +csstype@^3.2.2: + version "3.2.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" + integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== -data-view-byte-length@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" - integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-data-view "^1.0.2" +d3-array@1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== -data-view-byte-offset@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" - integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== +d3-geo@1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.7.1.tgz#44bbc7a218b1fd859f3d8fd7c443ca836569ce99" + integrity sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw== dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - is-data-view "^1.0.1" + d3-array "1" + +d3-voronoi@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.2.tgz#1687667e8f13a2d158c80c1480c5a29cb0d8973c" + integrity sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw== dayjs@^1.11.11: version "1.11.19" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== -debug@^3.2.7: - version "3.2.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" - integrity sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ== - dependencies: - ms "^2.1.1" - -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2: +debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: version "4.4.3" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -1603,24 +3192,6 @@ deep-strict-equal@^0.2.0: dependencies: core-assert "^0.2.0" -define-data-property@^1.0.1, define-data-property@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" - integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== - dependencies: - es-define-property "^1.0.0" - es-errors "^1.3.0" - gopd "^1.0.1" - -define-properties@^1.1.3, define-properties@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" - integrity sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg== - dependencies: - define-data-property "^1.0.1" - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" @@ -1631,26 +3202,12 @@ detect-libc@^2.0.3: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== -doctrine@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" - integrity sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw== - dependencies: - esutils "^2.0.2" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - draco3d@1.5.7: version "1.5.7" resolved "https://registry.yarnpkg.com/draco3d/-/draco3d-1.5.7.tgz#94f9bce293eb8920c159dc91a4ce9124a9e899e0" integrity sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ== -dunder-proto@^1.0.0, dunder-proto@^1.0.1: +dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== @@ -1659,12 +3216,12 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -earcut@^2.2.3, earcut@^2.2.4: +earcut@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a" integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ== -earcut@^3.0.0: +earcut@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.2.tgz#d478a29aaf99acf418151493048aa197d0512248" integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== @@ -1674,67 +3231,7 @@ electron-to-chromium@^1.5.263: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz#142be1ab5e1cd5044954db0e5898f60a4960384e" integrity sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A== -es-abstract@^1.17.5, es-abstract@^1.23.2, es-abstract@^1.23.3, es-abstract@^1.23.5, es-abstract@^1.23.6, es-abstract@^1.23.9, es-abstract@^1.24.0, es-abstract@^1.24.1: - version "1.24.1" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.1.tgz#f0c131ed5ea1bb2411134a8dd94def09c46c7899" - integrity sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw== - dependencies: - array-buffer-byte-length "^1.0.2" - arraybuffer.prototype.slice "^1.0.4" - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - call-bound "^1.0.4" - data-view-buffer "^1.0.2" - data-view-byte-length "^1.0.2" - data-view-byte-offset "^1.0.1" - es-define-property "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.1.1" - es-set-tostringtag "^2.1.0" - es-to-primitive "^1.3.0" - function.prototype.name "^1.1.8" - get-intrinsic "^1.3.0" - get-proto "^1.0.1" - get-symbol-description "^1.1.0" - globalthis "^1.0.4" - gopd "^1.2.0" - has-property-descriptors "^1.0.2" - has-proto "^1.2.0" - has-symbols "^1.1.0" - hasown "^2.0.2" - internal-slot "^1.1.0" - is-array-buffer "^3.0.5" - is-callable "^1.2.7" - is-data-view "^1.0.2" - is-negative-zero "^2.0.3" - is-regex "^1.2.1" - is-set "^2.0.3" - is-shared-array-buffer "^1.0.4" - is-string "^1.1.1" - is-typed-array "^1.1.15" - is-weakref "^1.1.1" - math-intrinsics "^1.1.0" - object-inspect "^1.13.4" - object-keys "^1.1.1" - object.assign "^4.1.7" - own-keys "^1.0.1" - regexp.prototype.flags "^1.5.4" - safe-array-concat "^1.1.3" - safe-push-apply "^1.0.0" - safe-regex-test "^1.1.0" - set-proto "^1.0.0" - stop-iteration-iterator "^1.1.0" - string.prototype.trim "^1.2.10" - string.prototype.trimend "^1.0.9" - string.prototype.trimstart "^1.0.8" - typed-array-buffer "^1.0.3" - typed-array-byte-length "^1.0.3" - typed-array-byte-offset "^1.0.4" - typed-array-length "^1.0.7" - unbox-primitive "^1.1.0" - which-typed-array "^1.1.19" - -es-define-property@^1.0.0, es-define-property@^1.0.1: +es-define-property@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== @@ -1744,28 +3241,6 @@ es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-iterator-helpers@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/es-iterator-helpers/-/es-iterator-helpers-1.2.2.tgz#d979a9f686e2b0b72f88dbead7229924544720bc" - integrity sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - define-properties "^1.2.1" - es-abstract "^1.24.1" - es-errors "^1.3.0" - es-set-tostringtag "^2.1.0" - function-bind "^1.1.2" - get-intrinsic "^1.3.0" - globalthis "^1.0.4" - gopd "^1.2.0" - has-property-descriptors "^1.0.2" - has-proto "^1.2.0" - has-symbols "^1.1.0" - internal-slot "^1.1.0" - iterator.prototype "^1.1.5" - safe-array-concat "^1.1.3" - es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" @@ -1783,50 +3258,37 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -es-shim-unscopables@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" - integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== - dependencies: - hasown "^2.0.2" - -es-to-primitive@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" - integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== - dependencies: - is-callable "^1.2.7" - is-date-object "^1.0.5" - is-symbol "^1.0.4" - -esbuild@^0.21.3: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== +esbuild@^0.27.0: + version "0.27.3" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8" + integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg== optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" + "@esbuild/aix-ppc64" "0.27.3" + "@esbuild/android-arm" "0.27.3" + "@esbuild/android-arm64" "0.27.3" + "@esbuild/android-x64" "0.27.3" + "@esbuild/darwin-arm64" "0.27.3" + "@esbuild/darwin-x64" "0.27.3" + "@esbuild/freebsd-arm64" "0.27.3" + "@esbuild/freebsd-x64" "0.27.3" + "@esbuild/linux-arm" "0.27.3" + "@esbuild/linux-arm64" "0.27.3" + "@esbuild/linux-ia32" "0.27.3" + "@esbuild/linux-loong64" "0.27.3" + "@esbuild/linux-mips64el" "0.27.3" + "@esbuild/linux-ppc64" "0.27.3" + "@esbuild/linux-riscv64" "0.27.3" + "@esbuild/linux-s390x" "0.27.3" + "@esbuild/linux-x64" "0.27.3" + "@esbuild/netbsd-arm64" "0.27.3" + "@esbuild/netbsd-x64" "0.27.3" + "@esbuild/openbsd-arm64" "0.27.3" + "@esbuild/openbsd-x64" "0.27.3" + "@esbuild/openharmony-arm64" "0.27.3" + "@esbuild/sunos-x64" "0.27.3" + "@esbuild/win32-arm64" "0.27.3" + "@esbuild/win32-ia32" "0.27.3" + "@esbuild/win32-x64" "0.27.3" escalade@^3.2.0: version "3.2.0" @@ -1838,107 +3300,90 @@ escape-string-regexp@^4.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== -eslint-plugin-react-hooks@^4.6.0: - version "4.6.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" - integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== +eslint-plugin-react-hooks@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz#66e258db58ece50723ef20cc159f8aa908219169" + integrity sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA== + dependencies: + "@babel/core" "^7.24.4" + "@babel/parser" "^7.24.4" + hermes-parser "^0.25.1" + zod "^3.25.0 || ^4.0.0" + zod-validation-error "^3.5.0 || ^4.0.0" -eslint-plugin-react-refresh@^0.4.1: +eslint-plugin-react-refresh@^0.4.20: version "0.4.26" resolved "https://registry.yarnpkg.com/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz#2bcdd109ea9fb4e0b56bb1b5146cf8841b21b626" integrity sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ== -eslint-plugin-react@^7.34.1: - version "7.37.5" - resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz#2975511472bdda1b272b34d779335c9b0e877065" - integrity sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA== - dependencies: - array-includes "^3.1.8" - array.prototype.findlast "^1.2.5" - array.prototype.flatmap "^1.3.3" - array.prototype.tosorted "^1.1.4" - doctrine "^2.1.0" - es-iterator-helpers "^1.2.1" - estraverse "^5.3.0" - hasown "^2.0.2" - jsx-ast-utils "^2.4.1 || ^3.0.0" - minimatch "^3.1.2" - object.entries "^1.1.9" - object.fromentries "^2.0.8" - object.values "^1.2.1" - prop-types "^15.8.1" - resolve "^2.0.0-next.5" - semver "^6.3.1" - string.prototype.matchall "^4.0.12" - string.prototype.repeat "^1.0.0" - -eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.2.2.tgz#deb4f92563390f32006894af62a22dba1c46423f" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.44.0: - version "8.57.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.1.tgz#7df109654aba7e3bbe5c8eae533c5e461d3c6ca9" - integrity sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA== +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint@^9.39.2: + version "9.39.2" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c" + integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.57.1" - "@humanwhocodes/config-array" "^0.13.0" + "@eslint-community/eslint-utils" "^4.8.0" + "@eslint-community/regexpp" "^4.12.1" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.1" + "@eslint/js" "9.39.2" + "@eslint/plugin-kit" "^0.4.1" + "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" - "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" + "@humanwhocodes/retry" "^0.4.2" + "@types/estree" "^1.0.6" ajv "^6.12.4" chalk "^4.0.0" - cross-spawn "^7.0.2" + cross-spawn "^7.0.6" debug "^4.3.2" - doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" - esquery "^1.4.2" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" + esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" + file-entry-cache "^8.0.0" find-up "^5.0.0" glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - is-path-inside "^3.0.3" - js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.4.1" lodash.merge "^4.6.2" minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" -espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.yarnpkg.com/espree/-/espree-9.6.1.tgz#a2a17b8e434690a5432f2f8018ce71d331a48c6f" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: - acorn "^8.9.0" + acorn "^8.15.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" + eslint-visitor-keys "^4.2.1" -esquery@^1.4.2: +esquery@^1.5.0: version "1.7.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== @@ -1952,7 +3397,7 @@ esrecurse@^4.3.0: dependencies: estraverse "^5.2.0" -estraverse@^5.1.0, estraverse@^5.2.0, estraverse@^5.3.0: +estraverse@^5.1.0, estraverse@^5.2.0: version "5.3.0" resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== @@ -1962,11 +3407,6 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -eventsource@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-2.0.2.tgz#76dfcc02930fb2ff339520b6d290da573a9e8508" - integrity sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA== - fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -1989,36 +3429,22 @@ fast-xml-parser@^4.2.5: dependencies: strnum "^1.1.1" -fastq@^1.6.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" - integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== - dependencies: - reusify "^1.0.4" - -faye-websocket@^0.11.4: - version "0.11.4" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" - integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== - dependencies: - websocket-driver ">=0.5.1" +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== fflate@0.7.4: version "0.7.4" resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.7.4.tgz#61587e5d958fdabb5a9368a302c25363f4f69f50" integrity sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw== -fflate@^0.8.0: - version "0.8.2" - resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea" - integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A== - -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== dependencies: - flat-cache "^3.0.4" + flat-cache "^4.0.0" find-up@^5.0.0: version "5.0.0" @@ -2028,30 +3454,13 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -flat-cache@^3.0.4: - version "3.2.0" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" - integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: flatted "^3.2.9" - keyv "^4.5.3" - rimraf "^3.0.2" - -flatbuffers@25.9.23: - version "25.9.23" - resolved "https://registry.yarnpkg.com/flatbuffers/-/flatbuffers-25.9.23.tgz#346811557fe9312ab5647535e793c761e9c81eb1" - integrity sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ== - -flatgeobuf@^4.4.0: - version "4.4.0" - resolved "https://registry.yarnpkg.com/flatgeobuf/-/flatgeobuf-4.4.0.tgz#f2067f359ed35a5bd6a19e0cde1a3cd2d16d6c37" - integrity sha512-uUt1xxywP+q8K73MmyKtapF4++dMCzvoqH+ojBTsCtZBbnQEg5qy0Ujze61Rwmpmt6Ra526jpRFHtEkFun5YVw== - dependencies: - "@repeaterjs/repeater" "3.0.6" - flatbuffers "25.9.23" - slice-source "0.4.1" - optionalDependencies: - ol ">=10" + keyv "^4.5.4" flatted@^3.2.9: version "3.3.3" @@ -2063,13 +3472,6 @@ follow-redirects@^1.15.6: resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== -for-each@^0.3.3, for-each@^0.3.5: - version "0.3.5" - resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" - integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== - dependencies: - is-callable "^1.2.7" - form-data@^4.0.4: version "4.0.5" resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" @@ -2081,11 +3483,6 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" @@ -2096,62 +3493,33 @@ function-bind@^1.1.2: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== -function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: - version "1.1.8" - resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" - integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - functions-have-names "^1.2.3" - hasown "^2.0.2" - is-callable "^1.2.7" - -functions-have-names@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" - integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== - -generator-function@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" - integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== - gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -geotiff@^2.0.7: - version "2.1.3" - resolved "https://registry.yarnpkg.com/geotiff/-/geotiff-2.1.3.tgz#993f40f2aa6aa65fb1e0451d86dd22ca8e66910c" - integrity sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA== +geojson-equality-ts@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/geojson-equality-ts/-/geojson-equality-ts-1.0.2.tgz#2aed0b9aca1fedb17212fecbfc42f73020410480" + integrity sha512-h3Ryq+0mCSN/7yLs0eDgrZhvc9af23o/QuC4aTiuuzP/MRCtd6mf5rLsLRY44jX0RPUfM8c4GqERQmlUxPGPoQ== dependencies: - "@petamoriken/float16" "^3.4.7" - lerc "^3.0.0" - pako "^2.0.4" - parse-headers "^2.0.2" - quick-lru "^6.1.1" - web-worker "^1.2.0" - xml-utils "^1.0.2" - zstddec "^0.1.0" + "@types/geojson" "^7946.0.14" -geotiff@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/geotiff/-/geotiff-3.0.2.tgz#f3ce7d9a6c9278b8b4e1638ffe10a08d507c8255" - integrity sha512-KZ+0YK8gW9HWitovPhfHvkyd1gsyXtY9oOrS/OSX+12M8ojAm+NJ6Vl3tUjp7ZMcPO7e7pJoqpWoMdzO0rF8IQ== +geojson-polygon-self-intersections@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/geojson-polygon-self-intersections/-/geojson-polygon-self-intersections-1.2.2.tgz#22abfb6d267390450ec104d5941305e4ed604277" + integrity sha512-6XRNF4CsRHYmR9z5YuIk5f/aOototnDf0dgMqYGcS7y1l57ttt6MAIAxl3rXyas6lq1HEbTuLMh4PgvO+OV42w== dependencies: - "@petamoriken/float16" "^3.4.7" - lerc "^3.0.0" - pako "^2.0.4" - parse-headers "^2.0.2" - quick-lru "^6.1.1" - web-worker "^1.5.0" - xml-utils "^1.10.2" - zstddec "^0.2.0" + rbush "^2.0.1" -get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: +geokdbush@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/geokdbush/-/geokdbush-2.0.1.tgz#85a4d65f89150f1ad5d8831e01c9b60f2f5a15a2" + integrity sha512-0M8so1Qx6+jJ1xpirpCNrgUsWAzIcQ3LrLmh0KJPBYI3gH7vy70nY5zEEjSp9Tn0nBt6Q2Fh922oL08lfib4Zg== + dependencies: + tinyqueue "^2.0.3" + +get-intrinsic@^1.2.6: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -2167,7 +3535,7 @@ get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@ hasown "^2.0.2" math-intrinsics "^1.1.0" -get-proto@^1.0.0, get-proto@^1.0.1: +get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== @@ -2175,16 +3543,12 @@ get-proto@^1.0.0, get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -get-symbol-description@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" - integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== - dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - get-intrinsic "^1.2.6" +get-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== -gl-matrix@^3.0.0, gl-matrix@^3.4.3: +gl-matrix@^3.0.0, gl-matrix@^3.4.3, gl-matrix@^3.4.4: version "3.4.4" resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003" integrity sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ== @@ -2196,72 +3560,31 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^7.1.3: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" +globals@^16.2.0: + version "16.5.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-16.5.0.tgz#ccf1594a437b97653b2be13ed4d8f5c9f850cac1" + integrity sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ== -globalthis@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" - integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== - dependencies: - define-properties "^1.2.1" - gopd "^1.0.1" - -gopd@^1.0.1, gopd@^1.2.0: +gopd@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - h3-js@^4.1.0: version "4.4.0" resolved "https://registry.yarnpkg.com/h3-js/-/h3-js-4.4.0.tgz#858586205d49fc2b164df8e2d1ab855565ee9e81" integrity sha512-DvJh07MhGgY2KcC4OeZc8SSyA+ZXpdvoh6uCzGpoKvWtZxJB+g6VXXC1+eWYkaMIsLz7J/ErhOalHCpcs1KYog== -has-bigints@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/has-bigints/-/has-bigints-1.1.0.tgz#28607e965ac967e03cd2a2c70a2636a1edad49fe" - integrity sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg== - has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== -has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" - integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== - dependencies: - es-define-property "^1.0.0" - -has-proto@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" - integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== - dependencies: - dunder-proto "^1.0.0" - has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" @@ -2281,6 +3604,18 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hermes-estree@0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-estree/-/hermes-estree-0.25.1.tgz#6aeec17d1983b4eabf69721f3aa3eb705b17f480" + integrity sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw== + +hermes-parser@^0.25.1: + version "0.25.1" + resolved "https://registry.yarnpkg.com/hermes-parser/-/hermes-parser-0.25.1.tgz#5be0e487b2090886c62bd8a11724cd766d5f54d1" + integrity sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA== + dependencies: + hermes-estree "0.25.1" + html2canvas@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/html2canvas/-/html2canvas-1.4.1.tgz#7cef1888311b5011d507794a066041b14669a543" @@ -2289,11 +3624,6 @@ html2canvas@^1.4.1: css-line-break "^2.1.0" text-segmentation "^1.0.3" -http-parser-js@>=0.5.1: - version "0.5.10" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" - integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== - ieee754@^1.1.12: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -2304,6 +3634,11 @@ ignore@^5.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + image-size@^0.7.4: version "0.7.5" resolved "https://registry.yarnpkg.com/image-size/-/image-size-0.7.5.tgz#269f357cf5797cb44683dfa99790e54c705ead04" @@ -2332,97 +3667,16 @@ imurmurhash@^0.1.4: resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@^2.0.4, inherits@~2.0.3: +inherits@~2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -internal-slot@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" - integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== - dependencies: - es-errors "^1.3.0" - hasown "^2.0.2" - side-channel "^1.1.0" - -is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: - version "3.0.5" - resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" - integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - get-intrinsic "^1.2.6" - -is-async-function@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" - integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== - dependencies: - async-function "^1.0.0" - call-bound "^1.0.3" - get-proto "^1.0.1" - has-tostringtag "^1.0.2" - safe-regex-test "^1.1.0" - -is-bigint@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" - integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== - dependencies: - has-bigints "^1.0.2" - -is-boolean-object@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" - integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - is-buffer@~1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" integrity sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w== -is-callable@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" - integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== - -is-core-module@^2.13.0: - version "2.16.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" - integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== - dependencies: - hasown "^2.0.2" - -is-data-view@^1.0.1, is-data-view@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" - integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== - dependencies: - call-bound "^1.0.2" - get-intrinsic "^1.2.6" - is-typed-array "^1.1.13" - -is-date-object@^1.0.5, is-date-object@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" - integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - is-error@^2.2.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-error/-/is-error-2.2.2.tgz#c10ade187b3c93510c5470a5567833ee25649843" @@ -2433,24 +3687,6 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== -is-finalizationregistry@^1.1.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" - integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== - dependencies: - call-bound "^1.0.3" - -is-generator-function@^1.0.10: - version "1.1.2" - resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" - integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== - dependencies: - call-bound "^1.0.4" - generator-function "^2.0.0" - get-proto "^1.0.1" - has-tostringtag "^1.0.2" - safe-regex-test "^1.1.0" - is-glob@^4.0.0, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -2458,100 +3694,6 @@ is-glob@^4.0.0, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" -is-map@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" - integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== - -is-negative-zero@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" - integrity sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw== - -is-number-object@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" - integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - -is-path-inside@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-regex@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" - integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== - dependencies: - call-bound "^1.0.2" - gopd "^1.2.0" - has-tostringtag "^1.0.2" - hasown "^2.0.2" - -is-set@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" - integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== - -is-shared-array-buffer@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" - integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== - dependencies: - call-bound "^1.0.3" - -is-string@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" - integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== - dependencies: - call-bound "^1.0.3" - has-tostringtag "^1.0.2" - -is-symbol@^1.0.4, is-symbol@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" - integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== - dependencies: - call-bound "^1.0.2" - has-symbols "^1.1.0" - safe-regex-test "^1.1.0" - -is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: - version "1.1.15" - resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" - integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== - dependencies: - which-typed-array "^1.1.16" - -is-weakmap@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" - integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== - -is-weakref@^1.0.2, is-weakref@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" - integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== - dependencies: - call-bound "^1.0.3" - -is-weakset@^2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" - integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== - dependencies: - call-bound "^1.0.3" - get-intrinsic "^1.2.6" - -isarray@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" - integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== - isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" @@ -2562,24 +3704,12 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== -iterator.prototype@^1.1.5: - version "1.1.5" - resolved "https://registry.yarnpkg.com/iterator.prototype/-/iterator.prototype-1.1.5.tgz#12c959a29de32de0aa3bbbb801f4d777066dae39" - integrity sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g== - dependencies: - define-data-property "^1.1.4" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.6" - get-proto "^1.0.0" - has-symbols "^1.1.0" - set-function-name "^2.0.2" - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: +js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^4.1.0: +js-yaml@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== @@ -2606,20 +3736,20 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stringify-pretty-compact@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz#cf4844770bddee3cb89a6170fe4b00eee5dbf1d4" + integrity sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q== + json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -"jsx-ast-utils@^2.4.1 || ^3.0.0": - version "3.3.5" - resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" - integrity sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ== - dependencies: - array-includes "^3.1.6" - array.prototype.flat "^1.3.1" - object.assign "^4.1.4" - object.values "^1.1.6" +jsts@2.7.1: + version "2.7.1" + resolved "https://registry.yarnpkg.com/jsts/-/jsts-2.7.1.tgz#a921c0cc9eefeef588bd53e952e0a7782d812d52" + integrity sha512-x2wSZHEBK20CY+Wy+BPE7MrFQHW6sIsdaGUMEqmGAio+3gFzQaBYPwLRonUfQf9Ak8pBieqj9tUofX1+WtAEIg== jszip@^3.1.5: version "3.10.1" @@ -2631,7 +3761,12 @@ jszip@^3.1.5: readable-stream "~2.3.6" setimmediate "^1.0.5" -keyv@^4.5.3: +kdbush@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/kdbush/-/kdbush-4.0.2.tgz#2f7b7246328b4657dd122b6c7f025fbc2c868e39" + integrity sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA== + +keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -2643,11 +3778,6 @@ ktx-parse@^0.7.0: resolved "https://registry.yarnpkg.com/ktx-parse/-/ktx-parse-0.7.1.tgz#d41514256d7d63acb8ef6ae62dc66f16efc1c39c" integrity sha512-FeA3g56ksdFNwjXJJsc1CCc7co+AJYDp6ipIp878zZ2bU8kWROatLYf39TQEd4/XRSUvBXovQ8gaVKWPXsCLEQ== -lerc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/lerc/-/lerc-3.0.0.tgz#36f36fbd4ba46f0abf4833799fff2e7d6865f5cb" - integrity sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww== - levn@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade" @@ -2685,13 +3815,6 @@ long@^5.2.1: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== -loose-envify@^1.1.0, loose-envify@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -2709,6 +3832,34 @@ lzo-wasm@^0.0.4: resolved "https://registry.yarnpkg.com/lzo-wasm/-/lzo-wasm-0.0.4.tgz#49152521a0b67a1da3a3e113dd38f150f303230a" integrity sha512-VKlnoJRFrB8SdJhlVKvW5vI1gGwcZ+mvChEXcSX6r2xDNc/Q2FD9esfBmGCuPZdrJ1feO+YcVFd2PTk0c137Gw== +maplibre-gl@^5.18.0: + version "5.18.0" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.18.0.tgz#0542080eb6e034f22f9750b8786d00b1642ea7e5" + integrity sha512-UtWxPBpHuFvEkM+5FVfcFG9ZKEWZQI6+PZkvLErr8Zs5ux+O7/KQ3JjSUvAfOlMeMgd/77qlHpOw0yHL7JU5cw== + dependencies: + "@mapbox/geojson-rewind" "^0.5.2" + "@mapbox/jsonlint-lines-primitives" "^2.0.2" + "@mapbox/point-geometry" "^1.1.0" + "@mapbox/tiny-sdf" "^2.0.7" + "@mapbox/unitbezier" "^0.0.1" + "@mapbox/vector-tile" "^2.0.4" + "@mapbox/whoots-js" "^3.1.0" + "@maplibre/geojson-vt" "^5.0.4" + "@maplibre/maplibre-gl-style-spec" "^24.4.1" + "@maplibre/mlt" "^1.1.6" + "@maplibre/vt-pbf" "^4.2.1" + "@types/geojson" "^7946.0.16" + "@types/supercluster" "^7.1.3" + earcut "^3.0.2" + gl-matrix "^3.4.4" + kdbush "^4.0.2" + murmurhash-js "^1.0.0" + pbf "^4.0.1" + potpack "^2.1.0" + quickselect "^3.0.0" + supercluster "^8.0.1" + tinyqueue "^3.0.0" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -2735,23 +3886,40 @@ mime-types@^2.1.12: dependencies: mime-db "1.52.0" -minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== dependencies: brace-expansion "^1.1.7" +minimatch@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimist@^1.2.6, minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mjolnir.js@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/mjolnir.js/-/mjolnir.js-3.0.0.tgz#2140362331c421501c43be2190479ea9cdbade4b" integrity sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA== -ms@^2.1.1, ms@^2.1.3: +ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== +murmurhash-js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" + integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== + nanoid@^3.3.11: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" @@ -2772,106 +3940,6 @@ node-releases@^2.0.27: resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== -numcodecs@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/numcodecs/-/numcodecs-0.3.2.tgz#09887cfc2a3ae1c59a495c01a7f0528118d85dcd" - integrity sha512-6YSPnmZgg0P87jnNhi3s+FVLOcIn3y+1CTIgUulA3IdASzK9fJM87sUFkpyA+be9GibGRaST2wCgkD+6U+fWKw== - dependencies: - fflate "^0.8.0" - -object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.13.3, object-inspect@^1.13.4: - version "1.13.4" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" - integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.4, object.assign@^4.1.7: - version "4.1.7" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" - integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - has-symbols "^1.1.0" - object-keys "^1.1.1" - -object.entries@^1.1.9: - version "1.1.9" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.9.tgz#e4770a6a1444afb61bd39f984018b5bede25f8b3" - integrity sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.4" - define-properties "^1.2.1" - es-object-atoms "^1.1.1" - -object.fromentries@^2.0.8: - version "2.0.8" - resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" - integrity sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - -object.values@^1.1.6, object.values@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" - integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -ol-ext@^4.0.10: - version "4.0.37" - resolved "https://registry.yarnpkg.com/ol-ext/-/ol-ext-4.0.37.tgz#8da5c4097322e56f99b45537ca353c242d1c9b88" - integrity sha512-RxzdgMWnNBDP9VZCza3oS3rl1+OCl+1SJLMjt7ATyDDLZl/zzrsQELfJ25WAL6HIWgjkQ2vYDh3nnHFupxOH4w== - -ol@>=10: - version "10.8.0" - resolved "https://registry.yarnpkg.com/ol/-/ol-10.8.0.tgz#fe528cd93f13e673e309435f577076e644653aa3" - integrity sha512-kLk7jIlJvKyhVMAjORTXKjzlM6YIByZ1H/d0DBx3oq8nSPCG6/gbLr5RxukzPgwbhnAqh+xHNCmrvmFKhVMvoQ== - dependencies: - "@types/rbush" "4.0.0" - earcut "^3.0.0" - geotiff "^3.0.2" - pbf "4.0.1" - rbush "^4.0.0" - zarrita "^0.6.0" - -ol@^9.2.4: - version "9.2.4" - resolved "https://registry.yarnpkg.com/ol/-/ol-9.2.4.tgz#07dcefdceb66ddbde13089bca136f4d4852b772b" - integrity sha512-bsbu4ObaAlbELMIZWnYEvX4Z9jO+OyCBshtODhDKmqYTPEfnKOX3RieCr97tpJkqWTZvyV4tS9UQDvHoCdxS+A== - dependencies: - color-rgba "^3.0.0" - color-space "^2.0.1" - earcut "^2.2.3" - geotiff "^2.0.7" - pbf "3.2.1" - rbush "^3.0.1" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - optionator@^0.9.3: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" @@ -2884,15 +3952,6 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -own-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" - integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== - dependencies: - get-intrinsic "^1.2.6" - object-keys "^1.1.1" - safe-push-apply "^1.0.0" - p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -2912,11 +3971,6 @@ pako@1.0.11, pako@~1.0.2: resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.11.tgz#6c9599d340d54dfd3946380252a35705a6b992bf" integrity sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw== -pako@^2.0.4: - version "2.1.0" - resolved "https://registry.yarnpkg.com/pako/-/pako-2.1.0.tgz#266cc37f98c7d883545d11335c00fbd4062c9a86" - integrity sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug== - parent-module@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" @@ -2924,46 +3978,16 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-headers@^2.0.2: - version "2.0.6" - resolved "https://registry.yarnpkg.com/parse-headers/-/parse-headers-2.0.6.tgz#7940f0abe5fe65df2dd25d4ce8800cb35b49d01c" - integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A== - path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -pbf@3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.2.1.tgz#b4c1b9e72af966cd82c6531691115cc0409ffe2a" - integrity sha512-ClrV7pNOn7rtmoQVF4TS1vyU0WhYRnP92fzbfF75jAIwpnzdJXf8iTd4CMEqO4yUenH6NDqLiwjqlh6QgZzgLQ== - dependencies: - ieee754 "^1.1.12" - resolve-protobuf-schema "^2.1.0" - -pbf@4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/pbf/-/pbf-4.0.1.tgz#ad9015e022b235dcdbe05fc468a9acadf483f0d4" - integrity sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA== - dependencies: - resolve-protobuf-schema "^2.1.0" - pbf@^3.2.1: version "3.3.0" resolved "https://registry.yarnpkg.com/pbf/-/pbf-3.3.0.tgz#1790f3d99118333cc7f498de816028a346ef367f" @@ -2972,6 +3996,13 @@ pbf@^3.2.1: ieee754 "^1.1.12" resolve-protobuf-schema "^2.1.0" +pbf@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/pbf/-/pbf-4.0.1.tgz#ad9015e022b235dcdbe05fc468a9acadf483f0d4" + integrity sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA== + dependencies: + resolve-protobuf-schema "^2.1.0" + picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" @@ -2982,12 +4013,27 @@ picomatch@^4.0.3: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== -possible-typed-array-names@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" - integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== +point-in-polygon-hao@^1.1.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz#8662abdcc84bcca230cc3ecbb0b0ab1a306f1bd6" + integrity sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ== + dependencies: + robust-predicates "^3.0.2" -postcss@^8.4.43: +point-in-polygon@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357" + integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw== + +polyclip-ts@^0.16.8: + version "0.16.8" + resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.8.tgz#503160d05e9d56380533aab0bc2dae835d6da5f9" + integrity sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ== + dependencies: + bignumber.js "^9.1.0" + splaytree-ts "^1.0.2" + +postcss@^8.5.6: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -2996,6 +4042,11 @@ postcss@^8.4.43: picocolors "^1.1.1" source-map-js "^1.2.1" +potpack@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/potpack/-/potpack-2.1.0.tgz#fe548e2f9061e9937f17191c1ab6dd98ca30e02f" + integrity sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ== + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3006,15 +4057,6 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@^15.8.1: - version "15.8.1" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" - integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== - dependencies: - loose-envify "^1.4.0" - object-assign "^4.1.1" - react-is "^16.13.1" - protocol-buffers-schema@^3.3.1: version "3.6.0" resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" @@ -3030,20 +4072,10 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -querystringify@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" - integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -quick-lru@^6.1.1: - version "6.1.2" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-6.1.2.tgz#e9a90524108629be35287d0b864e7ad6ceb3659e" - integrity sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ== +quickselect@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-1.1.1.tgz#852e412ce418f237ad5b660d70cffac647ae94c2" + integrity sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ== quickselect@^2.0.0: version "2.0.0" @@ -3055,6 +4087,13 @@ quickselect@^3.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-3.0.0.tgz#a37fc953867d56f095a20ac71c6d27063d2de603" integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== +rbush@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/rbush/-/rbush-2.0.2.tgz#bb6005c2731b7ba1d5a9a035772927d16a614605" + integrity sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA== + dependencies: + quickselect "^1.0.1" + rbush@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf" @@ -3062,30 +4101,17 @@ rbush@^3.0.1: dependencies: quickselect "^2.0.0" -rbush@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/rbush/-/rbush-4.0.1.tgz#1f55afa64a978f71bf9e9a99bc14ff84f3cb0d6d" - integrity sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ== +react-dom@^19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== dependencies: - quickselect "^3.0.0" + scheduler "^0.27.0" -react-dom@^18.2.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== - dependencies: - loose-envify "^1.1.0" - scheduler "^0.23.2" - -react-is@^16.13.1: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - -react-refresh@^0.17.0: - version "0.17.0" - resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.17.0.tgz#b7e579c3657f23d04eccbe4ad2e58a8ed51e7e53" - integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== +react-refresh@^0.18.0: + version "0.18.0" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.18.0.tgz#2dce97f4fe932a4d8142fa1630e475c1729c8062" + integrity sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw== react-router-dom@^6.30.3: version "6.30.3" @@ -3102,12 +4128,10 @@ react-router@6.30.3: dependencies: "@remix-run/router" "1.23.2" -react@^18.2.0: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== - dependencies: - loose-envify "^1.1.0" +react@^19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== readable-stream@~2.3.6: version "2.3.8" @@ -3127,42 +4151,6 @@ readdirp@^4.0.1: resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== -reference-spec-reader@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/reference-spec-reader/-/reference-spec-reader-0.2.0.tgz#52bd79614dde68e68f05c97a05ae04ff20acd7ec" - integrity sha512-q0mfCi5yZSSHXpCyxjgQeaORq3tvDsxDyzaadA/5+AbAUwRyRuuTh0aRQuE/vAOt/qzzxidJ5iDeu1cLHaNBlQ== - -reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: - version "1.0.10" - resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" - integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-abstract "^1.23.9" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.7" - get-proto "^1.0.1" - which-builtin-type "^1.2.1" - -regexp.prototype.flags@^1.5.3, regexp.prototype.flags@^1.5.4: - version "1.5.4" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" - integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== - dependencies: - call-bind "^1.0.8" - define-properties "^1.2.1" - es-errors "^1.3.0" - get-proto "^1.0.1" - gopd "^1.2.0" - set-function-name "^2.0.2" - -requires-port@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== - resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -3175,28 +4163,17 @@ resolve-protobuf-schema@^2.1.0: dependencies: protocol-buffers-schema "^3.3.1" -resolve@^2.0.0-next.5: - version "2.0.0-next.5" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-2.0.0-next.5.tgz#6b0ec3107e671e52b68cd068ef327173b90dc03c" - integrity sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA== - dependencies: - is-core-module "^2.13.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" +robust-predicates@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-2.0.4.tgz#0a2367a93abd99676d075981707f29cfb402248b" + integrity sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg== -reusify@^1.0.4: - version "1.1.0" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" - integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== - -rimraf@^3.0.2: +robust-predicates@^3.0.2: version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" + resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" + integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@^4.20.0: +rollup@^4.43.0: version "4.57.1" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.1.tgz#947f70baca32db2b9c594267fe9150aa316e5a88" integrity sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A== @@ -3230,52 +4207,17 @@ rollup@^4.20.0: "@rollup/rollup-win32-x64-msvc" "4.57.1" fsevents "~2.3.2" -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -safe-array-concat@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" - integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - get-intrinsic "^1.2.6" - has-symbols "^1.1.0" - isarray "^2.0.5" - -safe-buffer@>=5.1.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +rw@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" + integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-push-apply@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" - integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== - dependencies: - es-errors "^1.3.0" - isarray "^2.0.5" - -safe-regex-test@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" - integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - is-regex "^1.2.1" - -sass@^1.77.8: +sass@^1.97.3: version "1.97.3" resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2" integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg== @@ -3286,48 +4228,20 @@ sass@^1.77.8: optionalDependencies: "@parcel/watcher" "^2.4.1" -scheduler@^0.23.2: - version "0.23.2" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" - integrity sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ== - dependencies: - loose-envify "^1.1.0" +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.27.0.tgz#0c4ef82d67d1e5c1e359e8fc76d3a87f045fe5bd" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== semver@^6.3.1: version "6.3.1" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -set-function-length@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" - integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - function-bind "^1.1.2" - get-intrinsic "^1.2.4" - gopd "^1.0.1" - has-property-descriptors "^1.0.2" - -set-function-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" - integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== - dependencies: - define-data-property "^1.1.4" - es-errors "^1.3.0" - functions-have-names "^1.2.3" - has-property-descriptors "^1.0.2" - -set-proto@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" - integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== - dependencies: - dunder-proto "^1.0.1" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" +semver@^7.7.3: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== setimmediate@^1.0.5: version "1.0.5" @@ -3346,144 +4260,31 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - -side-channel-map@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" - integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - -side-channel-weakmap@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" - integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== - dependencies: - call-bound "^1.0.2" - es-errors "^1.3.0" - get-intrinsic "^1.2.5" - object-inspect "^1.13.3" - side-channel-map "^1.0.1" - -side-channel@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== - dependencies: - es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" - side-channel-map "^1.0.1" - side-channel-weakmap "^1.0.2" - -slice-source@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/slice-source/-/slice-source-0.4.1.tgz#40a57ac03c6668b5da200e05378e000bf2a61d79" - integrity sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg== +skmeans@0.9.7: + version "0.9.7" + resolved "https://registry.yarnpkg.com/skmeans/-/skmeans-0.9.7.tgz#72670cebb728508f56e29c0e10d11e623529ce5d" + integrity sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg== snappyjs@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/snappyjs/-/snappyjs-0.6.1.tgz#9bca9ff8c54b133a9cc84a71d22779e97fc51878" integrity sha512-YIK6I2lsH072UE0aOFxxY1dPDCS43I5ktqHpeAsuLNYWkE5pGxRGWfDM4/vSUfNzXjC1Ivzt3qx31PCLmc9yqg== -sockjs-client@^1.6.1: - version "1.6.1" - resolved "https://registry.yarnpkg.com/sockjs-client/-/sockjs-client-1.6.1.tgz#350b8eda42d6d52ddc030c39943364c11dcad806" - integrity sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw== - dependencies: - debug "^3.2.7" - eventsource "^2.0.2" - faye-websocket "^0.11.4" - inherits "^2.0.4" - url-parse "^1.5.10" - "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== +splaytree-ts@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.2.tgz#34963704587aff45eaa09c24713f552bbf56e8f0" + integrity sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stop-iteration-iterator@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" - integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== - dependencies: - es-errors "^1.3.0" - internal-slot "^1.1.0" - -string.prototype.matchall@^4.0.12: - version "4.0.12" - resolved "https://registry.yarnpkg.com/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz#6c88740e49ad4956b1332a911e949583a275d4c0" - integrity sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-abstract "^1.23.6" - es-errors "^1.3.0" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.6" - gopd "^1.2.0" - has-symbols "^1.1.0" - internal-slot "^1.1.0" - regexp.prototype.flags "^1.5.3" - set-function-name "^2.0.2" - side-channel "^1.1.0" - -string.prototype.repeat@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz#e90872ee0308b29435aa26275f6e1b762daee01a" - integrity sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.17.5" - -string.prototype.trim@^1.2.10: - version "1.2.10" - resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" - integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - define-data-property "^1.1.4" - define-properties "^1.2.1" - es-abstract "^1.23.5" - es-object-atoms "^1.0.0" - has-property-descriptors "^1.0.2" - -string.prototype.trimend@^1.0.9: - version "1.0.9" - resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" - integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.2" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - -string.prototype.trimstart@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" - integrity sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg== - dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -3491,13 +4292,6 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" @@ -3508,6 +4302,13 @@ strnum@^1.1.1: resolved "https://registry.yarnpkg.com/strnum/-/strnum-1.1.2.tgz#57bca4fbaa6f271081715dbc9ed7cee5493e28e4" integrity sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA== +supercluster@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/supercluster/-/supercluster-8.0.1.tgz#9946ba123538e9e9ab15de472531f604e7372df5" + integrity sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ== + dependencies: + kdbush "^4.0.2" + supports-color@^7.1.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" @@ -3515,10 +4316,12 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +sweepline-intersections@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz#85ab3629a291875926fae0acd508496430d8a647" + integrity sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ== + dependencies: + tinyqueue "^2.0.0" text-segmentation@^1.0.3: version "1.0.3" @@ -3527,11 +4330,6 @@ text-segmentation@^1.0.3: dependencies: utrie "^1.0.2" -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - texture-compressor@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/texture-compressor/-/texture-compressor-1.0.2.tgz#b5a54a9e5f9eb884d7c33b149f1f23a429465cd4" @@ -3540,6 +4338,48 @@ texture-compressor@^1.0.2: argparse "^1.0.10" image-size "^0.7.4" +tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinyqueue@^2.0.0, tinyqueue@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== + +tinyqueue@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" + integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== + +topojson-client@3.x: + version "3.1.0" + resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99" + integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw== + dependencies: + commander "2" + +topojson-server@3.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/topojson-server/-/topojson-server-3.0.1.tgz#d2b3ec095b6732299be76a48406111b3201a34f5" + integrity sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw== + dependencies: + commander "2" + +ts-api-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" + integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== + +tslib@^2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -3547,78 +4387,31 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -typed-array-buffer@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" - integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== +typescript-eslint@^8.55.0: + version "8.55.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.55.0.tgz#abae8295c5f0f82f816218113a46e89bc30c3de2" + integrity sha512-HE4wj+r5lmDVS9gdaN0/+iqNvPZwGfnJ5lZuz7s5vLlg9ODw0bIiiETaios9LvFI1U94/VBXGm3CB2Y5cNFMpw== dependencies: - call-bound "^1.0.3" - es-errors "^1.3.0" - is-typed-array "^1.1.14" + "@typescript-eslint/eslint-plugin" "8.55.0" + "@typescript-eslint/parser" "8.55.0" + "@typescript-eslint/typescript-estree" "8.55.0" + "@typescript-eslint/utils" "8.55.0" -typed-array-byte-length@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" - integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== - dependencies: - call-bind "^1.0.8" - for-each "^0.3.3" - gopd "^1.2.0" - has-proto "^1.2.0" - is-typed-array "^1.1.14" +typescript@~5.7.2: + version "5.7.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.3.tgz#919b44a7dbb8583a9b856d162be24a54bf80073e" + integrity sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw== -typed-array-byte-offset@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" - integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - for-each "^0.3.3" - gopd "^1.2.0" - has-proto "^1.2.0" - is-typed-array "^1.1.15" - reflect.getprototypeof "^1.0.9" - -typed-array-length@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" - integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== - dependencies: - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" - is-typed-array "^1.1.13" - possible-typed-array-names "^1.0.0" - reflect.getprototypeof "^1.0.6" - -unbox-primitive@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" - integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== - dependencies: - call-bound "^1.0.3" - has-bigints "^1.0.2" - has-symbols "^1.1.0" - which-boxed-primitive "^1.1.1" +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== undici-types@~7.16.0: version "7.16.0" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.16.0.tgz#ffccdff36aea4884cbfce9a750a0580224f58a46" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== -unzipit@1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/unzipit/-/unzipit-1.4.3.tgz#738298a6b235892bf7ce7db82cff813d4ca664ac" - integrity sha512-gsq2PdJIWWGhx5kcdWStvNWit9FVdTewm4SEG7gFskWs+XCVaULt9+BwuoBtJiRE8eo3L1IPAOrbByNLtLtIlg== - dependencies: - uzip-module "^1.0.2" - update-browserslist-db@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" @@ -3634,19 +4427,6 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -url-parse@^1.5.10: - version "1.5.10" - resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" - integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== - dependencies: - querystringify "^2.1.1" - requires-port "^1.0.0" - -use-sync-external-store@^1.2.2: - version "1.6.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" - integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== - util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -3659,99 +4439,25 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" -uzip-module@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/uzip-module/-/uzip-module-1.0.3.tgz#6bbabe2a3efea5d5a4a47479f523a571de3427ce" - integrity sha512-AMqwWZaknLM77G+VPYNZLEruMGWGzyigPK3/Whg99B3S6vGHuqsyl5ZrOv1UUF3paGK1U6PM0cnayioaryg/fA== - -vite@^5.2.10: - version "5.4.21" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.21.tgz#84a4f7c5d860b071676d39ba513c0d598fdc7027" - integrity sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw== +vite@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.1.tgz#7f6cfe8fb9074138605e822a75d9d30b814d6507" + integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" optionalDependencies: fsevents "~2.3.3" -web-worker@^1.2.0, web-worker@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/web-worker/-/web-worker-1.5.0.tgz#71b2b0fbcc4293e8f0aa4f6b8a3ffebff733dcc5" - integrity sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw== - -websocket-driver@>=0.5.1: - version "0.7.4" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" - integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== - dependencies: - http-parser-js ">=0.5.1" - safe-buffer ">=5.1.0" - websocket-extensions ">=0.1.1" - -websocket-extensions@>=0.1.1: - version "0.1.4" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" - integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== - wgsl_reflect@^1.2.0: version "1.2.3" resolved "https://registry.yarnpkg.com/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz#41985a661efdd00047e771ad7aa06ab131926a55" integrity sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q== -which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" - integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== - dependencies: - is-bigint "^1.1.0" - is-boolean-object "^1.2.1" - is-number-object "^1.1.1" - is-string "^1.1.1" - is-symbol "^1.1.1" - -which-builtin-type@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" - integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== - dependencies: - call-bound "^1.0.2" - function.prototype.name "^1.1.6" - has-tostringtag "^1.0.2" - is-async-function "^2.0.0" - is-date-object "^1.1.0" - is-finalizationregistry "^1.1.0" - is-generator-function "^1.0.10" - is-regex "^1.2.1" - is-weakref "^1.0.2" - isarray "^2.0.5" - which-boxed-primitive "^1.1.0" - which-collection "^1.0.2" - which-typed-array "^1.1.16" - -which-collection@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" - integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== - dependencies: - is-map "^2.0.3" - is-set "^2.0.3" - is-weakmap "^2.0.2" - is-weakset "^2.0.3" - -which-typed-array@^1.1.16, which-typed-array@^1.1.19: - version "1.1.20" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" - integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== - dependencies: - available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - call-bound "^1.0.4" - for-each "^0.3.5" - get-proto "^1.0.1" - gopd "^1.2.0" - has-tostringtag "^1.0.2" - which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3764,16 +4470,6 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -xml-utils@^1.0.2, xml-utils@^1.10.2: - version "1.10.2" - resolved "https://registry.yarnpkg.com/xml-utils/-/xml-utils-1.10.2.tgz#436b39ccc25a663ce367ea21abb717afdea5d6b1" - integrity sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA== - yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -3784,32 +4480,22 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zarrita@^0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/zarrita/-/zarrita-0.6.1.tgz#4e0d13e14c0ebcf70e08ed3bafd1d9a290651445" - integrity sha512-YOMTW8FT55Rz+vadTIZeOFZ/F2h4svKizyldvPtMYSxPgSNcRkOzkxCsWpIWlWzB1I/LmISmi0bEekOhLlI+Zw== - dependencies: - "@zarrita/storage" "^0.1.4" - numcodecs "^0.3.2" +"zod-validation-error@^3.5.0 || ^4.0.0": + version "4.0.2" + resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918" + integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ== + +"zod@^3.25.0 || ^4.0.0": + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== zstd-codec@^0.1: version "0.1.5" resolved "https://registry.yarnpkg.com/zstd-codec/-/zstd-codec-0.1.5.tgz#c180193e4603ef74ddf704bcc835397d30a60e42" integrity sha512-v3fyjpK8S/dpY/X5WxqTK3IoCnp/ZOLxn144GZVlNUjtwAchzrVo03h+oMATFhCIiJ5KTr4V3vDQQYz4RU684g== -zstddec@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/zstddec/-/zstddec-0.1.0.tgz#7050f3f0e0c3978562d0c566b3e5a427d2bad7ec" - integrity sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg== - -zstddec@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/zstddec/-/zstddec-0.2.0.tgz#91c8cde8f351ef5fe0bdfca66bb14a5fa0d16d71" - integrity sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA== - -zustand@^4.5.2: - version "4.5.7" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55" - integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw== - dependencies: - use-sync-external-store "^1.2.2" +zustand@^5: + version "5.0.11" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.11.tgz#99f912e590de1ca9ce6c6d1cab6cdb1f034ab494" + integrity sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==