commit b9d924e81e7fd88c865fda60ab94ec29240fa278 Author: htlee Date: Tue Mar 31 12:36:38 2026 +0900 chore: 팀 워크플로우 기반 초기 프로젝트 구성 WING-GIS 해양경찰 통합 GIS 위치정보시스템. 모노레포: frontend(React 19 + MapLibre + deck.gl) + services(Spring Boot + Gradle). - npm + Nexus 프록시 레지스트리 설정 - 팀 워크플로우 v1.6.1 부트스트랩 파일 배치 - .githooks (commit-msg, post-checkout) - custom_pre_commit: true (모노레포 pre-commit 별도 관리) Co-Authored-By: Claude Opus 4.6 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..322e437 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json.schemastore.org/claude-code-settings.json", + "env": { + "CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5" + }, + "permissions": { + "allow": [ + "Bash(npm run *)", + "Bash(npm install *)", + "Bash(npm test *)", + "Bash(npx *)", + "Bash(node *)", + "Bash(git status)", + "Bash(git diff *)", + "Bash(git log *)", + "Bash(git branch *)", + "Bash(git checkout *)", + "Bash(git add *)", + "Bash(git commit *)", + "Bash(git pull *)", + "Bash(git fetch *)", + "Bash(git merge *)", + "Bash(git stash *)", + "Bash(git remote *)", + "Bash(git config *)", + "Bash(git rev-parse *)", + "Bash(git show *)", + "Bash(git tag *)", + "Bash(curl -s *)", + "Bash(fnm *)", + "Bash(./gradlew *)" + ], + "deny": [ + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(git push origin --force*)", + "Bash(git reset --hard*)", + "Bash(git clean -fd*)", + "Bash(git checkout -- .)", + "Bash(git restore .)", + "Bash(rm -rf /)", + "Bash(rm -rf ~)", + "Bash(rm -rf .git*)", + "Bash(rm -rf /*)", + "Bash(rm -rf node_modules)", + "Read(./**/.env)", + "Read(./**/.env.*)", + "Read(./**/secrets/**)" + ] + } +} diff --git a/.claude/skills/init-project/SKILL.md b/.claude/skills/init-project/SKILL.md new file mode 100644 index 0000000..d0df8e5 --- /dev/null +++ b/.claude/skills/init-project/SKILL.md @@ -0,0 +1,295 @@ +--- +name: init-project +description: 팀 표준 워크플로우로 프로젝트를 초기화합니다 +argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]" +--- + +팀 표준 워크플로우에 따라 프로젝트를 초기화합니다. +프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지) + +## 프로젝트 타입 자동 감지 + +$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지: +1. `pom.xml` 존재 → **java-maven** +2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle** +3. `package.json` + `tsconfig.json` 존재 → **react-ts** +4. 감지 실패 → 사용자에게 타입 선택 요청 + +## 수행 단계 + +### 1. 프로젝트 분석 +- 빌드 파일, 설정 파일, 디렉토리 구조 파악 +- 사용 중인 프레임워크, 라이브러리 감지 +- 기존 `.claude/` 디렉토리 존재 여부 확인 +- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인 + +### 2. CLAUDE.md 생성 +프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함: +- 프로젝트 개요 (이름, 타입, 주요 기술 스택) +- 빌드/실행 명령어 (감지된 빌드 도구 기반) +- 테스트 실행 명령어 +- lint 실행 명령어 (감지된 도구 기반) +- 프로젝트 디렉토리 구조 요약 +- 팀 컨벤션 참조 (`.claude/rules/` 안내) + +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가): +```bash +GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}" +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로> +# 예시: +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" +curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig" +``` + +### 3. .claude/ 디렉토리 구성 +이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드: +- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조) + +⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다. + +### 3.5. Gitea 토큰 설정 + +**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json`의 `env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요. + +**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰. + +```bash +# 현재 GITEA_TOKEN 설정 여부 확인 +if [ -z "$GITEA_TOKEN" ]; then + echo "GITEA_TOKEN 미설정" +fi +``` + +**GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시: + +**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?" +- 옵션 1: 토큰 생성 안내 보기 (추천) +- 옵션 2: 이미 있음 (토큰 입력) +- 옵션 3: 나중에 하기 + +**토큰 생성 안내 선택 시**, 다음 내용을 표시: + +``` +📋 Gitea 토큰 생성 방법: + +1. 브라우저에서 접속: + https://gitea.gc-si.dev/user/settings/applications + +2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭 + +3. 입력: + - Token Name: "claude-code" (자유롭게 지정) + - Repository and Organization Access: ✅ All (public, private, and limited) + +4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지): + + ┌─────────────────┬──────────────────┬──────────────────────────────┐ + │ 항목 │ 권한 │ 용도 │ + ├─────────────────┼──────────────────┼──────────────────────────────┤ + │ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │ + │ organization │ Read │ gc 조직 리포 접근 │ + │ repository │ Read and Write │ /push, /mr, /release API 호출 │ + │ user │ Read │ API 사용자 인증 확인 │ + └─────────────────┴──────────────────┴──────────────────────────────┘ + +5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요. +``` + +표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요" +- 옵션 1: 토큰 입력 (Other로 입력) +- 옵션 2: 나중에 하기 + +**토큰 입력 시**: + +1. Gitea API로 유효성 검증: +```bash +curl -sf "https://gitea.gc-si.dev/api/v1/user" \ + -H "Authorization: token <입력된 토큰>" +``` +- 성공: `✅ () 인증 확인` 출력 +- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청 + +2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨): +```json +{ + "env": { + "GITEA_TOKEN": "<입력된 토큰>" + } +} +``` + +기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신. + +**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행: +``` +⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다. + 나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요: + { "env": { "GITEA_TOKEN": "your-token-here" } } +``` + +### 4. Hook 스크립트 설정 + +⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다. +여기서는 `settings.json`에 hooks 섹션만 설정한다. + +`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합): + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "compact", + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/on-post-compact.sh", + "timeout": 10 + } + ] + } + ], + "PreCompact": [ + { + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/on-pre-compact.sh", + "timeout": 30 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash .claude/scripts/on-commit.sh", + "timeout": 15 + } + ] + } + ] + } +} +``` + +### 5. Git Hooks 설정 +```bash +git config core.hooksPath .githooks +``` +`.githooks/` 디렉토리에 실행 권한 부여: +```bash +chmod +x .githooks/* +``` + +**pre-commit 훅 검증**: `.githooks/pre-commit`을 실행하여 빌드 검증이 정상 동작하는지 확인. +에러 발생 시 (예: 모노레포가 아닌 특수 구조, 빌드 명령 불일치 등): +1. 프로젝트에 맞게 `.githooks/pre-commit`을 커스텀 수정 +2. `.claude/workflow-version.json`에 `"custom_pre_commit": true` 추가 +3. 이후 `/sync-team-workflow` 실행 시 pre-commit은 덮어쓰지 않고 보존됨 + (`commit-msg`, `post-checkout`은 항상 팀 표준으로 동기화) + +### 6. 프로젝트 타입별 추가 설정 + +#### java-maven +- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전) +- `.mvn/settings.xml` Nexus 미러 설정 확인 +- `mvn compile` 빌드 성공 확인 + +#### java-gradle +- `.sdkmanrc` 생성 +- `gradle.properties.example` Nexus 설정 확인 +- `./gradlew compileJava` 빌드 성공 확인 + +#### react-ts +- `.node-version` 생성 (프로젝트에 맞는 Node 버전) +- `.npmrc` Nexus 레지스트리 설정 확인 +- `npm install && npm run build` 성공 확인 + +### 7. .gitignore 확인 +다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가: +``` +.claude/settings.local.json +.claude/CLAUDE.local.md +.env +.env.* +*.local +``` + +**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음): +``` +# Team workflow (managed by /sync-team-workflow) +.claude/rules/ +.claude/agents/ +.claude/skills/push/ +.claude/skills/mr/ +.claude/skills/create-mr/ +.claude/skills/release/ +.claude/skills/version/ +.claude/skills/fix-issue/ +.claude/scripts/ +``` + +### 8. Git exclude 설정 +`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가: + +```gitignore + +# Claude Code 워크플로우 (로컬 전용) +docs/CHANGELOG.md +*.tmp +``` + +### 9. Memory 초기화 +프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects//memory/`) 다음 파일들을 생성: + +- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내) + - 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크 +- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트 +- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작 +- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약 +- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록) +- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴) + +### 10. Lint 도구 확인 +- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안 +- Java: checkstyle, spotless 등 설정 확인 +- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인 + +### 11. workflow-version.json 생성 +Gitea API로 최신 팀 워크플로우 버전을 조회: +```bash +curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json" +``` +조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용. + +`.claude/workflow-version.json` 파일 생성: +```json +{ + "applied_global_version": "<조회된 버전>", + "applied_date": "<현재날짜>", + "project_type": "<감지된타입>", + "gitea_url": "https://gitea.gc-si.dev" +} +``` + +### 12. 팀 워크플로우 최신화 + +`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다. + +이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다. +(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음) + +### 13. 검증 및 요약 +- 생성/수정된 파일 목록 출력 +- `git config core.hooksPath` 확인 +- 빌드 명령 실행 가능 확인 +- Hook 스크립트 실행 권한 확인 +- 다음 단계 안내: + - 개발 시작, 첫 커밋 방법 + - 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec` diff --git a/.claude/skills/sync-team-workflow/SKILL.md b/.claude/skills/sync-team-workflow/SKILL.md new file mode 100644 index 0000000..5aa6441 --- /dev/null +++ b/.claude/skills/sync-team-workflow/SKILL.md @@ -0,0 +1,165 @@ +--- +name: sync-team-workflow +description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다 +--- + +팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다. +호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음). + +## 수행 절차 + +### 1. 사전 조건 확인 + +`.claude/workflow-version.json` 존재 확인: +- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료 + +설정 읽기: +```bash +GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev") +PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "") +``` + +프로젝트 타입이 비어있으면 자동 감지: +1. `pom.xml` → java-maven +2. `build.gradle` / `build.gradle.kts` → java-gradle +3. `package.json` + `tsconfig.json` → react-ts +4. 감지 실패 → 사용자에게 선택 요청 + +### Gitea 파일 다운로드 URL 패턴 +⚠️ Gitea raw 파일은 반드시 **web raw URL** 사용: +```bash +# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로> +# 타입별 파일: ${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/<파일경로> +``` + +### 2. 디렉토리 준비 + +필요한 디렉토리가 없으면 생성: +```bash +mkdir -p .claude/rules .claude/agents .claude/scripts +mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr +mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue +mkdir -p .githooks +``` + +### 3. 서버 파일 다운로드 + 적용 + +각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장. +다운로드 실패한 파일은 경고 출력 후 건너뜀. + +#### 3-1. template-common 파일 (덮어쓰기) + +**규칙 파일**: +``` +.claude/rules/team-policy.md +.claude/rules/git-workflow.md +.claude/rules/release-notes-guide.md +.claude/rules/subagent-policy.md +``` + +**에이전트 파일**: +``` +.claude/agents/explorer.md +.claude/agents/implementer.md +.claude/agents/reviewer.md +``` + +**스킬 파일 (6종)**: +``` +.claude/skills/push/SKILL.md +.claude/skills/mr/SKILL.md +.claude/skills/create-mr/SKILL.md +.claude/skills/release/SKILL.md +.claude/skills/version/SKILL.md +.claude/skills/fix-issue/SKILL.md +``` + +**Hook 스크립트**: +``` +.claude/scripts/on-pre-compact.sh +.claude/scripts/on-post-compact.sh +.claude/scripts/on-commit.sh +``` + +**Git Hooks** (commit-msg, post-checkout은 항상 교체): +``` +.githooks/commit-msg +.githooks/post-checkout +``` + +다운로드 예시: +```bash +curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md" +``` + +#### 3-2. template-{type} 파일 (타입별 덮어쓰기) + +``` +.claude/rules/code-style.md +.claude/rules/naming.md +.claude/rules/testing.md +``` + +**pre-commit hook**: +`.claude/workflow-version.json`의 `custom_pre_commit` 플래그 확인: +- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그 +- 플래그 없거나 false → `.githooks/pre-commit` 교체 + +다운로드 예시: +```bash +curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md" +``` + +#### 3-3. 실행 권한 부여 +```bash +chmod +x .githooks/* 2>/dev/null +chmod +x .claude/scripts/*.sh 2>/dev/null +``` + +### 4. settings.json 부분 머지 + +⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음): +```bash +SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json") +``` + +다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신: +- `env`: 서버 최신으로 교체 +- `deny` 목록: 서버 최신으로 교체 +- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합 +- `hooks`: 서버 최신으로 교체 + +### 5. workflow-version.json 갱신 + +서버의 최신 `workflow-version.json` 조회: +```bash +SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json") +SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))") +``` + +`.claude/workflow-version.json` 업데이트: +```json +{ + "applied_global_version": "<서버 version>", + "applied_date": "<현재날짜>", + "project_type": "<프로젝트타입>", + "gitea_url": "" +} +``` +기존 필드(`custom_pre_commit` 등)는 보존. + +### 6. 변경 보고 + +- 다운로드/갱신된 파일 목록 출력 +- 서버 `workflow-version.json`의 `changes` 중 최신 항목 표시 +- 결과 형태: +``` +✅ 팀 워크플로우 동기화 완료 + 버전: v1.6.0 + 갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3) + settings.json: 부분 갱신 (env, deny, hooks) +``` + +## 필요 환경변수 + +없음 (Gitea raw URL은 인증 불필요) diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json new file mode 100644 index 0000000..5d39e62 --- /dev/null +++ b/.claude/workflow-version.json @@ -0,0 +1,6 @@ +{ + "applied_global_version": "1.6.1", + "applied_date": "2026-03-31", + "project_type": "react-ts", + "custom_pre_commit": true +} diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100755 index 0000000..93bb350 --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,60 @@ +#!/bin/bash +#============================================================================== +# commit-msg hook +# Conventional Commits 형식 검증 (한/영 혼용 지원) +#============================================================================== + +COMMIT_MSG_FILE="$1" +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Merge 커밋은 검증 건너뜀 +if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then + exit 0 +fi + +# Revert 커밋은 검증 건너뜀 +if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then + exit 0 +fi + +# Conventional Commits 정규식 +# type(scope): subject +# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수) +# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택) +# - subject: 1~72자, 한/영 혼용 허용 (필수) +PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$' + +FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") + +if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then + echo "" + echo "╔══════════════════════════════════════════════════════════════╗" + echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║" + echo "╚══════════════════════════════════════════════════════════════╝" + echo "" + echo " 올바른 형식: type(scope): subject" + echo "" + echo " type (필수):" + echo " feat — 새로운 기능" + echo " fix — 버그 수정" + echo " docs — 문서 변경" + echo " style — 코드 포맷팅" + echo " refactor — 리팩토링" + echo " test — 테스트" + echo " chore — 빌드/설정 변경" + echo " ci — CI/CD 변경" + echo " perf — 성능 개선" + echo "" + echo " scope (선택): 한/영 모두 가능" + echo " subject (필수): 1~72자, 한/영 모두 가능" + echo "" + echo " 예시:" + echo " feat(auth): JWT 기반 로그인 구현" + echo " fix(배치): 야간 배치 타임아웃 수정" + echo " docs: README 업데이트" + echo " chore: Gradle 의존성 업데이트" + echo "" + echo " 현재 메시지: $FIRST_LINE" + echo "" + exit 1 +fi diff --git a/.githooks/post-checkout b/.githooks/post-checkout new file mode 100755 index 0000000..bae360f --- /dev/null +++ b/.githooks/post-checkout @@ -0,0 +1,25 @@ +#!/bin/bash +#============================================================================== +# post-checkout hook +# 브랜치 체크아웃 시 core.hooksPath 자동 설정 +# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정 +#============================================================================== + +# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag +# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃 +BRANCH_FLAG="$3" + +# 파일 체크아웃은 건너뜀 +if [ "$BRANCH_FLAG" = "0" ]; then + exit 0 +fi + +# .githooks 디렉토리 존재 확인 +REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null) +if [ -d "${REPO_ROOT}/.githooks" ]; then + CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "") + if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then + git config core.hooksPath .githooks + chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null + fi +fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24f0334 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +# === Build === +dist/ +build/ +*.jar + +# === Dependencies === +node_modules/ +.gradle/ + +# === IDE === +.idea/ +.vscode/ +*.swp +*.swo + +# === OS === +.DS_Store +Thumbs.db + +# === Environment === +.env +.env.* +!.env.example +secrets/ + +# === Test === +coverage/ + +# === Cache === +.eslintcache +.prettiercache +*.tsbuildinfo + +# === Code Review Graph (로컬 전용) === +.code-review-graph/ + +# === Claude Code === +!.claude/ +.claude/settings.local.json +.claude/CLAUDE.local.md + +# === Team workflow (managed by /sync-team-workflow) === +.claude/rules/ +.claude/agents/ +.claude/skills/push/ +.claude/skills/mr/ +.claude/skills/create-mr/ +.claude/skills/release/ +.claude/skills/version/ +.claude/skills/fix-issue/ +.claude/scripts/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..91c4d07 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,738 @@ +# WING-GIS Architecture Design +## 해양경찰청 통합 GIS 위치정보시스템 + +--- + +## 1. System Overview + +``` + WING-GIS System Architecture + ============================ + + [Browser / ECDIS / Mobile] + | + v + +-----------------+ +------------------+ +------------------+ + | React 19 SPA | | Nginx / Gateway | | Keycloak SSO | + | MapLibre+deck.gl|<--->| (API Gateway) |<--->| (INR-04 Auth) | + | (wing-gis-web) | | | | | + +-----------------+ +------------------+ +------------------+ + | + +--------------+--------------+ + | | | + +-----v----+ +-----v----+ +------v-----+ + | GIS API | | Vessel | | Analysis | + | Service | | Signal | | Service | + | (SFR-01~ | | Service | | (SFR-09) | + | SFR-04) | | (SFR-05~ | +------+-----+ + +-----+----+ | SFR-08) | | + | +-----+----+ | + | | | + +-----v--------------v--------------v-----+ + | PostgreSQL + PostGIS | + | (wing_gis_db / wing_vessel_db) | + +-----------------------------------------+ + | | + +-----v----+ +-----v-----------+ + | Redis | | Kafka / RabbitMQ| + | (Cache) | | (Signal Stream) | + +----------+ +-----------------+ +``` + +--- + +## 2. Technology Stack + +### Frontend (구현 완료) +| Category | Technology | Version | Purpose | +|----------|-----------|---------|---------| +| Framework | React | 19.x | SPA UI | +| Language | TypeScript | 5.9 | Type safety | +| Map Engine | **MapLibre GL JS** | 5.18 | ENC 전자해도 렌더링 | +| Map Overlay | **deck.gl (MapboxOverlay)** | 9.2 | 선박 아이콘 WebGL 렌더링 | +| State | Zustand | 5.x | 경량 상태 관리 | +| UI Library | Ant Design | 6.x | 해경 UI 컴포넌트 (일부 사용) | +| API Client | Axios + fetch | - | 서버 통신 | +| WebSocket | STOMP.js | 7.x | 실시간 물표 수신 (예정) | +| Build | Vite | 8.x | 빌드 도구 | +| Chart | ECharts | 6.x | 통계/분석 차트 (예정) | +| ENC Tiles | gcnautical.com | - | S-57 전자해도 벡터 타일 | + +### Backend +| Category | Technology | Version | Purpose | +|----------|-----------|---------|---------| +| Framework | Spring Boot | 3.3.x | MSA 서비스 | +| Language | Java | 21 (LTS) | 백엔드 로직 | +| ORM | JPA/Hibernate + QueryDSL | - | DB 접근 | +| GIS | GeoTools | 31.x | S-57/S-101 파싱 | +| API Docs | SpringDoc (OpenAPI 3) | - | INR-05 API 명세 | +| Security | Spring Security + Keycloak | - | SSO/GPKI (INR-04) | +| Message | Kafka / RabbitMQ | - | 물표 스트리밍 | +| Cache | Redis | 7.x | 물표 캐시 | +| Container | Docker + K8s | - | ECR-01 MSA 인프라 | + +### Database +| Category | Technology | Purpose | +|----------|-----------|---------| +| Main DB | PostgreSQL 16 + PostGIS 3.4 | 공간데이터 저장 | +| Tile Cache | pg_tileserv / Martin | 벡터 타일 서빙 | +| Time Series | TimescaleDB (확장) | 물표 이력/항적 | +| File Storage | MinIO (S3 호환) | SHP/GeoJSON 업로드 | + +--- + +## 3. MSA Service Decomposition (ECR-01) + +``` +wing-gis/ +├── services/ +│ ├── wing-gis-gateway/ # API Gateway (Spring Cloud Gateway) +│ ├── wing-gis-auth/ # 인증/권한 (INR-04: SSO, GPKI) +│ ├── wing-gis-map/ # 통합 GIS 서비스 (SFR-01~04) +│ ├── wing-gis-vessel/ # 물표/선박 신호 (SFR-05~06, SFR-08) +│ ├── wing-gis-layer/ # 레이어 관리 (SFR-07) +│ ├── wing-gis-analysis/ # 분석 서비스 (SFR-09) +│ ├── wing-gis-integration/ # 통합연계모듈 (SFR-06, SFR-10) +│ ├── wing-gis-admin/ # 관리도구 +│ └── wing-gis-mcp/ # MCP 에이전트 (INR-06: LLM 연계) +├── frontend/ +│ └── wing-gis-web/ # React SPA +├── infra/ +│ ├── docker-compose.yml # 개발환경 +│ ├── k8s/ # 운영환경 K8s 매니페스트 +│ └── sql/ # DB 초기화 스크립트 +└── docs/ + └── api/ # API 명세서 +``` + +--- + +## 4. Frontend Structure (React) — 현재 구현 상태 + +``` +wing-gis-web/src/ +├── App.tsx # 루트 (레이아웃 + useAisPolling) +├── main.tsx # 엔트리 포인트 +├── App.css # 디자인 토큰 (CSS 변수) +│ +├── components/ +│ ├── layout/ # ✅ 5-패널 레이아웃 +│ │ ├── TitleBar.tsx # 상단 헤더 +│ │ ├── SubToolbar.tsx # 컨텍스트별 도구 모음 +│ │ ├── Sidebar.tsx # 좌측 (검색, 레이어 트리) +│ │ ├── RightPanel.tsx # 우측 (물표현황 실데이터/경보/해양정보) +│ │ └── BottomBar.tsx # 하단 상태바 +│ ├── map/ # ✅ MapLibre GL JS + deck.gl 기반 +│ │ ├── MapViewML.tsx # 맵 컨테이너 + deck.gl 콜백 (툴팁/클릭) +│ │ ├── MapTools.tsx # 확대/축소/초기화 +│ │ ├── BaseMapSelector.tsx # 배경지도 탭 +│ │ ├── ScaleBar.tsx # 축척바 +│ │ ├── MapLegend.tsx # 물표 범례 (실시간 AIS 카운트) +│ │ └── CoordStatusBar.tsx # 좌표/줌 상태바 +│ └── vessel/ # ✅ 선박 UI +│ ├── VesselPopup.tsx # 선박 미니 팝업 +│ ├── VesselSearch.tsx # 선박 통합 검색 (SFR-08) +│ └── VesselSignalToggle.tsx # 신호 소스 ON/OFF 토글 +│ +├── features/ +│ ├── vesselLayer/ # ✅ AIS 실시간 + 비AIS 더미 렌더링 +│ │ ├── hooks/ +│ │ │ └── useVesselDeckLayer.ts # AIS+비AIS deck.gl 레이어 통합 +│ │ └── lib/ +│ │ ├── aisAdapter.ts # AisTarget → AisFeature 변환 +│ │ ├── aisDeckLayers.ts # AIS IconLayer + halo + overlay +│ │ ├── aisIcons.ts # SignalKindCode별 SVG 아이콘 8종 +│ │ ├── shipTooltip.ts # 호버 툴팁 HTML (사진 썸네일 포함) +│ │ ├── ShipBatchRenderer.ts # 뷰포트 컬링 + 밀도 제어 (제네릭) +│ │ ├── vesselAdapter.ts # 비AIS Vessel → VesselFeature +│ │ ├── vesselDeckLayers.ts # 비AIS deck.gl 레이어 +│ │ └── vesselIcons.ts # 비AIS VesselSource별 아이콘 +│ ├── nauticalChart/ # ✅ ENC 전자해도 오버레이 +│ │ ├── hooks/useNauticalChartOverlay.ts +│ │ ├── lib/fetchNauticalStyle.ts # gcnautical.com 스타일 fetch +│ │ ├── lib/nauticalLayerManager.ts # 레이어 주입/제거/S-52 테마 +│ │ ├── model/types.ts # S52Theme, NauticalChartSettings +│ │ └── ui/NauticalChartToggle.tsx # ENC on/off + 주간/황혼/야간 +│ └── shipImage/ # ✅ 선박 사진 (호버 썸네일 + 클릭 모달) +│ ├── api/shipImageApi.ts # 이미지 API + URL 유틸 +│ └── ui/ShipImageModal.tsx # 사진 뷰어 모달 (네비게이션+캐러셀) +│ +├── hooks/ +│ ├── useMap.ts # ✅ MapLibre + MapboxOverlay 초기화 +│ ├── useDeckLayers.ts # ✅ deck.gl 레이어 + getTooltip/onClick +│ ├── useStore.ts # ✅ Zustand (vessels, aisTargets, signalState...) +│ ├── useAisPolling.ts # ✅ AIS REST 폴링 (60분→1분→2시간 프루닝) +│ └── useVesselStream.ts # ⏳ STOMP WebSocket (백엔드 연동 시) +│ +├── services/ +│ ├── aisApi.ts # ✅ /signal-batch/api/v2/vessels/recent-positions +│ ├── api.ts # Axios 인스턴스 +│ └── vesselApi.ts # 물표 CRUD API +│ +├── types/ +│ ├── ais.ts # ✅ SignalKindCode 8종, AisTarget, DTO, 색상맵 +│ ├── vessel.ts # Vessel, VesselSource 7종 +│ └── layer.ts # LayerGroup, S100_PRODUCTS +│ +├── utils/ +│ ├── coordinate.ts # DMS 변환, 축척 계산 +│ ├── s52Colors.ts # S-52 수심 색상 팔레트 +│ └── vesselIcon.ts # 범용 SVG 아이콘 생성 +│ +├── lib/map/ +│ ├── mapCore.ts # kickRepaint, onMapStyleReady +│ ├── mapConstants.ts # 기본 좌표, 줌, OSM 폴백 스타일 +│ └── MaplibreDeckCustomLayer.ts # Globe 모드용 커스텀 레이어 (예비) +│ +├── context/MapContext.tsx # mapRef, overlayRef 전달 +└── data/mockVessels.ts # 비AIS 더미 데이터 +``` + +### 구현 상태 범례 +- ✅ 구현 완료 (실동작) +- ⏳ 코드 준비됨, 백엔드 연동 대기 + +### 미구현 (향후 개발) +- `components/layer/` — LayerTree, LayerUpload, LayerStyle (SLD 편집) +- `components/analysis/` — AnalysisTabs, BufferAnalysis, HeatmapAnalysis +- `components/admin/` — UserManage, IntegrationStatus, AuditLog +- `components/common/` — LoginModal (SSO/GPKI), NotificationBell +- `hooks/useAuth.ts`, `hooks/useLayer.ts` +- `services/layerApi.ts`, `services/analysisApi.ts`, `services/authApi.ts` +- `types/s100.ts`, `types/auth.ts` +- React Router 라우팅 + +--- + +## 5. Backend Structure (Spring Boot) + +``` +wing-gis-map/ # 대표 서비스 구조 +├── src/main/java/kr/go/kcg/wingis/ +│ ├── WingGisMapApplication.java +│ ├── config/ +│ │ ├── SecurityConfig.java +│ │ ├── CorsConfig.java +│ │ ├── WebSocketConfig.java +│ │ └── PostgisConfig.java +│ ├── domain/ +│ │ ├── vessel/ +│ │ │ ├── Vessel.java # Entity +│ │ │ ├── VesselTrack.java # 항적 Entity +│ │ │ ├── VesselRepository.java +│ │ │ ├── VesselService.java +│ │ │ └── VesselController.java +│ │ ├── layer/ +│ │ │ ├── Layer.java +│ │ │ ├── LayerGroup.java +│ │ │ ├── S100Layer.java # S-100 전용 +│ │ │ ├── LayerRepository.java +│ │ │ ├── LayerService.java +│ │ │ └── LayerController.java +│ │ ├── chart/ +│ │ │ ├── ChartDataset.java # S-101 ENC 데이터셋 +│ │ │ ├── ChartFeature.java # 해도 피처 +│ │ │ └── ChartService.java +│ │ ├── boundary/ +│ │ │ ├── Boundary.java # EEZ/NLL/관할구역 +│ │ │ └── BoundaryService.java +│ │ ├── analysis/ +│ │ │ ├── AnalysisResult.java +│ │ │ ├── AnalysisService.java +│ │ │ └── AnalysisController.java +│ │ └── user/ +│ │ ├── User.java +│ │ └── UserService.java +│ ├── integration/ # SFR-06 통합연계모듈 +│ │ ├── ais/ +│ │ │ ├── AisReceiver.java # AIS 수신기 +│ │ │ └── AisDecoder.java # NMEA 디코더 +│ │ ├── vpass/ +│ │ │ └── VPassClient.java +│ │ ├── vts/ +│ │ │ └── VtsRadarClient.java +│ │ ├── lrit/ +│ │ │ └── LritClient.java +│ │ └── DataFusionEngine.java # 물표 융합 엔진 +│ ├── dto/ +│ │ ├── VesselDto.java +│ │ ├── LayerDto.java +│ │ └── SearchDto.java +│ └── exception/ +│ └── GlobalExceptionHandler.java +├── src/main/resources/ +│ ├── application.yml +│ ├── application-dev.yml +│ └── application-prod.yml +├── build.gradle +└── Dockerfile +``` + +--- + +## 6. Database Schema (PostgreSQL + PostGIS) + +### Core Tables + +```sql +-- 물표/선박 +CREATE TABLE vessel ( + id BIGSERIAL PRIMARY KEY, + mmsi VARCHAR(20) UNIQUE, + imo VARCHAR(20), + name VARCHAR(100), + callsign VARCHAR(20), + ship_type VARCHAR(50), + flag VARCHAR(5), + gt DECIMAL, + dwt DECIMAL, + loa DECIMAL, + beam DECIMAL, + draft DECIMAL, + status VARCHAR(30), + source VARCHAR(20), -- AIS/V-Pass/VTS/RADAR/E-NAV/VHF-DSC + position GEOMETRY(Point, 4326), + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + nav_status VARCHAR(30), + destination VARCHAR(100), + eta TIMESTAMP, + last_updated TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_vessel_position ON vessel USING GIST(position); +CREATE INDEX idx_vessel_mmsi ON vessel(mmsi); +CREATE INDEX idx_vessel_source ON vessel(source); + +-- 항적 (TimescaleDB hypertable 권장) +CREATE TABLE vessel_track ( + id BIGSERIAL, + vessel_id BIGINT REFERENCES vessel(id), + position GEOMETRY(Point, 4326), + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + recorded_at TIMESTAMP NOT NULL, + PRIMARY KEY (id, recorded_at) +); + +CREATE INDEX idx_track_vessel ON vessel_track(vessel_id, recorded_at DESC); +CREATE INDEX idx_track_position ON vessel_track USING GIST(position); + +-- 레이어 +CREATE TABLE layer_group ( + id SERIAL PRIMARY KEY, + name VARCHAR(100), + parent_id INTEGER REFERENCES layer_group(id), + sort_order INTEGER DEFAULT 0, + icon VARCHAR(10), + is_system BOOLEAN DEFAULT FALSE +); + +CREATE TABLE layer ( + id BIGSERIAL PRIMARY KEY, + group_id INTEGER REFERENCES layer_group(id), + name VARCHAR(200), + layer_type VARCHAR(30), -- VECTOR/RASTER/WMS/S100 + s100_product VARCHAR(10), -- S-101/S-102/S-104/S-111/S-122/S-124/S-127/S-412 + geometry_type VARCHAR(20), -- POINT/LINE/POLYGON + srid INTEGER DEFAULT 4326, + style_sld TEXT, + min_scale INTEGER, + max_scale INTEGER, + is_visible BOOLEAN DEFAULT TRUE, + is_default_on BOOLEAN DEFAULT FALSE, + feature_count INTEGER DEFAULT 0, + source_url VARCHAR(500), + created_by BIGINT, + created_at TIMESTAMP DEFAULT NOW() +); + +-- S-100 전자해도 데이터셋 +CREATE TABLE chart_dataset ( + id BIGSERIAL PRIMARY KEY, + cell_name VARCHAR(20), -- KR4G3A40 + product VARCHAR(10), -- S-101, S-102, ... + edition INTEGER, + update_num INTEGER, + scale INTEGER, + status VARCHAR(20), -- CURRENT/SUPERSEDED/NEW + coverage GEOMETRY(Polygon, 4326), + fc_version VARCHAR(20), + pc_version VARCHAR(20), + feature_count INTEGER, + file_path VARCHAR(500), + issued_date DATE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_chart_coverage ON chart_dataset USING GIST(coverage); + +-- 해도 피처 +CREATE TABLE chart_feature ( + id BIGSERIAL PRIMARY KEY, + dataset_id BIGINT REFERENCES chart_dataset(id), + feature_type VARCHAR(50), -- Lighthouse/DepthArea/Buoy/... + feature_name_ko VARCHAR(200), + feature_name_en VARCHAR(200), + geometry GEOMETRY(Geometry, 4326), + attributes JSONB, -- S-101 속성 (유연한 구조) + scamin INTEGER, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_feature_geom ON chart_feature USING GIST(geometry); +CREATE INDEX idx_feature_type ON chart_feature(feature_type); +CREATE INDEX idx_feature_attrs ON chart_feature USING GIN(attributes); + +-- 경계/관할구역 +CREATE TABLE boundary ( + id SERIAL PRIMARY KEY, + name VARCHAR(100), + boundary_type VARCHAR(30), -- EEZ/NLL/TERRITORIAL/JURISDICTION + geometry GEOMETRY(MultiPolygon, 4326), + organization VARCHAR(100), + attributes JSONB +); + +CREATE INDEX idx_boundary_geom ON boundary USING GIST(geometry); + +-- 분석 결과 +CREATE TABLE analysis_result ( + id BIGSERIAL PRIMARY KEY, + analysis_type VARCHAR(50), -- ILLEGAL_FISHING/TRANSSHIP/DARK_VESSEL/... + name VARCHAR(200), + geometry GEOMETRY(Geometry, 4326), + result_data JSONB, + confidence DECIMAL, + status VARCHAR(20), + created_by BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP +); + +-- 사용자 +CREATE TABLE app_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + name VARCHAR(50), + department VARCHAR(100), + organization VARCHAR(100), -- 지방청/해양경찰서 + role VARCHAR(30), -- ADMIN/OPERATOR/VIEWER + jurisdiction_id INTEGER REFERENCES boundary(id), + sso_id VARCHAR(100), + gpki_dn VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 경보/알림 +CREATE TABLE alert ( + id BIGSERIAL PRIMARY KEY, + alert_type VARCHAR(30), -- SOS/ZONE_VIOLATION/SIGNAL_LOST/... + severity VARCHAR(10), -- CRITICAL/WARNING/INFO + title VARCHAR(200), + description TEXT, + vessel_id BIGINT REFERENCES vessel(id), + position GEOMETRY(Point, 4326), + is_acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_by BIGINT REFERENCES app_user(id), + created_at TIMESTAMP DEFAULT NOW() +); + +-- 감사 로그 +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES app_user(id), + action VARCHAR(50), + target_type VARCHAR(50), + target_id VARCHAR(50), + detail JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMP DEFAULT NOW() +); +``` + +--- + +## 7. API Endpoints (RESTful) + +### Vessel API (SFR-05, SFR-08) +``` +GET /api/v1/vessels # 물표 목록 (필터/페이징) +GET /api/v1/vessels/{mmsi} # 물표 상세 +GET /api/v1/vessels/{mmsi}/tracks # 항적 조회 +GET /api/v1/vessels/search?q= # 통합 검색 (SFR-08) +GET /api/v1/vessels/bbox?bbox= # 영역 내 물표 +WS /ws/vessels # 실시간 물표 스트림 +``` + +### Layer API (SFR-03, SFR-07) +``` +GET /api/v1/layers # 레이어 트리 +POST /api/v1/layers/upload # 레이어 업로드 +PUT /api/v1/layers/{id}/style # SLD 스타일 변경 +GET /api/v1/layers/{id}/features # 피처 조회 +DELETE /api/v1/layers/{id} # 레이어 삭제 +``` + +### Chart API (SFR-04) +``` +GET /api/v1/charts # S-100 데이터셋 목록 +GET /api/v1/charts/{cellName}/features # 해도 피처 +GET /api/v1/charts/tile/{z}/{x}/{y} # 벡터 타일 +``` + +### Analysis API (SFR-09) +``` +POST /api/v1/analysis/buffer # 버퍼 분석 +POST /api/v1/analysis/heatmap # 히트맵 +POST /api/v1/analysis/upload # 결과 업로드 +GET /api/v1/analysis/results # 결과 목록 +``` + +### Auth API (INR-04) +``` +POST /api/v1/auth/login # 로그인 +POST /api/v1/auth/sso # SSO 인증 +POST /api/v1/auth/gpki # GPKI 인증 +GET /api/v1/auth/me # 현재 사용자 +POST /api/v1/auth/logout # 로그아웃 +``` + +--- + +## 8. S-100 Product Specification Support Matrix + +### 8.1 Specification Inventory + +본 사업에서 지원하는 IHO S-100 기반 제품 규격과 보유 문서 현황: + +| S-100 Product | Version | Full Name | PS | FC | PC | DCEG | Validation | Sample | +|---------------|---------|-----------|:--:|:--:|:--:|:----:|:----------:|:------:| +| **S-100** | Ed.5.2.1 (Dec 2025) | Universal Hydrographic Data Model | PDF | - | Lua/XSLT | - | S-158-100 v1.0.0 | - | +| **S-101** | Ed.2.0.0 | Electronic Navigational Chart (ENC) | PDF | XML 2.0.0 | ZIP 2.0.0 | PDF 2.0.0 | S-158-101 v1.1.0 | - | +| **S-102** | Ed.3.0.0 | Bathymetric Surface | PDF | XML 3.0.0 | ZIP 3.0.0 | - | S-158-102 v1.0.0 | - | +| **S-104** | Ed.2.0.0 | Water Level Information | PDF | XML 2.0.0 | - | - | - | - | +| **S-111** | Ed.2.0.0 | Surface Currents | PDF | XML 2.0.0 | ZIP 2.0.0 | - | - | - | +| **S-122** | Ed.1.0.0 | Marine Protected Areas (MPA) | ZIP | XML 1.0.0 | - | - | - | ZIP | +| **S-123** | Ed.1.0.0 | Marine Radio Services (MRS) | ZIP | XML 1.0.0 | - | - | - | ZIP | +| **S-124** | Ed.2.0.0 | Navigational Warnings | PDF | XML 2.0.0 | ZIP 2.0.0 | PDF 2.0.0 | - | - | +| **S-127** | Ed.1.0.0 | Marine Traffic Management | PDF | ZIP 1.0.0 | - | - | - | ZIP | + +> 참조 경로: `/Documents/GIS/S-100 Specifications/{S-1xx}/` + +### 8.2 Service-to-Product Mapping + +각 백엔드 서비스가 담당하는 S-100 제품과 구체적 활용 범위: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ wing-gis-map (GIS API) │ +│ │ +│ S-101 ENC Ed.2.0.0 ─── 전자해도 렌더링, 피처 조회, Edition 관리 │ +│ ├─ FC 2.0.0 ── FeatureType 정의 (DepthArea, Lighthouse, Buoy...) │ +│ ├─ PC 2.0.0 ── S-52 Portrayal 렌더링 규칙 │ +│ ├─ DCEG 2.0.0 ── 피처 속성 검증 (Data Capture Encoding Guide) │ +│ └─ S-158-101 ── 데이터 품질 검증 (Validation Checks) │ +│ │ +│ S-102 Bathymetric Ed.3.0.0 ─── 수심 격자 데이터 표출 │ +│ ├─ FC 3.0.0 ── BAG 수심 피처 정의 │ +│ └─ PC 3.0.0 ── 수심 음영/등심선 색상 팔레트 │ +│ │ +│ S-104 Water Level Ed.2.0.0 ─── 실시간 조석/수위 오버레이 │ +│ └─ FC 2.0.0 ── 조석 관측점/예측 격자 피처 정의 │ +│ │ +│ S-111 Surface Currents Ed.2.0.0 ─── 해류 벡터/격자 오버레이 │ +│ ├─ FC 2.0.0 ── 해류 벡터 피처 정의 │ +│ └─ PC 2.0.0 ── 해류 화살표/색상 렌더링 규칙 │ +│ │ +│ S-122 MPA Ed.1.0.0 ─── 해양보호구역 경계 표출 │ +│ └─ FC 1.0.0 ── 보호구역 등급/경계 피처 정의 │ +│ │ +│ S-123 MRS Ed.1.0.0 ─── 해양무선 커버리지 표출 │ +│ └─ FC 1.0.0 ── VHF/MF/HF 무선 범위 피처 정의 │ +│ │ +│ S-124 Nav Warnings Ed.2.0.0 ─── 항행경보 구역 실시간 표출 │ +│ ├─ FC 2.0.0 ── 경보 구역/유형 피처 정의 │ +│ ├─ PC 2.0.0 ── 경보 심볼/색상 렌더링 │ +│ └─ DCEG 2.0.0 ── 경보 데이터 입력 검증 │ +│ │ +│ S-127 MTM Ed.1.0.0 ─── TSS/VTS 구역 표출 │ +│ └─ FC 1.0.0 ── 통항분리대/VTS 관할 피처 정의 │ +│ │ +│ S-100 Ed.5.2.1 ─── 공통 데이터 모델 / Exchange Set / 인코딩 │ +│ ├─ Part 5 (FC) ── Feature Catalogue 구조 파싱 │ +│ ├─ Part 9/9a (Portrayal/Lua) ── 렌더링 엔진 │ +│ ├─ Part 10a/10b/10c ── 인코딩 (8211/GML/HDF5) │ +│ ├─ Part 15 ── 암호화/서명 (Encryption optional, Signing mandatory) │ +│ ├─ Part 16/16a ── Interoperability / Harmonised Portrayal │ +│ └─ Part 17 ── Discovery Metadata (Exchange Catalogue) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ wing-gis-vessel (Vessel Signal) │ +│ │ +│ S-124 ─── 조난 경보 연동 (VHF-DSC → S-124 Nav Warning 자동 생성) │ +│ S-127 ─── VTS 관제 구역 내 선박 연계 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ wing-gis-integration (통합연계모듈) │ +│ │ +│ S-100 Part 10b (GML) ─── 물표 데이터 GML 인코딩 변환 │ +│ S-100 Part 14 ─── Online Communication Exchange 프로토콜 │ +│ S-100 Part 17 ─── Exchange Catalogue 메타데이터 관리 │ +│ S-100 Part 15 ─── 데이터 서명/검증 (Digital Signature) │ +├─────────────────────────────────────────────────────────────────────────┤ +│ wing-gis-analysis (분석 서비스) │ +│ │ +│ S-102 ─── 수심 격자 기반 해저 지형 분석 │ +│ S-104 ─── 조석 데이터 기반 수위 예측 분석 │ +│ S-111 ─── 해류 데이터 기반 표류 예측 분석 │ +├─────────────────────────────────────────────────────────────────────────┤ +│ wing-gis-layer (레이어 관리) │ +│ │ +│ S-100 Part 5 ─── Feature Catalogue XML 파싱/관리 │ +│ S-100 Part 9 ─── Portrayal Catalogue 관리 (SLD 변환) │ +│ S-100 Part 11 ─── Product Specification 메타 관리 │ +│ S-158 ─── Validation Checks 실행 엔진 │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 8.3 Feature Catalogue Integration + +FC(Feature Catalogue) XML을 파싱하여 DB에 자동 로드하는 구조: + +``` +S-100 Specifications/S-101/101_Feature_Catalogue_2.0.0.xml + │ + ▼ + ┌──────────────┐ ┌──────────────────────────────┐ + │ FC Parser │────▶│ chart_feature_type (DB) │ + │ (GeoTools/ │ │ ├─ feature_type: Lighthouse │ + │ JAXB) │ │ ├─ attributes: JSONB │ + │ │ │ ├─ geometry_type: Point │ + └──────────────┘ │ └─ product: S-101 │ + └──────────────────────────────┘ +``` + +| FC File | Product | Feature Types | DB Table | +|---------|---------|---------------|----------| +| `101_FC_2.0.0.xml` | S-101 | DepthArea, DepthContour, Lighthouse, Buoy, Rock, Wreck, FairwaySystem, AnchorageArea, RestrictedArea, Coastline, LandArea, ... (~180 types) | `chart_feature` | +| `102_FC_3.0.0.xml` | S-102 | BathymetricDataset, TrackingList, QualityOfBathymetricData | `chart_feature` | +| `104_FC_2.0.0.xml` | S-104 | WaterLevelTrend, WaterLevelTimeSeries, TidalStation | `chart_feature` | +| `111_FC_2.0.0.xml` | S-111 | SurfaceCurrentDataset, SurfaceCurrentTimeSeries | `chart_feature` | +| `122_FC_1.0.0.xml` | S-122 | MarineProtectedArea | `boundary` | +| `123_FC_1.0.0.xml` | S-123 | RadioServiceArea, RadioStation | `chart_feature` | +| `124_FC_2.0.0.xml` | S-124 | NavigationalWarningPart, NavigationalWarningFeaturePart | `alert` / `chart_feature` | +| `S-127_FC_1.0.0` | S-127 | TrafficSeparationScheme, VTSArea, ReportingPoint | `boundary` | + +### 8.4 Portrayal Catalogue Integration + +PC(Portrayal Catalogue)를 활용한 S-52 기반 렌더링 규칙: + +| PC File | Product | 렌더링 대상 | Frontend 적용 | +|---------|---------|-----------|-------------| +| `101_PC_2.0.0` | S-101 | 해도 심볼/색상/선형 | OpenLayers Style + Lua 엔진 | +| `102_PC_3.0.0` | S-102 | 수심 음영/등심선 | Raster 타일 색상 팔레트 | +| `111_PC_2.0.0` | S-111 | 해류 벡터 화살표 | SVG 벡터 오버레이 | +| `124_PC_2.0.0` | S-124 | 항행경보 구역 심볼 | 경보 폴리곤 스타일 | +| S-100 BPC (Lua) | 공통 | Lua 기반 Portrayal 스크립팅 | Part 9a Lua 인터프리터 | + +### 8.5 Validation Integration (S-158) + +S-158 기반 데이터 품질 검증: + +| Validation File | 대상 | 검증 내용 | 서비스 | +|----------------|------|---------|--------| +| `S-158-100_v1.0.0` | S-100 공통 | Exchange Set 구조, 메타데이터, 인코딩 규칙 | wing-gis-integration | +| `S-158-101_v1.1.0` | S-101 ENC | 피처 속성 완전성, 토폴로지, SCAMIN, 좌표 정밀도 | wing-gis-map | +| `S-158-102_v1.0.0` | S-102 수심 | BAG 격자 무결성, 수심 범위 검증 | wing-gis-map | + +### 8.6 Data Encoding Support (S-100 Part 10) + +| Encoding | S-100 Part | 지원 제품 | 라이브러리 | +|----------|-----------|---------|-----------| +| ISO/IEC 8211 | Part 10a | S-101 (레거시 S-57 호환) | GeoTools S-57 Driver | +| GML 3.2 | Part 10b | S-122, S-123, S-124, S-127 | JAXB + GML Parser | +| HDF5 | Part 10c | S-102, S-104, S-111 (격자 데이터) | JHdf5 / HDF5 Java | + +### 8.7 S-100 Ed.5.2.1 Key Changes Applied + +본 시스템에 반영된 S-100 Ed.5.2.1 (December 2025) 주요 변경사항: + +| Part | 변경 내용 | 시스템 반영 | +|------|---------|-----------| +| Part 1 | URI Derived Type에 fileURI 추가, MRN 형식 변경 | 메타데이터 URI 처리 | +| Part 5 | FC InformationType/FeatureType 상속 관계 정정 | FC XML 파서 | +| Part 10b | GML 파일 확장자 .GML 필수, xlink:title 명시 | GML 인코딩 모듈 | +| Part 10c | HDF5 featureAttributeTableKey 컬럼명 변경, timePoint optional | HDF5 리더 | +| Part 11 | Product Specification 참조를 IHO GI Registry로 변경 | PS 메타 관리 | +| Part 14 | ISO/IEC 80000-13:2025 추가 | 온라인 교환 | +| **Part 15** | **암호화 optional, 서명 mandatory**, schemeAdministrator [1] 필수 | **S-63 Encryptor** | +| Part 16a | Harmonised Portrayal URL 변경 | 렌더링 엔진 | +| Part 17 | editionNumber → PositiveInteger, boundingPolygon 좌표 순서 CRS 기준 | Exchange Catalogue | +| Part 18 | URL 정규화 참조 변경 | 다국어 지원 | + +--- + +## 9. RFP Requirement Mapping (80 Requirements) + +| RFP ID | 요구사항 | 서비스 | 상태 | 비고 | +|--------|---------|--------|:----:|------| +| SFR-01 | 통합 GIS 서비스 | wing-gis-map + MapViewML | ✅ | MapLibre GL JS | +| SFR-02 | 서비스 제공 방식 | React SPA (WEB) | ✅ | Vite 8 | +| SFR-03 | 전자해도+공간정보 통합조회 | nauticalChart 피처 | ✅ | gcnautical.com ENC | +| SFR-04 | S-100 차세대 전자해도 | fetchEncStyle + S-52 테마 | ✅ | 주간/황혼/야간 | +| SFR-05 | 물표정보 연계+융합 | useAisPolling + deck.gl | ✅ | AIS 실시간, 비AIS 더미 | +| SFR-06 | 통합연계모듈 | wing-gis-integration | ⏳ | Kafka 스트림 | +| SFR-07 | 관할 기반+사용자 레이어 | wing-gis-layer | ⏳ | 업로드/SLD | +| SFR-08 | 선박 통합 검색 | VesselSearch | ⚠️ | UI 구현, 백엔드 미연동 | +| SFR-09 | 외부 분석결과 시각화 | wing-gis-analysis | ⏳ | GeoJSON/SHP | +| SFR-10 | 7개 시스템 통합 | wing-gis-integration | ⏳ | 7종 어댑터 | +| PER-03 | 동시접속 3,000명 | K8s HPA + Redis | ⏳ | Scale-out | +| ECR-01 | MSA 아키텍처 | Docker + K8s | ⚠️ | Docker Compose 완료, K8s 미구현 | +| INR-04 | SSO/GPKI | wing-gis-auth + Keycloak | ⏳ | | +| INR-05 | API 서비스 | SpringDoc OpenAPI | ⏳ | | +| INR-06 | MCP 에이전트 | wing-gis-mcp | ⏳ | LLM 연계 | +| DAR-01~12 | 데이터 요구사항 | PostgreSQL + PostGIS | ⚠️ | 스키마 완료, 데이터 미적재 | + +> ✅ 구현 완료 · ⚠️ 부분 구현 · ⏳ 미착수 + +--- + +## 10. Development Phases + +### Phase 1: 기반 구축 (M+0 ~ M+2) — 완료 +- [x] 프로젝트 구조 설계 (ARCHITECTURE.md) +- [x] React 프로젝트 초기화 (Vite 8 + TypeScript 5.9) +- [x] PostgreSQL + PostGIS 스키마 생성 (001_init_schema.sql) +- [x] Docker Compose 개발환경 (7개 서비스) +- [x] **MapLibre GL JS 기반 지도** (OpenLayers → MapLibre 전환 완료) +- [x] **deck.gl MapboxOverlay 통합** (선박 WebGL 렌더링) +- [x] **ENC 전자해도** (gcnautical.com 벡터 타일, S-52 테마) +- [x] 레이아웃 5-패널 구조 (TitleBar/SubToolbar/Sidebar/RightPanel/BottomBar) +- [x] Zustand 상태 관리 (vessels, aisTargets, signalState, nauticalChartSettings) + +### Phase 2: 핵심 기능 (M+2 ~ M+4) — 진행 중 +- [x] **AIS 실시간 물표** (REST 폴링 60분 초기 → 1분 간격 → 2시간 프루닝) +- [x] **SignalKindCode 기반 아이콘** (8종 색상/형태, 항해/정박 구분) +- [x] **호버 툴팁** (이름, MMSI, SOG/COG, 사진 썸네일) +- [x] **선박 사진 모달** (고화질 뷰어, 네비게이션, 캐러셀) +- [x] **물표현황 실데이터** (AIS 카운트, 관할 내 선박 분류) +- [x] 선박 통합 검색 UI (SFR-08, 백엔드 미연동) +- [ ] EEZ/NLL/관할구역 경계 레이어 +- [ ] 사용자 인증 (SSO/GPKI) + +### Phase 3: 통합 (M+4 ~ M+6) +- [ ] 7개 시스템 연계 어댑터 (V-Pass/E-NAV/VTS/VTS-RADAR/AIR-AIS/VHF-DSC/LRIT) +- [ ] STOMP WebSocket 실시간 물표 스트리밍 +- [ ] 통합연계모듈 (수집/정제/융합/배포) +- [ ] 공간분석 기능 (버퍼/히트맵/통계) +- [ ] 레이어 업로드/SLD 편집 +- [ ] MCP 에이전트 (LLM 연계) + +### Phase 4: 안정화 (M+6 ~ M+7) +- [ ] 성능 최적화 (ShipBatchRenderer 밀도 컬링 고도화, 3,000명 동시접속) +- [ ] 보안 감사 로그 +- [ ] 운영 전환 + 교육 diff --git a/frontend/wing-gis-web/.gitignore b/frontend/wing-gis-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/wing-gis-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/wing-gis-web/.npmrc b/frontend/wing-gis-web/.npmrc new file mode 100644 index 0000000..67e04d2 --- /dev/null +++ b/frontend/wing-gis-web/.npmrc @@ -0,0 +1,5 @@ +# Nexus npm 프록시 레지스트리 +registry=https://nexus.gc-si.dev/repository/npm-public/ + +# Nexus 인증 +//nexus.gc-si.dev/repository/npm-public/:_auth=YWRtaW46R2NzYyE4OTMy diff --git a/frontend/wing-gis-web/README.md b/frontend/wing-gis-web/README.md new file mode 100644 index 0000000..e182536 --- /dev/null +++ b/frontend/wing-gis-web/README.md @@ -0,0 +1,199 @@ +# WING-GIS Web Frontend + +해양경찰청 통합 GIS 위치정보시스템 프론트엔드 + +## 기술 스택 + +| 분류 | 기술 | 버전 | 용도 | +|------|------|------|------| +| Framework | React | 19.x | SPA UI | +| Language | TypeScript | 5.9 | 타입 안전성 | +| Build | Vite | 8.x | 빌드 + HMR | +| Map Engine | MapLibre GL JS | 5.18 | ENC 전자해도 렌더링 | +| Map Overlay | deck.gl (MapboxOverlay) | 9.2 | 선박 아이콘 WebGL 렌더링 | +| State | Zustand | 5.x | 경량 상태 관리 | +| HTTP | Axios / fetch | - | REST API 통신 | +| WebSocket | STOMP.js | 7.x | 실시간 물표 수신 (예정) | + +## 시작하기 + +```bash +# 의존성 설치 +yarn install + +# 개발 서버 (http://localhost:5174) +yarn dev + +# 프로덕션 빌드 +yarn build + +# 빌드 결과물 프리뷰 +yarn preview +``` + +## 프로젝트 구조 + +``` +src/ +├── App.tsx # 루트 컴포넌트 (레이아웃 + AIS 폴링) +├── main.tsx # 엔트리 포인트 +├── App.css # 디자인 토큰 (CSS 변수) +│ +├── components/ +│ ├── layout/ # 5-패널 레이아웃 +│ │ ├── TitleBar.tsx # 상단 헤더 (로고, 네비, 시계, 사용자) +│ │ ├── SubToolbar.tsx # 컨텍스트별 도구 모음 +│ │ ├── Sidebar.tsx # 좌측 (검색, 레이어 트리) +│ │ ├── RightPanel.tsx # 우측 (물표현황/경보/해양정보) +│ │ └── BottomBar.tsx # 하단 상태바 +│ ├── map/ # 지도 오버레이 컴포넌트 +│ │ ├── MapViewML.tsx # MapLibre + deck.gl 통합 컨테이너 +│ │ ├── MapTools.tsx # 지도 도구 (확대/축소/초기화) +│ │ ├── BaseMapSelector.tsx # 배경지도 탭 +│ │ ├── ScaleBar.tsx # 축척바 +│ │ ├── MapLegend.tsx # 물표 범례 (실시간 AIS 카운트) +│ │ └── CoordStatusBar.tsx # 좌표/줌 상태바 +│ └── vessel/ # 선박 관련 UI +│ ├── VesselPopup.tsx # 선박 미니 팝업 +│ ├── VesselSearch.tsx # 선박 통합 검색 +│ └── VesselSignalToggle.tsx # 신호 소스 ON/OFF 토글 +│ +├── features/ +│ ├── vesselLayer/ # 선박 렌더링 파이프라인 +│ │ ├── hooks/ +│ │ │ └── useVesselDeckLayer.ts # deck.gl 레이어 통합 훅 +│ │ └── lib/ +│ │ ├── aisAdapter.ts # AisTarget → AisFeature 변환 +│ │ ├── aisDeckLayers.ts # AIS deck.gl IconLayer 빌더 +│ │ ├── aisIcons.ts # SignalKindCode별 SVG 아이콘 (8종) +│ │ ├── shipTooltip.ts # 호버 툴팁 HTML 생성 +│ │ ├── ShipBatchRenderer.ts # 뷰포트 컬링 + 밀도 제어 +│ │ ├── vesselAdapter.ts # 비AIS 소스 어댑터 +│ │ ├── vesselDeckLayers.ts # 비AIS deck.gl 레이어 +│ │ └── vesselIcons.ts # 비AIS 소스별 SVG 아이콘 +│ ├── nauticalChart/ # ENC 전자해도 오버레이 +│ │ ├── hooks/ +│ │ │ └── useNauticalChartOverlay.ts +│ │ ├── lib/ +│ │ │ ├── fetchNauticalStyle.ts # gcnautical.com 스타일 fetch +│ │ │ └── nauticalLayerManager.ts # 레이어 주입/제거/S-52 테마 +│ │ ├── model/ +│ │ │ └── types.ts # S52Theme, NauticalChartSettings +│ │ └── ui/ +│ │ └── NauticalChartToggle.tsx # ENC on/off + 주간/황혼/야간 +│ └── shipImage/ # 선박 사진 +│ ├── api/ +│ │ └── shipImageApi.ts # 이미지 API + 썸네일/고화질 URL +│ └── ui/ +│ └── ShipImageModal.tsx # 사진 뷰어 모달 +│ +├── hooks/ +│ ├── useMap.ts # MapLibre 맵 + MapboxOverlay 초기화 +│ ├── useDeckLayers.ts # deck.gl 레이어 + 콜백 전달 +│ ├── useStore.ts # Zustand 글로벌 스토어 +│ ├── useAisPolling.ts # AIS REST 폴링 (60분 초기, 1분 간격) +│ └── useVesselStream.ts # STOMP WebSocket (예정) +│ +├── services/ +│ ├── aisApi.ts # AIS recent-positions API +│ ├── api.ts # Axios 인스턴스 +│ └── vesselApi.ts # 물표 CRUD API +│ +├── types/ +│ ├── ais.ts # SignalKindCode, AisTarget, DTO +│ ├── vessel.ts # Vessel, VesselSource, 색상/형태 맵 +│ └── layer.ts # LayerGroup, S100_PRODUCTS +│ +├── utils/ +│ ├── coordinate.ts # DMS 변환, 축척 계산 +│ ├── s52Colors.ts # S-52 수심 색상 팔레트 +│ └── vesselIcon.ts # 범용 SVG 아이콘 생성 +│ +├── lib/map/ +│ ├── mapCore.ts # kickRepaint, onMapStyleReady +│ ├── mapConstants.ts # 기본 좌표, 줌, OSM 폴백 스타일 +│ └── MaplibreDeckCustomLayer.ts # Globe 모드용 (현재 미사용) +│ +├── context/ +│ └── MapContext.tsx # mapRef, overlayRef 전달 +│ +└── data/ + └── mockVessels.ts # 비AIS 더미 선박 데이터 +``` + +## 핵심 데이터 흐름 + +### AIS 선박 위치 + +``` +API: GET /signal-batch/api/v2/vessels/recent-positions?minutes=N + │ + ▼ +useAisPolling (60분 초기 → 60초 간격 2분 데이터 → 2시간 프루닝) + │ + ▼ +Zustand Store: aisTargets (Map) + │ + ▼ +useVesselDeckLayer → ShipBatchRenderer (뷰포트 컬링 + 밀도 제어) + │ + ▼ +deck.gl IconLayer (signalKindCode별 색상 아이콘) + │ + ├── 호버 → getTooltip (이름, MMSI, SOG/COG, 사진 썸네일) + └── 클릭 → ShipImageModal (사진 있는 선박만) +``` + +### ENC 전자해도 + +``` +gcnautical.com/styles/nautical.json → fetchEncStyle (폰트 패칭) + │ + ▼ +MapLibre map.setStyle() (초기 로드 시 OSM → ENC 전환) + │ + ├── ENC 토글 → useNauticalChartOverlay (레이어 주입/제거) + └── S-52 테마 → applyS52Theme (주간/황혼/야간 색상) +``` + +## 선박 아이콘 체계 + +### AIS 선박 (SignalKindCode 기반, 실시간 데이터) + +| 코드 | 종류 | 색상 | 형태 | +|------|------|------|------| +| 000020 | 어선 | #00C853 (녹색) | 항해: 화살표 32x48 / 정박: 원 16x16 | +| 000021 | 경비함정 | #FF5722 (주황) | 〃 | +| 000022 | 여객선 | #2196F3 (파랑) | 〃 | +| 000023 | 화물선 | #9C27B0 (보라) | 〃 | +| 000024 | 유조선 | #F44336 (빨강) | 〃 | +| 000025 | 관공선 | #FF9800 (오렌지) | 〃 | +| 000027 | 일반 | #607D8B (회색) | 〃 | +| 000028 | 부이 | #795548 (갈색) | 폴+몸체 32x44 (회전 없음) | + +### 비AIS 소스 (VesselSource 기반, 더미 데이터) + +| 소스 | 색상 | 형태 | +|------|------|------| +| V-Pass | #1a8a3a | 삼각형 | +| E-NAV | #8833cc | 삼각형 | +| VTS | #cc6600 | 사각형 | +| VTS-RADAR | #cc3300 | 다이아몬드 | +| AIR-AIS | #0099aa | 비행기 | +| VHF-DSC | #d63030 | 삼각형 (점멸) | + +## 환경 변수 + +| 변수 | 기본값 | 설명 | +|------|--------|------| +| VITE_API_BASE | `http://localhost:8080` | 백엔드 API 기본 URL | +| VITE_WS_URL | `http://localhost:8080/ws/vessels` | WebSocket 엔드포인트 | + +## Vite 프록시 (개발 환경) + +| 경로 | 타겟 | 용도 | +|------|------|------| +| `/api` | `http://localhost:8080` | 백엔드 API | +| `/signal-batch` | `https://wing.gc-si.dev` | AIS 선박 위치 API | +| `/shipimg` | `https://wing.gc-si.dev` | 선박 사진 정적 파일 | +| `/martin` | `http://192.168.1.18:3030` | Martin ENC 타일 서버 | diff --git a/frontend/wing-gis-web/eslint.config.js b/frontend/wing-gis-web/eslint.config.js new file mode 100644 index 0000000..5e6b472 --- /dev/null +++ b/frontend/wing-gis-web/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/wing-gis-web/index.html b/frontend/wing-gis-web/index.html new file mode 100644 index 0000000..f2a4a7a --- /dev/null +++ b/frontend/wing-gis-web/index.html @@ -0,0 +1,13 @@ + + + + + + + wing-gis-web + + +
+ + + diff --git a/frontend/wing-gis-web/package-lock.json b/frontend/wing-gis-web/package-lock.json new file mode 100644 index 0000000..4b40f12 --- /dev/null +++ b/frontend/wing-gis-web/package-lock.json @@ -0,0 +1,5001 @@ +{ + "name": "wing-gis-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wing-gis-web", + "version": "0.0.0", + "dependencies": { + "@ant-design/icons": "^6.1.1", + "@deck.gl/core": "^9.2.7", + "@deck.gl/layers": "^9.2.7", + "@deck.gl/mapbox": "^9.2.7", + "@stomp/stompjs": "^7.3.0", + "@types/sockjs-client": "^1.5.4", + "antd": "^6.3.4", + "axios": "^1.14.0", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "maplibre-gl": "^5.18.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "sockjs-client": "^1.6.1", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", + "integrity": "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1", + "csstype": "^3.1.3", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", + "integrity": "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^2.1.2", + "@babel/runtime": "^7.23.2", + "@rc-component/util": "^1.4.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.1.1.tgz", + "integrity": "sha512-AMT4N2y++TZETNHiM77fs4a0uPVCJGuL5MTonk13Pvv7UN7sID1cNEZOc1qNqx6zLKAOilTEFAdAoAFKa0U//Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-2.0.0.tgz", + "integrity": "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "clsx": "^2.1.1", + "json2mq": "^0.2.0", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@deck.gl/core": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/core/-/core-9.2.11.tgz", + "integrity": "sha512-lpdxXQuFSkd6ET7M6QxPI8QMhsLRY6vzLyk83sPGFb7JSb4OhrNHYt9sfIhcA/hxJW7bdBSMWWphf2GvQetVuA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/core": "~4.3.4", + "@loaders.gl/images": "~4.3.4", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6", + "@luma.gl/shadertools": "~9.2.6", + "@luma.gl/webgl": "~9.2.6", + "@math.gl/core": "^4.1.0", + "@math.gl/sun": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "@probe.gl/env": "^4.1.1", + "@probe.gl/log": "^4.1.1", + "@probe.gl/stats": "^4.1.1", + "@types/offscreencanvas": "^2019.6.4", + "gl-matrix": "^3.0.0", + "mjolnir.js": "^3.0.0" + } + }, + "node_modules/@deck.gl/layers": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz", + "integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==", + "license": "MIT", + "dependencies": { + "@loaders.gl/images": "~4.3.4", + "@loaders.gl/schema": "~4.3.4", + "@luma.gl/shadertools": "~9.2.6", + "@mapbox/tiny-sdf": "^2.0.5", + "@math.gl/core": "^4.1.0", + "@math.gl/polygon": "^4.1.0", + "@math.gl/web-mercator": "^4.1.0", + "earcut": "^2.2.4" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@loaders.gl/core": "~4.3.4", + "@luma.gl/core": "~9.2.6", + "@luma.gl/engine": "~9.2.6" + } + }, + "node_modules/@deck.gl/mapbox": { + "version": "9.2.11", + "resolved": "https://registry.npmjs.org/@deck.gl/mapbox/-/mapbox-9.2.11.tgz", + "integrity": "sha512-5OaFZgjyA4Vq6WjHUdcEdl0Phi8dwj8hSCErej0NetW90mctdbxwMt0gSbqcvWBowwhyj2QAhH0P2FcITjKG/A==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" + }, + "peerDependencies": { + "@deck.gl/core": "~9.2.0", + "@luma.gl/constants": "~9.2.6", + "@luma.gl/core": "~9.2.6", + "@math.gl/web-mercator": "^4.1.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", + "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", + "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", + "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@loaders.gl/core": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/core/-/core-4.3.4.tgz", + "integrity": "sha512-cG0C5fMZ1jyW6WCsf4LoHGvaIAJCEVA/ioqKoYRwoSfXkOf+17KupK1OUQyUCw5XoRn+oWA1FulJQOYlXnb9Gw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4", + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2" + } + }, + "node_modules/@loaders.gl/images": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/images/-/images-4.3.4.tgz", + "integrity": "sha512-qgc33BaNsqN9cWa/xvcGvQ50wGDONgQQdzHCKDDKhV2w/uptZoR5iofJfuG8UUV2vUMMd82Uk9zbopRx2rS4Ag==", + "license": "MIT", + "dependencies": { + "@loaders.gl/loader-utils": "4.3.4" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/loader-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/loader-utils/-/loader-utils-4.3.4.tgz", + "integrity": "sha512-tjMZvlKQSaMl2qmYTAxg+ySR6zd6hQn5n3XaU8+Ehp90TD3WzxvDKOMNDqOa72fFmIV+KgPhcmIJTpq4lAdC4Q==", + "license": "MIT", + "dependencies": { + "@loaders.gl/schema": "4.3.4", + "@loaders.gl/worker-utils": "4.3.4", + "@probe.gl/log": "^4.0.2", + "@probe.gl/stats": "^4.0.2" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/schema": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/schema/-/schema-4.3.4.tgz", + "integrity": "sha512-1YTYoatgzr/6JTxqBLwDiD3AVGwQZheYiQwAimWdRBVB0JAzych7s1yBuE0CVEzj4JDPKOzVAz8KnU1TiBvJGw==", + "license": "MIT", + "dependencies": { + "@types/geojson": "^7946.0.7" + }, + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@loaders.gl/worker-utils": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/@loaders.gl/worker-utils/-/worker-utils-4.3.4.tgz", + "integrity": "sha512-EbsszrASgT85GH3B7jkx7YXfQyIYo/rlobwMx6V3ewETapPUwdSAInv+89flnk5n2eu2Lpdeh+2zS6PvqbL2RA==", + "license": "MIT", + "peerDependencies": { + "@loaders.gl/core": "^4.3.0" + } + }, + "node_modules/@luma.gl/constants": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/constants/-/constants-9.2.6.tgz", + "integrity": "sha512-rvFFrJuSm5JIWbDHFuR4Q2s4eudO3Ggsv0TsGKn9eqvO7bBiPm/ANugHredvh3KviEyYuMZZxtfJvBdr3kzldg==", + "license": "MIT" + }, + "node_modules/@luma.gl/core": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/core/-/core-9.2.6.tgz", + "integrity": "sha512-d8KcH8ZZcjDAodSN/G2nueA9YE2X8kMz7Q0OxDGpCww6to1MZXM3Ydate/Jqsb5DDKVgUF6yD6RL8P5jOki9Yw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8", + "@types/offscreencanvas": "^2019.6.4" + } + }, + "node_modules/@luma.gl/engine": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/engine/-/engine-9.2.6.tgz", + "integrity": "sha512-1AEDs2AUqOWh7Wl4onOhXmQF+Rz1zNdPXF+Kxm4aWl92RQ42Sh2CmTvRt2BJku83VQ91KFIEm/v3qd3Urzf+Uw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "@probe.gl/log": "^4.0.8", + "@probe.gl/stats": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0", + "@luma.gl/shadertools": "~9.2.0" + } + }, + "node_modules/@luma.gl/shadertools": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/shadertools/-/shadertools-9.2.6.tgz", + "integrity": "sha512-4+uUbynqPUra9d/z1nQChyHmhLgmKfSMjS7kOwLB6exSnhKnpHL3+Hu9fv55qyaX50nGH3oHawhGtJ6RRvu65w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@math.gl/core": "^4.1.0", + "@math.gl/types": "^4.1.0", + "wgsl_reflect": "^1.2.0" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@luma.gl/webgl": { + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/@luma.gl/webgl/-/webgl-9.2.6.tgz", + "integrity": "sha512-NGBTdxJMk7j8Ygr1zuTyAvr1Tw+EpupMIQo7RelFjEsZXg6pujFqiDMM+rgxex8voCeuhWBJc7Rs+MoSqd46UQ==", + "license": "MIT", + "dependencies": { + "@luma.gl/constants": "9.2.6", + "@math.gl/types": "^4.1.0", + "@probe.gl/env": "^4.0.8" + }, + "peerDependencies": { + "@luma.gl/core": "~9.2.0" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", + "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz", + "integrity": "sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz", + "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.1" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz", + "integrity": "sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.7.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.7.0.tgz", + "integrity": "sha512-Ed7rcKYU5iELfablg9Mj+TVCsXsPBgdMyXPRAxb2v7oWg9YJnpQdZ5msDs1LESu/mtXy3Z48Vdppv2t/x5kAhw==", + "license": "ISC", + "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" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.8.tgz", + "integrity": "sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz", + "integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==", + "license": "MIT", + "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" + } + }, + "node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz", + "integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==", + "license": "ISC" + }, + "node_modules/@math.gl/core": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/core/-/core-4.1.0.tgz", + "integrity": "sha512-FrdHBCVG3QdrworwrUSzXIaK+/9OCRLscxI2OUy6sLOHyHgBMyfnEGs99/m3KNvs+95BsnQLWklVfpKfQzfwKA==", + "license": "MIT", + "dependencies": { + "@math.gl/types": "4.1.0" + } + }, + "node_modules/@math.gl/polygon": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/polygon/-/polygon-4.1.0.tgz", + "integrity": "sha512-YA/9PzaCRHbIP5/0E9uTYrqe+jsYTQoqoDWhf6/b0Ixz8bPZBaGDEafLg3z7ffBomZLacUty9U3TlPjqMtzPjA==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@math.gl/sun": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/sun/-/sun-4.1.0.tgz", + "integrity": "sha512-i3q6OCBLSZ5wgZVhXg+X7gsjY/TUtuFW/2KBiq/U1ypLso3S4sEykoU/MGjxUv1xiiGtr+v8TeMbO1OBIh/HmA==", + "license": "MIT" + }, + "node_modules/@math.gl/types": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/types/-/types-4.1.0.tgz", + "integrity": "sha512-clYZdHcmRvMzVK5fjeDkQlHUzXQSNdZ7s4xOqC3nJPgz4C/TZkUecTo9YS4PruZqtDda/ag4erndP0MIn40dGA==", + "license": "MIT" + }, + "node_modules/@math.gl/web-mercator": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@math.gl/web-mercator/-/web-mercator-4.1.0.tgz", + "integrity": "sha512-HZo3vO5GCMkXJThxRJ5/QYUYRr3XumfT8CzNNCwoJfinxy5NtKUd7dusNTXn7yJ40UoB8FMIwkVwNlqaiRZZAw==", + "license": "MIT", + "dependencies": { + "@math.gl/core": "4.1.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", + "integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.122.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", + "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@probe.gl/env": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/env/-/env-4.1.1.tgz", + "integrity": "sha512-+68seNDMVsEegRB47pFA/Ws1Fjy8agcFYXxzorKToyPcD6zd+gZ5uhwoLd7TzsSw6Ydns//2KEszWn+EnNHTbA==", + "license": "MIT" + }, + "node_modules/@probe.gl/log": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/log/-/log-4.1.1.tgz", + "integrity": "sha512-kcZs9BT44pL7hS1OkRGKYRXI/SN9KejUlPD+BY40DguRLzdC5tLG/28WGMyfKdn/51GT4a0p+0P8xvDn1Ez+Kg==", + "license": "MIT", + "dependencies": { + "@probe.gl/env": "4.1.1" + } + }, + "node_modules/@probe.gl/stats": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@probe.gl/stats/-/stats-4.1.1.tgz", + "integrity": "sha512-4VpAyMHOqydSvPlEyHwXaE+AkIdR03nX+Qhlxsk2D/IW4OVmDZgIsvJB1cDzyEEtcfKcnaEbfXeiPgejBceT6g==", + "license": "MIT" + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.0.tgz", + "integrity": "sha512-n4HcR5siNUXRX23nDizbZBQPO0ZM/5oTtmKZ6/eqL0L2bo747cklFdZGRN2f+c9qWGICwDzrhW0H7tE9PptdcA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/cascader": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@rc-component/cascader/-/cascader-1.14.0.tgz", + "integrity": "sha512-Ip9356xwZUR2nbW5PRVGif4B/bDve4pLa/N+PGbvBaTnjbvmN4PFMBGQSmlDlzKP1ovxaYMvwF/dI9lXNLT4iQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/checkbox": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/checkbox/-/checkbox-2.0.0.tgz", + "integrity": "sha512-3CXGPpAR9gsPKeO2N78HAPOzU30UdemD6HGJoWVJOpa6WleaGB5kzZj3v6bdTZab31YuWgY/RxV3VKPctn0DwQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/collapse": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/collapse/-/collapse-1.2.0.tgz", + "integrity": "sha512-ZRYSKSS39qsFx93p26bde7JUZJshsUBEQRlRXPuJYlAiNX0vyYlF5TsAm8JZN3LcF8XvKikdzPbgAtXSbkLUkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-3.1.1.tgz", + "integrity": "sha512-OHaCHLHszCegdXmIq2ZRIZBN/EtpT6Wm8SG/gpzLATHbVKc/avvuKi+zlOuk05FTWvgaMmpxAko44uRJ3M+2pg==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-2.0.1.tgz", + "integrity": "sha512-HyZbYm47s/YqtP6pKXNMjPEMaukyg7P0qVfgMLzr7YiFNMHbK2fKTAGzms9ykfGHSfyf75nBbgWw+hHkp+VImw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/dialog": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/@rc-component/dialog/-/dialog-1.8.4.tgz", + "integrity": "sha512-Ay6PM7phkTkquplG8fWfUGFZ2GTLx9diTl4f0d8Eqxd7W1u1KjE9AQooFQHOHnhZf0Ya3z51+5EKCWHmt/dNEw==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.3", + "@rc-component/portal": "^2.1.0", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/drawer": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@rc-component/drawer/-/drawer-1.4.2.tgz", + "integrity": "sha512-1ib+fZEp6FBu+YvcIktm+nCQ+Q+qIpwpoaJH6opGr4ofh2QMq+qdr5DLC4oCf5qf3pcWX9lUWPYX652k4ini8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.1.3", + "@rc-component/util": "^1.9.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/dropdown": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/dropdown/-/dropdown-1.0.2.tgz", + "integrity": "sha512-6PY2ecUSYhDPhkNHHb4wfeAya04WhpmUSKzdR60G+kMNVUCX2vjT/AgTS0Lz0I/K6xrPMJ3enQbwVpeN3sHCgg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/@rc-component/form": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/form/-/form-1.8.0.tgz", + "integrity": "sha512-eUD5KKYnIZWmJwRA0vnyO/ovYUfHGU1svydY1OrqU5fw8Oz9Tdqvxvrlh0wl6xI/EW69dT7II49xpgOWzK3T5A==", + "license": "MIT", + "dependencies": { + "@rc-component/async-validator": "^5.1.0", + "@rc-component/util": "^1.6.2", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/image": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/image/-/image-1.8.0.tgz", + "integrity": "sha512-Dr41bFevLB5NgVaJhEUmNvbEf+ynAhim6W98ZW2xvCsdFISc2TYP4ZvCVdie3eaZdum2kieVcvpNHu+UrzAAHA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/portal": "^2.1.2", + "@rc-component/util": "^1.10.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/input": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/input/-/input-1.1.2.tgz", + "integrity": "sha512-Q61IMR47piUBudgixJ30CciKIy9b1H95qe7GgEKOmSJVJXvFRWJllJfQry9tif+MX2cWFXWJf/RXz4kaCeq/Fg==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/input-number": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/@rc-component/input-number/-/input-number-1.6.2.tgz", + "integrity": "sha512-Gjcq7meZlCOiWN1t1xCC+7/s85humHVokTBI7PJgTfoyw5OWF74y3e6P8PHX104g9+b54jsodFIzyaj6p8LI9w==", + "license": "MIT", + "dependencies": { + "@rc-component/mini-decimal": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mentions": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@rc-component/mentions/-/mentions-1.6.0.tgz", + "integrity": "sha512-KIkQNP6habNuTsLhUv0UGEOwG67tlmE7KNIJoQZZNggEZl5lQJTytFDb69sl5CK3TDdISCTjKP3nGEBKgT61CQ==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/textarea": "~1.1.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/menu": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/menu/-/menu-1.2.0.tgz", + "integrity": "sha512-VWwDuhvYHSnTGj4n6bV3ISrLACcPAzdPOq3d0BzkeiM5cve8BEYfvkEhNoM0PLzv51jpcejeyrLXeMVIJ+QJlg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.3.tgz", + "integrity": "sha512-bk/FJ09fLf+NLODMAFll6CfYrHPBioTedhW6lxDBuuWucJEqFUd4l/D/5JgIi3dina6sYahB8iuPAZTNz2pMxw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/motion": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@rc-component/motion/-/motion-1.3.2.tgz", + "integrity": "sha512-itfd+GztzJYAb04Z4RkEub1TbJAfZc2Iuy8p44U44xD1F5+fNYFKI3897ijlbIyfvXkTmMm+KGcjkQQGMHywEQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-2.0.1.tgz", + "integrity": "sha512-AyarjoLU5YlxuValRi+w8JRH2Z84TBbFO2RoGWz9d8bSu0FqT8DtugH3xC3BV7mUwlmROFauyWuXFuq4IFbH+w==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/notification": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/notification/-/notification-1.2.0.tgz", + "integrity": "sha512-OX3J+zVU7rvoJCikjrfW7qOUp7zlDeFBK2eA3SFbGSkDqo63Sl4Ss8A04kFP+fxHSxMDIS9jYVEZtU1FNCFuBA==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/overflow": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rc-component/overflow/-/overflow-1.0.0.tgz", + "integrity": "sha512-GSlBeoE0XTBi5cf3zl8Qh7Uqhn7v8RrlJ8ajeVpEkNe94HWy5l5BQ0Mwn2TVUq9gdgbfEMUmTX7tJFAg7mz0Rw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/pagination": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/pagination/-/pagination-1.2.0.tgz", + "integrity": "sha512-YcpUFE8dMLfSo6OARJlK6DbHHvrxz7pMGPGmC/caZSJJz6HRKHC1RPP001PRHCvG9Z/veD039uOQmazVuLJzlw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/picker": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/picker/-/picker-1.9.1.tgz", + "integrity": "sha512-9FBYYsvH3HMLICaPDA/1Th5FLaDkFa7qAtangIdlhKb3ZALaR745e9PsOhheJb6asS4QXc12ffiAcjdkZ4C5/g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/trigger": "^3.6.15", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/@rc-component/portal": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-2.2.0.tgz", + "integrity": "sha512-oc6FlA+uXCMiwArHsJyHcIkX4q6uKyndrPol2eWX8YPkAnztHOPsFIRtmWG4BMlGE5h7YIRE3NiaJ5VS8Lb1QQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=12.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/progress": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/progress/-/progress-1.0.2.tgz", + "integrity": "sha512-WZUnH9eGxH1+xodZKqdrHke59uyGZSWgj5HBM5Kwk5BrTMuAORO7VJ2IP5Qbm9aH3n9x3IcesqHHR0NWPBC7fQ==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.1.tgz", + "integrity": "sha512-LfLGNymzKdUPjXUbRP+xOhIWY4jQ+YMj5MmWAcgcAq1Ij8XP7tRmAXqyuv96XvLUBE/5cA8hLFl9eO1JQMujrA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/rate": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/rate/-/rate-1.0.1.tgz", + "integrity": "sha512-bkXxeBqDpl5IOC7yL7GcSYjQx9G8H+6kLYQnNZWeBYq2OYIv1MONd6mqKTjnnJYpV0cQIU2z3atdW0j1kttpTw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/resize-observer": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/resize-observer/-/resize-observer-1.1.2.tgz", + "integrity": "sha512-t/Bb0W8uvL4PYKAB3YcChC+DlHh0Wt5kM7q/J+0qpVEUMLe7Hk5zuvc9km0hMnTFPSx5Z7Wu/fzCLN6erVLE8Q==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/segmented": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/segmented/-/segmented-1.3.0.tgz", + "integrity": "sha512-5J/bJ01mbDnoA6P/FW8SxUvKn+OgUSTZJPzCNnTBntG50tzoP7DydGhqxp7ggZXZls7me3mc2EQDXakU3iTVFg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@rc-component/motion": "^1.1.4", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@rc-component/select": { + "version": "1.6.15", + "resolved": "https://registry.npmjs.org/@rc-component/select/-/select-1.6.15.tgz", + "integrity": "sha512-SyVCWnqxCQZZcQvQJ/CxSjx2bGma6ds/HtnpkIfZVnt6RoEgbqUmHgD6vrzNarNXwbLXerwVzWwq8F3d1sst7g==", + "license": "MIT", + "dependencies": { + "@rc-component/overflow": "^1.0.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.3.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/slider": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/slider/-/slider-1.0.1.tgz", + "integrity": "sha512-uDhEPU1z3WDfCJhaL9jfd2ha/Eqpdfxsn0Zb0Xcq1NGQAman0TWaR37OWp2vVXEOdV2y0njSILTMpTfPV1454g==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/steps": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rc-component/steps/-/steps-1.2.2.tgz", + "integrity": "sha512-/yVIZ00gDYYPHSY0JP+M+s3ZvuXLu2f9rEjQqiUDs7EcYsUYrpJ/1bLj9aI9R7MBR3fu/NGh6RM9u2qGfqp+Nw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/switch": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rc-component/switch/-/switch-1.0.3.tgz", + "integrity": "sha512-Jgi+EbOBquje/XNdofr7xbJQZPYJP+BlPfR0h+WN4zFkdtB2EWqEfvkXJWeipflwjWip0/17rNbxEAqs8hVHfw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/table": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@rc-component/table/-/table-1.9.1.tgz", + "integrity": "sha512-FVI5ZS/GdB3BcgexfCYKi3iHhZS3Fr59EtsxORszYGrfpH1eWr33eDNSYkVfLI6tfJ7vftJDd9D5apfFWqkdJg==", + "license": "MIT", + "dependencies": { + "@rc-component/context": "^2.0.1", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.1.0", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tabs": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@rc-component/tabs/-/tabs-1.7.0.tgz", + "integrity": "sha512-J48cs2iBi7Ho3nptBxxIqizEliUC+ExE23faspUQKGQ550vaBlv3aGF8Epv/UB1vFWeoJDTW/dNzgIU0Qj5i/w==", + "license": "MIT", + "dependencies": { + "@rc-component/dropdown": "~1.0.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.1.3", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/textarea": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/textarea/-/textarea-1.1.2.tgz", + "integrity": "sha512-9rMUEODWZDMovfScIEHXWlVZuPljZ2pd1LKNjslJVitn4SldEzq5vO1CL3yy3Dnib6zZal2r2DPtjy84VVpF6A==", + "license": "MIT", + "dependencies": { + "@rc-component/input": "~1.1.0", + "@rc-component/resize-observer": "^1.0.0", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tooltip": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/tooltip/-/tooltip-1.4.0.tgz", + "integrity": "sha512-8Rx5DCctIlLI4raR0I0xHjVTf1aF48+gKCNeAAo5bmF5VoR5YED+A/XEqzXv9KKqrJDRcd3Wndpxh2hyzrTtSg==", + "license": "MIT", + "dependencies": { + "@rc-component/trigger": "^3.7.1", + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-2.3.0.tgz", + "integrity": "sha512-K04K9r32kUC+auBSQfr+Fss4SpSIS9JGe56oq/ALAX0p+i2ylYOI1MgR83yBY7v96eO6ZFXcM/igCQmubps0Ow==", + "license": "MIT", + "dependencies": { + "@rc-component/portal": "^2.2.0", + "@rc-component/trigger": "^3.0.0", + "@rc-component/util": "^1.7.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tree": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@rc-component/tree/-/tree-1.2.4.tgz", + "integrity": "sha512-5Gli43+m4R7NhpYYz3Z61I6LOw9yI6CNChxgVtvrO6xB1qML7iE6QMLVMB3+FTjo2yF6uFdAHtqWPECz/zbX5w==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.0.0", + "@rc-component/util": "^1.8.1", + "@rc-component/virtual-list": "^1.0.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/tree-select": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@rc-component/tree-select/-/tree-select-1.8.0.tgz", + "integrity": "sha512-iYsPq3nuLYvGqdvFAW+l+I9ASRIOVbMXyA8FGZg2lGym/GwkaWeJGzI4eJ7c9IOEhRj0oyfIN4S92Fl3J05mjQ==", + "license": "MIT", + "dependencies": { + "@rc-component/select": "~1.6.0", + "@rc-component/tree": "~1.2.0", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@rc-component/trigger": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-3.9.0.tgz", + "integrity": "sha512-X8btpwfrT27AgrZVOz4swclhEHTZcqaHeQMXXBgveagOiakTa36uObXbdwerXffgV8G9dH1fAAE0DHtVQs8EHg==", + "license": "MIT", + "dependencies": { + "@rc-component/motion": "^1.1.4", + "@rc-component/portal": "^2.2.0", + "@rc-component/resize-observer": "^1.1.1", + "@rc-component/util": "^1.2.1", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/upload": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/upload/-/upload-1.1.0.tgz", + "integrity": "sha512-LIBV90mAnUE6VK5N4QvForoxZc4XqEYZimcp7fk+lkE4XwHHyJWxpIXQQwMU8hJM+YwBbsoZkGksL1sISWHQxw==", + "license": "MIT", + "dependencies": { + "@rc-component/util": "^1.3.0", + "clsx": "^2.1.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.10.0.tgz", + "integrity": "sha512-aY9GLBuiUdpyfIUpAWSYer4Tu3mVaZCo5A0q9NtXcazT3MRiI3/WNHCR+DUn5VAtR6iRRf0ynCqQUcHli5UdYw==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@rc-component/virtual-list": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz", + "integrity": "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@rc-component/resize-observer": "^1.0.1", + "@rc-component/util": "^1.4.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz", + "integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz", + "integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz", + "integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz", + "integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz", + "integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz", + "integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz", + "integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stomp/stompjs": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@stomp/stompjs/-/stompjs-7.3.0.tgz", + "integrity": "sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==", + "license": "Apache-2.0" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tybys/wasm-util/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/sockjs-client": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@types/sockjs-client/-/sockjs-client-1.5.4.tgz", + "integrity": "sha512-zk+uFZeWyvJ5ZFkLIwoGA/DfJ+pYzcZ8eH4H/EILCm2OBZyHH6Hkdna1/UWL/CFruh5wj6ES7g75SvUB0VsH5w==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", + "integrity": "sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/type-utils": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.0.tgz", + "integrity": "sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.0.tgz", + "integrity": "sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.0", + "@typescript-eslint/types": "^8.58.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.0.tgz", + "integrity": "sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.0.tgz", + "integrity": "sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.0.tgz", + "integrity": "sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.0.tgz", + "integrity": "sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.0.tgz", + "integrity": "sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.0", + "@typescript-eslint/tsconfig-utils": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/visitor-keys": "8.58.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.0.tgz", + "integrity": "sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.0", + "@typescript-eslint/types": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.0.tgz", + "integrity": "sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/antd/-/antd-6.3.5.tgz", + "integrity": "sha512-8BPz9lpZWQm42PTx7yL4KxWAotVuqINiKcoYRcLtdd5BFmAcAZicVyFTnBJyRDlzGZFZeRW3foGu6jXYFnej6Q==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/cssinjs": "^2.1.2", + "@ant-design/cssinjs-utils": "^2.1.2", + "@ant-design/fast-color": "^3.0.1", + "@ant-design/icons": "^6.1.1", + "@ant-design/react-slick": "~2.0.0", + "@babel/runtime": "^7.28.4", + "@rc-component/cascader": "~1.14.0", + "@rc-component/checkbox": "~2.0.0", + "@rc-component/collapse": "~1.2.0", + "@rc-component/color-picker": "~3.1.1", + "@rc-component/dialog": "~1.8.4", + "@rc-component/drawer": "~1.4.2", + "@rc-component/dropdown": "~1.0.2", + "@rc-component/form": "~1.8.0", + "@rc-component/image": "~1.8.0", + "@rc-component/input": "~1.1.2", + "@rc-component/input-number": "~1.6.2", + "@rc-component/mentions": "~1.6.0", + "@rc-component/menu": "~1.2.0", + "@rc-component/motion": "^1.3.2", + "@rc-component/mutate-observer": "^2.0.1", + "@rc-component/notification": "~1.2.0", + "@rc-component/pagination": "~1.2.0", + "@rc-component/picker": "~1.9.1", + "@rc-component/progress": "~1.0.2", + "@rc-component/qrcode": "~1.1.1", + "@rc-component/rate": "~1.0.1", + "@rc-component/resize-observer": "^1.1.2", + "@rc-component/segmented": "~1.3.0", + "@rc-component/select": "~1.6.15", + "@rc-component/slider": "~1.0.1", + "@rc-component/steps": "~1.2.2", + "@rc-component/switch": "~1.0.3", + "@rc-component/table": "~1.9.1", + "@rc-component/tabs": "~1.7.0", + "@rc-component/textarea": "~1.1.2", + "@rc-component/tooltip": "~1.4.0", + "@rc-component/tour": "~2.3.0", + "@rc-component/tree": "~1.2.4", + "@rc-component/tree-select": "~1.8.0", + "@rc-component/trigger": "^3.9.0", + "@rc-component/upload": "~1.1.0", + "@rc-component/util": "^1.10.0", + "clsx": "^2.1.1", + "dayjs": "^1.11.11", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.12.tgz", + "integrity": "sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts-for-react": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/echarts-for-react/-/echarts-for-react-3.0.6.tgz", + "integrity": "sha512-4zqLgTGWS3JvkQDXjzkR1k1CHRdpd6by0988TWMJgnvDytegWLbeP/VNZmMa+0VJx2eD7Y632bi2JquXDgiGJg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "size-sensor": "^1.0.1" + }, + "peerDependencies": { + "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", + "react": "^15.0.0 || >=16.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "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": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kdbush": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz", + "integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==", + "license": "ISC" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/maplibre-gl": { + "version": "5.21.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.21.1.tgz", + "integrity": "sha512-zto1RTnFkOpOO1bm93ElCXF1huey2N4LvXaGLMFcYAu9txh0OhGIdX1q3LZLkrMKgMxMeYduaQo+DVNzg098fg==", + "license": "BSD-3-Clause", + "dependencies": { + "@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": "^6.0.4", + "@maplibre/maplibre-gl-style-spec": "^24.7.0", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "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", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/earcut": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mjolnir.js": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mjolnir.js/-/mjolnir.js-3.0.0.tgz", + "integrity": "sha512-siX3YCG7N2HnmN1xMH3cK4JkUZJhbkhRFJL+G5N1vH0mh1t5088rJknIoqDFWDIU6NPGvRRgLnYW3ZHjSMEBLA==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz", + "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", + "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz", + "integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.122.0", + "@rolldown/pluginutils": "1.0.0-rc.12" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", + "@rolldown/binding-darwin-x64": "1.0.0-rc.12", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.12", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz", + "integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/size-sensor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/size-sensor/-/size-sensor-1.0.3.tgz", + "integrity": "sha512-+k9mJ2/rQMiRmQUcjn+qznch260leIXY8r4FyYKKyRBO/s5UoeMAHGkCJyE1R/4wrIhTJONfyloY55SkE7ve3A==", + "license": "ISC" + }, + "node_modules/sockjs-client": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz", + "integrity": "sha512-2g0tjOR+fRs0amxENLi/q5TiJTqY+WXFOzb5UwXndlK6TO3U/mirZznpx6w34HVMoc3g7cY24yC/ZMIYnDlfkw==", + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "eventsource": "^2.0.2", + "faye-websocket": "^0.11.4", + "inherits": "^2.0.4", + "url-parse": "^1.5.10" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://tidelift.com/funding/github/npm/sockjs-client" + } + }, + "node_modules/sockjs-client/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.0.tgz", + "integrity": "sha512-e2TQzKfaI85fO+F3QywtX+tCTsu/D3WW5LVU6nz8hTFKFZ8yBJ6mSYRpXqdR3mFjPWmO0eWsTa5f+UpAOe/FMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.0", + "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/typescript-estree": "8.58.0", + "@typescript-eslint/utils": "8.58.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, + "node_modules/vite": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz", + "integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.12", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/wgsl_reflect": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/wgsl_reflect/-/wgsl_reflect-1.2.3.tgz", + "integrity": "sha512-BQWBIsOn411M+ffBxmA6QRLvAOVbuz3Uk4NusxnqC1H7aeQcVLhdA3k2k/EFFFtqVjhz3z7JOOZF1a9hj2tv4Q==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/wing-gis-web/package.json b/frontend/wing-gis-web/package.json new file mode 100644 index 0000000..a842bd0 --- /dev/null +++ b/frontend/wing-gis-web/package.json @@ -0,0 +1,43 @@ +{ + "name": "wing-gis-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^6.1.1", + "@deck.gl/core": "^9.2.7", + "@deck.gl/layers": "^9.2.7", + "@deck.gl/mapbox": "^9.2.7", + "@stomp/stompjs": "^7.3.0", + "@types/sockjs-client": "^1.5.4", + "antd": "^6.3.4", + "axios": "^1.14.0", + "echarts": "^6.0.0", + "echarts-for-react": "^3.0.6", + "maplibre-gl": "^5.18.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "sockjs-client": "^1.6.1", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/node": "^24.12.0", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.57.0", + "vite": "^8.0.1" + } +} diff --git a/frontend/wing-gis-web/public/favicon.svg b/frontend/wing-gis-web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/wing-gis-web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/wing-gis-web/public/icons.svg b/frontend/wing-gis-web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/wing-gis-web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/wing-gis-web/src/App.css b/frontend/wing-gis-web/src/App.css new file mode 100644 index 0000000..089ad60 --- /dev/null +++ b/frontend/wing-gis-web/src/App.css @@ -0,0 +1,85 @@ +@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=JetBrains+Mono:wght@400;600&display=swap'); + +:root { + --c-primary:#0093b2; + --c-primary-dark:#006f87; + --c-primary-light:#e6f7fa; + --c-primary-mid:#b3e4ee; + --c-header:#1a3a4a; + --c-header2:#0e2535; + --c-sidebar:#f7f9fa; + --c-sb-border:#d4dde2; + --c-white:#fff; + --c-text1:#1a2e38; + --c-text2:#4a6070; + --c-text3:#8a9eaa; + --c-border:#dde5ea; + --c-hover:#edf6f9; + --c-sel:#d0eef5; + --c-red:#d63030; + --c-orange:#e07020; + --c-green:#2a9050; + --c-chart-blue:#0093b2; + --c-chart-sea:#6ab4cc; + --sans:'Noto Sans KR',sans-serif; + --mono:'JetBrains Mono',monospace; + --sh:0 2px 8px rgba(0,0,0,.12); + --sh-sm:0 1px 4px rgba(0,0,0,.08); +} + +*{margin:0;padding:0;box-sizing:border-box} + +html, body, #root { + height:100%; + width:100%; + overflow:hidden; +} + +body { + font-family:var(--sans); + background:var(--c-white); + color:var(--c-text1); + height:100vh; + display:flex; + flex-direction:column; + overflow:hidden; + font-size:13px; + -webkit-font-smoothing:antialiased; + -moz-osx-font-smoothing:grayscale; +} + +/* Scrollbar */ +::-webkit-scrollbar{width:3px;height:3px} +::-webkit-scrollbar-thumb{background:var(--c-border);border-radius:2px} + +/* VHF-DSC blink animation */ +@keyframes vblink{0%,100%{opacity:1}50%{opacity:.25}} + +/* Vessel popup pulse */ +@keyframes vp-pulse{0%,100%{opacity:1}50%{opacity:.5}} + +/* Spinner */ +@keyframes spin{from{transform:rotate(0deg)}to{transform:rotate(360deg)}} + +/* deck.gl tooltip */ +.deck-tooltip{ + background:rgba(26,58,74,.96)!important; + color:#fff!important; + border:1px solid rgba(0,147,178,.35)!important; + border-radius:6px!important; + box-shadow:0 6px 20px rgba(0,0,0,.35)!important; + padding:7px 9px!important; + font-size:12px!important; + line-height:1.35!important; + font-family:'Noto Sans KR',system-ui,sans-serif!important; + pointer-events:none!important; + max-width:300px!important; +} + +button { + font-family:var(--sans); +} + +input:focus { + outline:none; +} diff --git a/frontend/wing-gis-web/src/App.tsx b/frontend/wing-gis-web/src/App.tsx new file mode 100644 index 0000000..65beb32 --- /dev/null +++ b/frontend/wing-gis-web/src/App.tsx @@ -0,0 +1,34 @@ +import React, { useEffect } from 'react'; +import TitleBar from './components/layout/TitleBar'; +import SubToolbar from './components/layout/SubToolbar'; +import Sidebar from './components/layout/Sidebar'; +import RightPanel from './components/layout/RightPanel'; +import BottomBar from './components/layout/BottomBar'; +import MapViewML from './components/map/MapViewML'; +import { useStore } from './hooks/useStore'; +import { useAisPolling } from './hooks/useAisPolling'; +import './App.css'; + +const App: React.FC = () => { + const { setUser } = useStore(); + useAisPolling(); + + useEffect(() => { + setUser({ name: '관제관', department: '인천지방청', organization: '해양경찰청' }); + }, [setUser]); + + return ( +
+ + +
+ + + +
+ +
+ ); +}; + +export default App; diff --git a/frontend/wing-gis-web/src/assets/hero.png b/frontend/wing-gis-web/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/frontend/wing-gis-web/src/assets/hero.png differ diff --git a/frontend/wing-gis-web/src/assets/react.svg b/frontend/wing-gis-web/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/wing-gis-web/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/wing-gis-web/src/assets/vite.svg b/frontend/wing-gis-web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/wing-gis-web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/wing-gis-web/src/components/layout/BottomBar.tsx b/frontend/wing-gis-web/src/components/layout/BottomBar.tsx new file mode 100644 index 0000000..05cb958 --- /dev/null +++ b/frontend/wing-gis-web/src/components/layout/BottomBar.tsx @@ -0,0 +1,52 @@ +import React, { useState, useEffect } from 'react'; + +const BottomBar: React.FC = () => { + const [time, setTime] = useState('--:--:--'); + + useEffect(() => { + const tick = () => { + setTime(new Date().toLocaleTimeString('ko-KR', { hour12: false })); + }; + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, []); + + return ( +
+ {/* Status dots */} +
+
시스템 정상 +
+
+
+
AIS 수신 정상 +
+
+
V-Pass 정상 +
+
+
VHF-DSC 점검중 +
+
+ + {/* Center */} +
+ 물표 갱신: {time} +
+
+ 통합연계모듈 v2.1 +
+ + {/* Right */} +
+ {'㈜지씨 WING-GIS 시안 v0.3'} +
+
+ ); +}; + +export default BottomBar; diff --git a/frontend/wing-gis-web/src/components/layout/RightPanel.tsx b/frontend/wing-gis-web/src/components/layout/RightPanel.tsx new file mode 100644 index 0000000..8f262e9 --- /dev/null +++ b/frontend/wing-gis-web/src/components/layout/RightPanel.tsx @@ -0,0 +1,225 @@ +import React, { useState, useMemo } from 'react'; +import { useStore } from '../../hooks/useStore'; + +type PanelTab = 'stat' | 'alarm' | 'marine'; + +const DUMMY_COUNTS = { vpass: 384, enav: 76, vts: 142, vtsRadar: 62, airAis: 8, vhfDsc: 3 }; +const DUMMY_TOTAL = DUMMY_COUNTS.vpass + DUMMY_COUNTS.enav + DUMMY_COUNTS.vts + DUMMY_COUNTS.vtsRadar + DUMMY_COUNTS.airAis + DUMMY_COUNTS.vhfDsc; + +const CARGO_CODES = new Set(['000023', '000024']); +const FISHING_CODES = new Set(['000020']); +const PASSENGER_CODES = new Set(['000022']); + +const RightPanel: React.FC = () => { + const [activeTab, setActiveTab] = useState('stat'); + const aisTargets = useStore((s) => s.aisTargets); + const aisCount = aisTargets.size; + + const shipBreakdown = useMemo(() => { + let cargo = 0, fishing = 0, passenger = 0, etc = 0; + for (const t of aisTargets.values()) { + if (CARGO_CODES.has(t.signalKindCode)) cargo++; + else if (FISHING_CODES.has(t.signalKindCode)) fishing++; + else if (PASSENGER_CODES.has(t.signalKindCode)) passenger++; + else etc++; + } + return [ + { k: '화물선', v: `${cargo.toLocaleString()}척` }, + { k: '어선', v: `${fishing.toLocaleString()}척` }, + { k: '여객선', v: `${passenger.toLocaleString()}척` }, + { k: '기타', v: `${etc.toLocaleString()}척` }, + ]; + }, [aisTargets]); + + const tabs: {key:PanelTab;label:string}[] = [ + {key:'stat',label:'물표현황'}, + {key:'alarm',label:'경보'}, + {key:'marine',label:'해양정보'}, + ]; + + return ( + + ); +}; + +export default RightPanel; diff --git a/frontend/wing-gis-web/src/components/layout/Sidebar.tsx b/frontend/wing-gis-web/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..34a5d07 --- /dev/null +++ b/frontend/wing-gis-web/src/components/layout/Sidebar.tsx @@ -0,0 +1,371 @@ +import React, { useState } from 'react'; +import { useStore } from '../../hooks/useStore'; + +type SidebarTab = 'layers' | 'poi' | 'fav'; + +/* ── Checkbox component ── */ +const Checkbox: React.FC<{on:boolean;onClick?:()=>void}> = ({on,onClick}) => ( +
+ {on &&
} +
+); + +/* ── S-100 number badge ── */ +const S100Badge: React.FC<{num:string;bg:string}> = ({num,bg}) => ( + {num} +); + +/* ── Tooltip badge ── */ +const TipBadge: React.FC<{bg:string;color:string;title:string}> = ({bg,color,title:tipText}) => { + const [show, setShow] = useState(false); + return ( + setShow(true)} onMouseLeave={()=>setShow(false)} + style={{fontSize:9,padding:'1px 5px',borderRadius:9,background:bg,color,fontWeight:700,cursor:'help',position:'relative'}} + > + ? + {show && ( +
{tipText}
+ )} +
+ ); +}; + +const Sidebar: React.FC = () => { + const { searchQuery, setSearchQuery } = useStore(); + const [activeTab, setActiveTab] = useState('layers'); + const [expanded, setExpanded] = useState>({ + user: true, s100: true, signal: true, safety: false, linked: false + }); + + const toggle = (key: string) => setExpanded(prev => ({...prev, [key]: !prev[key]})); + + const tabs: {key:SidebarTab;label:string}[] = [ + {key:'layers',label:'레이어'}, + {key:'poi',label:'관심지역'}, + {key:'fav',label:'즐겨찾기'}, + ]; + + /* ── Layer item style ── */ + const liStyle = (selected?:boolean, sub?:boolean, gap?:boolean): React.CSSProperties => ({ + display:'flex',alignItems:'center',gap:6, + padding:sub?'4px 10px 4px 32px':'4px 10px 4px 16px', + cursor:'pointer',transition:'.1s', + background:selected?'#d0eef5':'transparent', + ...(gap?{marginTop:2,borderTop:'1px solid #dde5ea'}:{}) + }); + + return ( + + ); +}; + +export default Sidebar; diff --git a/frontend/wing-gis-web/src/components/layout/SubToolbar.tsx b/frontend/wing-gis-web/src/components/layout/SubToolbar.tsx new file mode 100644 index 0000000..c3e0c60 --- /dev/null +++ b/frontend/wing-gis-web/src/components/layout/SubToolbar.tsx @@ -0,0 +1,111 @@ +import React, { useState } from 'react'; +import { useStore } from '../../hooks/useStore'; + +interface ToolButton { + id: string; + icon: string; + label: string; +} + +const toolSets: Record = { + map: [ + { id: 'move', icon: '↔', label: '지도 이동' }, + { id: 'save', icon: '+', label: '원지도 저장' }, + { id: 'info', icon: '📋', label: '지도 정보' }, + { id: 'new', icon: '✚', label: '새지도 생성' }, + { id: 'load', icon: '📂', label: '지도 불러오기' }, + { id: 'saveas', icon: '💾', label: '새 이름으로 저장' }, + 'sep', + { id: 'print', icon: '🖨', label: '지도화면 출력' }, + { id: 'capture', icon: '📸', label: '지도화면 저장' }, + 'sep', + { id: 'distance', icon: '📐', label: '거리 측정' }, + { id: 'area', icon: '⬡', label: '면적 측정' }, + { id: 'select', icon: '🔍', label: '객체 선택' }, + ], + layer: [ + { id: 'upload', icon: '⬆', label: '레이어 추가' }, + { id: 'style', icon: '🎨', label: '스타일 편집' }, + { id: 'attr', icon: '📊', label: '속성 테이블' }, + { id: 'prop', icon: '🔧', label: '레이어 속성' }, + 'sep', + { id: 'delete', icon: '🗑', label: '레이어 삭제' }, + { id: 'export', icon: '⬇', label: '내보내기' }, + 'sep', + { id: 'share', icon: '📤', label: '레이어 공유' }, + { id: 'storage', icon: '🗄', label: '내 저장소' }, + ], + analysis: [ + { id: 'buffer', icon: '◎', label: '반경 분석' }, + { id: 'density', icon: '🔥', label: '밀집도 분석' }, + { id: 'idw', icon: '〰', label: '수치추정' }, + { id: 'zonalstat', icon: '📐', label: '해역별 통계' }, + { id: 'hotspot', icon: '📍', label: '핫스팟' }, + 'sep', + { id: 'grid', icon: '⊞', label: '벡터 분석' }, + { id: 'raster', icon: '⬡', label: '래스터 분석' }, + 'sep', + { id: 'nearest', icon: '📈', label: '통계 분석' }, + ], + theme: [ + { id: 'category', icon: '🎨', label: '범위구분도' }, + { id: 'choropleth', icon: '🗺', label: '단계구분도' }, + { id: 'symbol', icon: '⭕', label: '도형표현도' }, + { id: 'graph', icon: '📊', label: '그래프표현도' }, + { id: 'timeseries', icon: '⏱', label: '시계열표현도' }, + { id: 'heatmap', icon: '🔥', label: '히트맵' }, + 'sep', + { id: 'text', icon: 'A', label: '텍스트 객체' }, + { id: 'legend', icon: '📋', label: '범례 추가' }, + ], + admin: [ + { id: 'users', icon: '👤', label: '사용자 관리' }, + { id: 'org', icon: '🏛', label: '기관·권한' }, + { id: 'commonlayer', icon: '📂', label: '공통 레이어' }, + 'sep', + { id: 'signal', icon: '🔌', label: '물표 연계 현황' }, + { id: 'module', icon: '📡', label: '통합연계모듈' }, + 'sep', + { id: 'log', icon: '📜', label: '접속 로그' }, + { id: 'audit', icon: '🔒', label: '보안 감사' }, + ], +}; + +const SubToolbar: React.FC = () => { + const { activeNav } = useStore(); + const [activeTool, setActiveTool] = useState('move'); + + const tools = toolSets[activeNav] || toolSets.map; + + return ( +
+ {tools.map((tool, i) => { + if (tool === 'sep') { + return
; + } + const isActive = activeTool === tool.id; + return ( + + ); + })} +
+ ); +}; + +export default SubToolbar; diff --git a/frontend/wing-gis-web/src/components/layout/TitleBar.tsx b/frontend/wing-gis-web/src/components/layout/TitleBar.tsx new file mode 100644 index 0000000..aeaf42f --- /dev/null +++ b/frontend/wing-gis-web/src/components/layout/TitleBar.tsx @@ -0,0 +1,245 @@ +import React, { useState, useEffect } from 'react'; +import { useStore } from '../../hooks/useStore'; + +type NavKey = 'map' | 'layer' | 'analysis' | 'theme' | 'admin'; + +interface DropdownItem { + icon: string; + label: string; + section?: string; + sep?: boolean; + active?: boolean; +} + +const navMenus: Record = { + map: { + label: '지도', icon: '🗺', + items: [ + {icon:'',label:'',section:'지도 생성'}, + {icon:'🗺',label:'새 지도 생성',active:true}, + {icon:'📂',label:'지도 불러오기'}, + {icon:'💾',label:'지도 저장'}, + {icon:'📋',label:'지도 정보'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'화면 출력'}, + {icon:'🖨',label:'지도화면 출력'}, + {icon:'📸',label:'지도화면 저장'}, + {icon:'🔗',label:'퍼블리셔 공유'}, + ] + }, + layer: { + label: '레이어', icon: '📂', wide: true, + items: [ + {icon:'',label:'',section:'레이어 등록'}, + {icon:'⬆',label:'레이어 업로드'}, + {icon:'📍',label:'지오코딩 등록'}, + {icon:'🔗',label:'공유 레이어 불러오기'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'레이어 관리'}, + {icon:'🎨',label:'스타일 편집 (SLD)'}, + {icon:'📊',label:'속성 테이블'}, + {icon:'⬇',label:'레이어 내보내기'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'데이터 관리'}, + {icon:'🗄',label:'내 저장소 현황'}, + {icon:'📤',label:'레이어 공유 등록'}, + ] + }, + analysis: { + label: '공간분석', icon: '🔬', cols: true, + items: [] // handled separately for 2-column layout + }, + theme: { + label: '주제도', icon: '🎨', + items: [ + {icon:'',label:'',section:'주제도 유형'}, + {icon:'🗺',label:'단계구분도'}, + {icon:'🎨',label:'범주구분도'}, + {icon:'⭕',label:'도형표현도'}, + {icon:'📊',label:'그래프표현도'}, + {icon:'⏱',label:'시계열 표현도'}, + {icon:'🔥',label:'히트맵'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'부가 요소'}, + {icon:'📝',label:'텍스트 객체'}, + {icon:'📋',label:'범례 객체 추가'}, + ] + }, + admin: { + label: '관리도구', icon: '⚙', right: true, + items: [ + {icon:'',label:'',section:'시스템 관리'}, + {icon:'👤',label:'사용자 관리'}, + {icon:'🏛',label:'기관·권한 관리'}, + {icon:'📂',label:'공통 레이어 관리'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'연계 관리'}, + {icon:'🔌',label:'물표 연계 현황'}, + {icon:'📡',label:'통합연계모듈 상태'}, + {icon:'🌐',label:'외부 API 연계 설정'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'로그·감사'}, + {icon:'📜',label:'접속 로그 조회'}, + {icon:'🔒',label:'보안 감사 로그'}, + ] + }, +}; + +const analysisCol1: DropdownItem[] = [ + {icon:'',label:'',section:'벡터 분석'}, + {icon:'◎',label:'반경(Buffer) 분석'}, + {icon:'✂',label:'Clip / Union'}, + {icon:'⬡',label:'보로노이 맵'}, + {icon:'⊞',label:'그리드 분석'}, + {icon:'🔀',label:'Dissolve / Erase'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'래스터 분석'}, + {icon:'🔥',label:'밀집도 (Kernel)'}, + {icon:'〰',label:'보간 (IDW / TPS)'}, +]; +const analysisCol2: DropdownItem[] = [ + {icon:'',label:'',section:'공간통계'}, + {icon:'📍',label:'핫스팟 분석'}, + {icon:'↔',label:'최근린 분석'}, + {icon:'🔁',label:'공간자기상관'}, + {icon:'🧭',label:'방향분포 분석'}, + {icon:'',label:'',sep:true}, + {icon:'',label:'',section:'통계 분석'}, + {icon:'📐',label:'해역별 통계 분석'}, + {icon:'📈',label:'빈도 통계 분석'}, +]; + +const DropdownMenu: React.FC<{items: DropdownItem[]; wide?: boolean; right?: boolean}> = ({items, wide, right}) => ( +
+ {items.map((item,i)=>{ + if (item.sep) return
; + if (item.section) return
{item.section}
; + return ( +
+ {item.icon}{item.label} +
+ ); + })} +
+); + +const AnalysisDropdown: React.FC = () => ( +
+
+ {analysisCol1.map((item,i)=>{ + if (item.sep) return
; + if (item.section) return
{item.section}
; + return
{item.icon}{item.label}
; + })} +
+
+ {analysisCol2.map((item,i)=>{ + if (item.sep) return
; + if (item.section) return
{item.section}
; + return
{item.icon}{item.label}
; + })} +
+
+); + +const TitleBar: React.FC = () => { + const { activeNav, setActiveNav } = useStore(); + const [clock, setClock] = useState(''); + const [hoveredNav, setHoveredNav] = useState(null); + + useEffect(() => { + const tick = () => { + const t = new Date().toLocaleTimeString('ko-KR', { hour12: false }); + setClock(t); + }; + tick(); + const timer = setInterval(tick, 1000); + return () => clearInterval(timer); + }, []); + + const navKeys: NavKey[] = ['map', 'layer', 'analysis', 'theme', 'admin']; + + return ( +
+ {/* Logo */} +
+
WING-GIS
+
+
WING-GIS
+
해양경찰청 통합 GIS 위치정보시스템
+
+
+ + {/* Nav */} + + + {/* Right section */} +
+ {/* Bell */} +
+ 🔔 +
3
+
+ {/* User */} +
+
+ {'인천지방청 \u00A0|\u00A0 관제관'} +
+ {/* Clock */} +
{clock}
+
+
+ ); +}; + +export default TitleBar; diff --git a/frontend/wing-gis-web/src/components/map/BaseMapSelector.tsx b/frontend/wing-gis-web/src/components/map/BaseMapSelector.tsx new file mode 100644 index 0000000..d265abd --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/BaseMapSelector.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const BASE_MAPS = ['기본지도', '속성지도', '위성지도', '야간지도', '하이브리드']; + +interface BaseMapSelectorProps { + activeIndex?: number; +} + +const BaseMapSelector: React.FC = ({ activeIndex = 0 }) => ( +
+ {BASE_MAPS.map((t, i) => ( +
{t}
+ ))} +
3D지도
+
+); + +export default BaseMapSelector; diff --git a/frontend/wing-gis-web/src/components/map/CoordStatusBar.tsx b/frontend/wing-gis-web/src/components/map/CoordStatusBar.tsx new file mode 100644 index 0000000..ae4fba8 --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/CoordStatusBar.tsx @@ -0,0 +1,27 @@ +import React from 'react'; + +interface CoordStatusBarProps { + coord?: string; + epsg?: string; + scale?: string; + zoom?: number; +} + +const CoordStatusBar: React.FC = ({ + coord = '37°28\'14"N 126°38\'52"E', + epsg = 'EPSG:4326', + scale = '1:50,000', + zoom = 10, +}) => ( +
+ {coord} +
+ {epsg} +
+ 축척 {scale} +
+ Zoom {zoom} +
+); + +export default CoordStatusBar; diff --git a/frontend/wing-gis-web/src/components/map/MapLegend.tsx b/frontend/wing-gis-web/src/components/map/MapLegend.tsx new file mode 100644 index 0000000..ccb1dd7 --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/MapLegend.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useStore } from '../../hooks/useStore'; + +const STATIC_LEGEND = [ + { sym: '▲', color: '#1a8a3a', name: 'V-Pass', count: '384' }, + { sym: '▲', color: '#8833cc', name: 'E-Navigation', count: '76' }, + { sym: '■', color: '#cc6600', name: 'VTS', count: '142', size: 10 }, + { sym: '◆', color: '#cc3300', name: 'VTS-레이더타겟', count: '62', size: 10 }, + { sym: '✈', color: '#0099aa', name: '항공기AIS', count: '8' }, + { sym: '▲', color: '#d63030', name: 'VHF-DSC 조난', count: '3' }, +]; + +const MapLegend: React.FC = () => { + const aisCount = useStore((s) => s.aisTargets.size); + const VESSEL_LEGEND = [ + { sym: '▲', color: '#0055cc', name: 'AIS', count: aisCount.toLocaleString() }, + ...STATIC_LEGEND, + ]; + + return ( +
+
물표 범례
+ {VESSEL_LEGEND.map((l, i) => ( +
+ {l.sym} + {l.name} + {l.count} +
+ ))} +
+
+ + EEZ +
+
+ + NLL +
+
+ + 등대 · 부표 +
+
+
+ ); +}; + +export default MapLegend; diff --git a/frontend/wing-gis-web/src/components/map/MapTools.tsx b/frontend/wing-gis-web/src/components/map/MapTools.tsx new file mode 100644 index 0000000..709a993 --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/MapTools.tsx @@ -0,0 +1,52 @@ +import React, { useCallback } from 'react'; +import { useMapContext } from '../../context/MapContext'; +import { DEFAULT_CENTER, DEFAULT_ZOOM } from '../../lib/map/mapConstants'; + +const MAP_TOOLS = [ + { t: '이동', icon: '↔', action: 'pan' }, + { t: '확대', icon: '+', action: 'zoomIn' }, + { t: '축소', icon: '-', action: 'zoomOut' }, + { t: '초기화', icon: '⌂', action: 'reset' }, + { t: '거리측정', icon: '📏', action: 'measureDist' }, + { t: '면적측정', icon: '⬡', action: 'measureArea' }, + { t: '객체선택', icon: '🔍', action: 'select' }, +] as const; + +const MapTools: React.FC = () => { + const { mapRef } = useMapContext(); + + const handleClick = useCallback((action: string) => { + const map = mapRef.current; + if (!map) return; + + switch (action) { + case 'zoomIn': + map.zoomIn(); + break; + case 'zoomOut': + map.zoomOut(); + break; + case 'reset': + map.flyTo({ center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, duration: 1000 }); + break; + } + }, [mapRef]); + + return ( +
+ {MAP_TOOLS.map((b, i) => ( + + ))} +
+ ); +}; + +export default MapTools; diff --git a/frontend/wing-gis-web/src/components/map/MapViewML.tsx b/frontend/wing-gis-web/src/components/map/MapViewML.tsx new file mode 100644 index 0000000..b42fed2 --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/MapViewML.tsx @@ -0,0 +1,116 @@ +import React, { useRef, useEffect, useCallback, useMemo } from 'react'; +import type { PickingInfo } from '@deck.gl/core'; +import { useMap } from '../../hooks/useMap'; +import { useDeckLayers } from '../../hooks/useDeckLayers'; +import { useVesselDeckLayer } from '../../features/vesselLayer/hooks/useVesselDeckLayer'; +import { useNauticalChartOverlay } from '../../features/nauticalChart/hooks/useNauticalChartOverlay'; +import NauticalChartToggle from '../../features/nauticalChart/ui/NauticalChartToggle'; +import ShipImageModal from '../../features/shipImage/ui/ShipImageModal'; +import { getShipTooltipHtml } from '../../features/vesselLayer/lib/shipTooltip'; +import type { AisFeature } from '../../features/vesselLayer/lib/aisAdapter'; +import { useStore } from '../../hooks/useStore'; +import { MapContext } from '../../context/MapContext'; +import { formatCoord, zoomToScale } from '../../utils/coordinate'; +import MapTools from './MapTools'; +import BaseMapSelector from './BaseMapSelector'; +import ScaleBar from './ScaleBar'; +import MapLegend from './MapLegend'; +import CoordStatusBar from './CoordStatusBar'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +interface MapViewMLProps { + children?: React.ReactNode; +} + +const MapViewML: React.FC = ({ children }) => { + const containerRef = useRef(null); + const { mapRef, overlayRef, mapSyncEpoch, initMap, destroyMap } = useMap(); + + const [coord, setCoord] = React.useState('—'); + const [scaleText, setScaleText] = React.useState('1:50,000'); + const [zoom, setZoom] = React.useState(10); + + const photoModal = useStore((s) => s.photoModal); + const openPhotoModal = useStore((s) => s.openPhotoModal); + const closePhotoModal = useStore((s) => s.closePhotoModal); + + const vesselLayers = useVesselDeckLayer(mapRef, mapSyncEpoch); + + const deckCallbacks = useMemo(() => ({ + getTooltip: (info: PickingInfo) => { + const obj = info.object as AisFeature | undefined; + if (!obj || !('signalKindCode' in obj)) return null; + return getShipTooltipHtml(obj); + }, + onClick: (info: PickingInfo) => { + const obj = info.object as AisFeature | undefined; + if (!obj || !('signalKindCode' in obj)) return; + if (obj.shipImagePath && obj.shipImageCount && obj.shipImageCount > 0) { + openPhotoModal({ + imo: obj.imo, + name: obj.name || `MMSI ${obj.mmsi}`, + imagePath: obj.shipImagePath, + imageCount: obj.shipImageCount, + }); + } + }, + }), [openPhotoModal]); + + useDeckLayers(overlayRef, vesselLayers, deckCallbacks); + + const nauticalChartSettings = useStore((s) => s.nauticalChartSettings); + useNauticalChartOverlay(mapRef, nauticalChartSettings, { mapSyncEpoch }); + + useEffect(() => { + if (!containerRef.current) return; + initMap(containerRef.current); + return destroyMap; + }, [initMap, destroyMap]); + + const handleMapEvents = useCallback(() => { + const map = mapRef.current; + if (!map) return; + + map.on('mousemove', (e) => { + const { lng, lat } = e.lngLat; + setCoord(formatCoord(lat, lng)); + }); + + map.on('zoomend', () => { + const z = Math.round(map.getZoom()); + setZoom(z); + setScaleText(zoomToScale(z)); + }); + }, [mapRef]); + + useEffect(() => { + if (mapSyncEpoch > 0) handleMapEvents(); + }, [mapSyncEpoch, handleMapEvents]); + + return ( + +
+
+ + + + + + {children} + + + {photoModal && ( + + )} +
+ + ); +}; + +export default MapViewML; diff --git a/frontend/wing-gis-web/src/components/map/MockSeaChart.tsx b/frontend/wing-gis-web/src/components/map/MockSeaChart.tsx new file mode 100644 index 0000000..17f57be --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/MockSeaChart.tsx @@ -0,0 +1,140 @@ +import React from 'react'; + +const MockSeaChart: React.FC = () => ( + <> + {/* Grid overlay */} +
+ {/* Depth gradient */} +
+ + + + + + + + + + + + + + + + + + + {/* Depth contours */} + + + + + + + + {/* Depth contour labels */} + 5 + 10 + 20 + 50 + 100 + 200 + 10 + 20 + 50 + + {/* Coastline */} + + + + {/* Islands */} + + 덕적도 + + 영흥도 + + 강화도 + + + 교동도 + + + {/* Rocks */} + + + + + + + + {/* Shallow areas */} + + + + {/* EEZ */} + + EEZ + 배타적 경제수역 + + {/* NLL */} + + NLL + + {/* Jurisdiction */} + + 인천 관할해역 + + {/* Patrol route */} + + 순찰항로 + + {/* Tracks */} + + + + + + {/* Lighthouses */} + + + Fl.4s + + + Fl.3s + + + + + + {/* Depth numbers */} + 24 + 38 + 52 + 67 + 43 + 29 + 81 + 114 + 98 + 15 + 12 + + {/* Sea name */} + {'서 해'} + Yellow Sea + + {/* Lat/Lon grid */} + + + + + + + 38°N + 37°N + 36°N + 125°E + 126°E + 127°E + + +); + +export default MockSeaChart; diff --git a/frontend/wing-gis-web/src/components/map/ScaleBar.tsx b/frontend/wing-gis-web/src/components/map/ScaleBar.tsx new file mode 100644 index 0000000..4c429d7 --- /dev/null +++ b/frontend/wing-gis-web/src/components/map/ScaleBar.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +interface ScaleBarProps { + text?: string; +} + +const ScaleBar: React.FC = ({ text = '1 : 50,000 ━━━ 5 km' }) => ( +
+ {text} +
+); + +export default ScaleBar; diff --git a/frontend/wing-gis-web/src/components/vessel/VesselPopup.tsx b/frontend/wing-gis-web/src/components/vessel/VesselPopup.tsx new file mode 100644 index 0000000..56b7f1e --- /dev/null +++ b/frontend/wing-gis-web/src/components/vessel/VesselPopup.tsx @@ -0,0 +1,105 @@ +import { useStore } from '../../hooks/useStore'; + +/** + * 선박 미니 팝업 (지도 위 클릭 시 표시) + * WING-GIS 프로토타입의 .vp 스타일 + */ +export default function VesselPopup() { + const vessel = useStore((s) => s.selectedVessel); + const setSelectedVessel = useStore((s) => s.setSelectedVessel); + + if (!vessel) return null; + + const isAlert = vessel.status?.includes('SOS') || vessel.source === 'VHF-DSC'; + + return ( +
+ {/* Header */} +
+
+ + {vessel.flag || 'KR'} + +
+
{vessel.name || '—'}
+
+ MMSI: {vessel.mmsi || '—'} +
+
+
+ setSelectedVessel(null)} + style={{ position: 'absolute', top: 7, right: 9, color: 'rgba(255,255,255,0.45)', cursor: 'pointer', fontSize: 16 }} + > + ✕ + +
+ + {/* Ship icon */} +
+ 🚢 +
+ + {/* Tags */} +
+ + {vessel.shipType || vessel.source} + + + {vessel.status || '항해중'} + +
+ + {/* Info rows */} +
+ + + + +
+ + {/* Footer buttons */} +
+ {['📋 상세정보', '🗺 항적조회', '🧭 항로예측'].map((label) => ( + + ))} +
+
+ ); +} + +function InfoRow({ label, value, highlight, dim }: { label: string; value: string; highlight?: boolean; dim?: boolean }) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/frontend/wing-gis-web/src/components/vessel/VesselSearch.tsx b/frontend/wing-gis-web/src/components/vessel/VesselSearch.tsx new file mode 100644 index 0000000..3288be9 --- /dev/null +++ b/frontend/wing-gis-web/src/components/vessel/VesselSearch.tsx @@ -0,0 +1,160 @@ +import { useState, useCallback } from 'react'; +import { useStore } from '../../hooks/useStore'; +import { VESSEL_COLORS, type VesselSource, type Vessel } from '../../types/vessel'; + +/** + * SFR-08: 선박 통합 검색 컴포넌트 + * 선박명/MMSI/호출부호/선종/국적/소스 검색 + */ +export default function VesselSearch() { + const vessels = useStore((s) => s.vessels); + const setSelectedVessel = useStore((s) => s.setSelectedVessel); + const [query, setQuery] = useState(''); + const [typeFilter, setTypeFilter] = useState(''); + const [srcFilter, setSrcFilter] = useState(''); + + const results = useCallback(() => { + let filtered = vessels; + const q = query.toLowerCase().trim(); + if (q) { + filtered = filtered.filter( + (v) => + v.name?.toLowerCase().includes(q) || + v.mmsi?.replace(/\s/g, '').includes(q.replace(/\s/g, '')) || + v.callsign?.toLowerCase().includes(q) || + v.imo?.includes(q) + ); + } + if (typeFilter) filtered = filtered.filter((v) => v.shipType === typeFilter); + if (srcFilter) filtered = filtered.filter((v) => v.source === srcFilter); + return filtered; + }, [vessels, query, typeFilter, srcFilter]); + + const highlight = (text: string) => { + if (!query.trim() || !text) return text; + const re = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); + return text.replace(re, '$1'); + }; + + const hasFilter = query || typeFilter || srcFilter; + const list = hasFilter ? results() : []; + + return ( +
+ {/* 검색 입력 */} +
+
+ setQuery(e.target.value)} + style={{ flex: 1, border: 'none', outline: 'none', fontSize: 12, fontFamily: 'inherit' }} + /> + 🔍 +
+
+ + {/* 필터 */} + {hasFilter && ( +
+ + + +
+ )} + + {/* 검색 결과 */} + {hasFilter && ( +
+
+ {list.length}건 +
+
+ {list.length === 0 ? ( +
검색 결과가 없습니다.
+ ) : ( + list.map((v) => ( + setSelectedVessel(v)} /> + )) + )} +
+
+ )} +
+ ); +} + +function VesselResultItem({ vessel: v, highlight, onClick }: { + vessel: Vessel; + highlight: (t: string) => string; + onClick: () => void; +}) { + const color = VESSEL_COLORS[v.source as VesselSource] || '#666'; + const isAlert = v.status?.includes('SOS') || v.source === 'VHF-DSC'; + + return ( +
(e.currentTarget.style.background = '#edf6f9')} + onMouseOut={(e) => (e.currentTarget.style.background = '')} + > +
+ {v.source === 'AIR-AIS' ? '✈' : '🚢'} +
+
+
${highlight(v.mmsi || '')}` }} + /> +
+ {v.shipType || '—'} · {v.source} · {v.sog ?? '—'} kn +
+
+ + {v.status || '정상'} + +
+ ); +} diff --git a/frontend/wing-gis-web/src/components/vessel/VesselShape.tsx b/frontend/wing-gis-web/src/components/vessel/VesselShape.tsx new file mode 100644 index 0000000..e6b2a66 --- /dev/null +++ b/frontend/wing-gis-web/src/components/vessel/VesselShape.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { vesselColors } from '../../data/mockVessels'; + +interface VesselShapeProps { + type: 'tri' | 'sq' | 'dia' | 'plane'; + cls: string; +} + +const VesselShape: React.FC = ({ type, cls }) => { + const color = vesselColors[cls] || '#666'; + if (type === 'tri') return
; + if (type === 'sq') return
; + if (type === 'dia') return
; + if (type === 'plane') return
{'\u2708'}
; + return null; +}; + +export default VesselShape; diff --git a/frontend/wing-gis-web/src/components/vessel/VesselSignalToggle.tsx b/frontend/wing-gis-web/src/components/vessel/VesselSignalToggle.tsx new file mode 100644 index 0000000..e60292a --- /dev/null +++ b/frontend/wing-gis-web/src/components/vessel/VesselSignalToggle.tsx @@ -0,0 +1,73 @@ +import { useStore } from '../../hooks/useStore'; +import { VESSEL_COLORS, type VesselSource } from '../../types/vessel'; + +const SIGNALS: { key: VesselSource; label: string; icon: string }[] = [ + { key: 'AIS', label: 'AIS', icon: '▲' }, + { key: 'V-Pass', label: 'V-Pass', icon: '▲' }, + { key: 'E-NAV', label: 'E-Navigation', icon: '▲' }, + { key: 'VTS', label: 'VTS', icon: '■' }, + { key: 'VTS-RADAR', label: 'VTS-레이더타겟', icon: '◆' }, + { key: 'AIR-AIS', label: '항공기AIS', icon: '✈' }, + { key: 'VHF-DSC', label: 'VHF-DSC 조난', icon: '▲' }, +]; + +/** + * 물표 신호 ON/OFF 토글 리스트 (사이드바 레이어 트리 내) + */ +export default function VesselSignalToggle() { + const signalState = useStore((s) => s.signalState); + const toggleSignal = useStore((s) => s.toggleSignal); + const vessels = useStore((s) => s.vessels); + + const count = (source: VesselSource) => vessels.filter((v) => v.source === source).length; + + return ( +
+ {SIGNALS.map(({ key, label, icon }) => { + const on = signalState[key]; + const color = VESSEL_COLORS[key]; + const cnt = count(key); + + return ( +
toggleSignal(key)} + style={{ + display: 'flex', alignItems: 'center', gap: 6, padding: '4px 10px 4px 16px', + cursor: 'pointer', transition: 'background 0.1s', + background: on ? undefined : '#fafafa', + }} + onMouseOver={(e) => (e.currentTarget.style.background = '#edf6f9')} + onMouseOut={(e) => (e.currentTarget.style.background = on ? '' : '#fafafa')} + > + {/* 체크박스 */} +
+ {on && } +
+ + {/* 아이콘 */} + {icon} + + {/* 이름 */} + {label} + + {/* 카운트 배지 */} + + {cnt} + +
+ ); + })} +
+ ); +} diff --git a/frontend/wing-gis-web/src/context/MapContext.tsx b/frontend/wing-gis-web/src/context/MapContext.tsx new file mode 100644 index 0000000..1c7ba4e --- /dev/null +++ b/frontend/wing-gis-web/src/context/MapContext.tsx @@ -0,0 +1,17 @@ +import { createContext, useContext, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; + +interface MapContextValue { + mapRef: MutableRefObject; + overlayRef: MutableRefObject; + mapSyncEpoch: number; +} + +export const MapContext = createContext(null); + +export function useMapContext(): MapContextValue { + const ctx = useContext(MapContext); + if (!ctx) throw new Error('useMapContext must be used within MapContext.Provider'); + return ctx; +} diff --git a/frontend/wing-gis-web/src/data/mockVessels.ts b/frontend/wing-gis-web/src/data/mockVessels.ts new file mode 100644 index 0000000..f999576 --- /dev/null +++ b/frontend/wing-gis-web/src/data/mockVessels.ts @@ -0,0 +1,72 @@ +export interface MockVesselInfo { + mmsi: string; + imo: string; + flag: string; + icon: string; + type: string; + status: string; + spd: string; + draft: string; + from: string; + to: string; + src: string; +} + +export const vesselDB: Record = { + '대양호': {mmsi:'440 234 001',imo:'9234567',flag:'KR',icon:'🚢',type:'Bulk', status:'항해중', spd:'12.4 kn / 170°',draft:'10.3m',from:'군산항',to:'포항항', src:'AIS'}, + '한성호': {mmsi:'440 891 002',imo:'9345678',flag:'KR',icon:'🚢',type:'Tanker', status:'항해중', spd:'9.8 kn / 220°', draft:'8.6m', from:'인천항',to:'여수항', src:'AIS'}, + '청해호': {mmsi:'440 312 003',imo:'9456789',flag:'KR',icon:'🚢',type:'Cargo', status:'항해중', spd:'11.2 kn / 185°',draft:'7.1m', from:'평택항',to:'부산항', src:'AIS'}, + '서해301': {mmsi:'440 501 004',imo:'9567890',flag:'KR',icon:'🚢',type:'Cargo', status:'항해중', spd:'8.3 kn / 155°', draft:'6.4m', from:'인천항',to:'목포항', src:'AIS'}, + '동양호': {mmsi:'440 702 005',imo:'9678901',flag:'KR',icon:'🚢',type:'Bulk', status:'정박중', spd:'0.0 kn / —', draft:'11.2m',from:'광양항',to:'인천항', src:'AIS'}, + '평화호': {mmsi:'440 103 006',imo:'9789012',flag:'KR',icon:'🚢',type:'Cargo', status:'항해중', spd:'10.1 kn / 200°',draft:'5.8m', from:'인천항',to:'완도항', src:'AIS'}, + '부안201': {mmsi:'441 001 101',imo:'—', flag:'KR',icon:'🎣',type:'어선', status:'조업중', spd:'3.2 kn / 090°', draft:'2.1m', from:'부안항',to:'조업지', src:'V-Pass'}, + '신안어01': {mmsi:'441 002 102',imo:'—', flag:'KR',icon:'🎣',type:'어선', status:'조업중', spd:'2.8 kn / 045°', draft:'1.9m', from:'신안항',to:'조업지', src:'V-Pass'}, + '충남3호': {mmsi:'441 003 103',imo:'—', flag:'KR',icon:'🎣',type:'어선', status:'입항중', spd:'5.1 kn / 310°', draft:'2.4m', from:'조업지',to:'태안항', src:'V-Pass'}, + 'TGT-VTS#021':{mmsi:'—', imo:'—', flag:'UN',icon:'🔶',type:'VTS', status:'관제중', spd:'8.2 kn / 090°', draft:'—', from:'—', to:'—', src:'VTS'}, + 'TGT#047': {mmsi:'—', imo:'—', flag:'XX',icon:'❓',type:'레이더', status:'위험', spd:'— / —', draft:'—', from:'—', to:'—', src:'VTS-레이더'}, + 'TGT#012': {mmsi:'—', imo:'—', flag:'XX',icon:'❓',type:'레이더', status:'추적중', spd:'— / —', draft:'—', from:'—', to:'—', src:'VTS-레이더'}, + '홍성호': {mmsi:'440 123 456',imo:'9112233',flag:'KR',icon:'🚨',type:'화물선', status:'SOS 경보',spd:'0.2 kn / —', draft:'6.8m', from:'인천항',to:'군산항', src:'AIS+V-Pass'}, + 'KCG-601': {mmsi:'447 601 001',imo:'—', flag:'KR',icon:'✈', type:'항공기', status:'비행중', spd:'120 kn / 265°', draft:'—', from:'인천기지',to:'순찰', src:'항공기AIS'}, + 'KCG-202': {mmsi:'447 202 001',imo:'—', flag:'KR',icon:'✈', type:'항공기', status:'비행중', spd:'135 kn / 180°', draft:'—', from:'목포기지',to:'순찰', src:'항공기AIS'}, +}; + +export interface VesselMarkerData { + name: string; + left: string; + top: string; + type: 'tri' | 'sq' | 'dia' | 'plane'; + cls: string; + label: string; +} + +export const vesselMarkers: VesselMarkerData[] = [ + {name:'대양호',left:'35%',top:'42%',type:'tri',cls:'v-ais',label:'대양호 ▲AIS'}, + {name:'한성호',left:'44%',top:'36%',type:'tri',cls:'v-ais',label:'한성호 ▲AIS'}, + {name:'청해호',left:'52%',top:'31%',type:'tri',cls:'v-ais',label:'청해호 ▲AIS'}, + {name:'서해301',left:'66%',top:'37%',type:'tri',cls:'v-ais',label:'서해301 ▲AIS'}, + {name:'동양호',left:'22%',top:'44%',type:'tri',cls:'v-ais',label:'동양호 ▲AIS'}, + {name:'평화호',left:'33%',top:'34%',type:'tri',cls:'v-ais',label:'평화호 ▲AIS'}, + {name:'부안201',left:'28%',top:'59%',type:'tri',cls:'v-vpass',label:'부안201 ▲V-Pass'}, + {name:'신안어01',left:'40%',top:'66%',type:'tri',cls:'v-vpass',label:'신안어01 ▲V-Pass'}, + {name:'충남3호',left:'60%',top:'62%',type:'tri',cls:'v-vpass',label:'충남3호 ▲V-Pass'}, + {name:'e-Nav',left:'56%',top:'44%',type:'tri',cls:'v-enav',label:'e-Nav 선박 ▲e-NAV'}, + {name:'인천1234',left:'42%',top:'30%',type:'tri',cls:'v-enav',label:'인천1234 ▲e-NAV'}, + {name:'TGT-VTS#021',left:'70%',top:'44%',type:'sq',cls:'v-vts',label:'■VTS#021'}, + {name:'VTS#008',left:'26%',top:'48%',type:'sq',cls:'v-vts',label:'■VTS#008'}, + {name:'TGT#047',left:'58%',top:'47%',type:'dia',cls:'v-vtsradar',label:'◆RADAR#047'}, + {name:'TGT#012',left:'72%',top:'53%',type:'dia',cls:'v-vtsradar',label:'◆RADAR#012'}, + {name:'RADAR#033',left:'45%',top:'56%',type:'dia',cls:'v-vtsradar',label:'◆RADAR#033'}, + {name:'KCG-601',left:'62%',top:'28%',type:'plane',cls:'v-airais',label:'✈ KCG-601 항공기AIS'}, + {name:'KCG-202',left:'38%',top:'22%',type:'plane',cls:'v-airais',label:'✈ KCG-202 항공기AIS'}, + {name:'홍성호',left:'48%',top:'53%',type:'tri',cls:'v-vhfdsc',label:'⚠ 홍성호 VHF-DSC SOS'}, +]; + +export const vesselColors: Record = { + 'v-ais': '#0055cc', + 'v-vpass': '#1a8a3a', + 'v-enav': '#8833cc', + 'v-vts': '#cc6600', + 'v-vtsradar': '#cc3300', + 'v-airais': '#0099aa', + 'v-vhfdsc': '#d63030', +}; diff --git a/frontend/wing-gis-web/src/features/nauticalChart/hooks/useNauticalChartOverlay.ts b/frontend/wing-gis-web/src/features/nauticalChart/hooks/useNauticalChartOverlay.ts new file mode 100644 index 0000000..2d68592 --- /dev/null +++ b/frontend/wing-gis-web/src/features/nauticalChart/hooks/useNauticalChartOverlay.ts @@ -0,0 +1,89 @@ +import { useEffect, useRef, useState, type MutableRefObject } from 'react'; +import type maplibregl from 'maplibre-gl'; +import type { NauticalChartSettings } from '../model/types'; +import { fetchNauticalStyle, type NauticalStyleData } from '../lib/fetchNauticalStyle'; +import { + injectNauticalLayers, + removeNauticalLayers, + applyS52Theme, + moveNauticalToTop, + type NauticalLayerState, +} from '../lib/nauticalLayerManager'; +import { onMapStyleReady, kickRepaint } from '../../../lib/map/mapCore'; + +export function useNauticalChartOverlay( + mapRef: MutableRefObject, + settings: NauticalChartSettings | undefined, + opts: { mapSyncEpoch: number }, +) { + const stateRef = useRef(null); + const { mapSyncEpoch } = opts; + + const [styleData, setStyleData] = useState(null); + + useEffect(() => { + if (styleData) return; + let cancelled = false; + fetchNauticalStyle() + .then((data) => { if (!cancelled) setStyleData(data); }) + .catch((err) => { console.warn('[NauticalChart] Failed to load:', err); }); + return () => { cancelled = true; }; + }, [styleData]); + + useEffect(() => { + const map = mapRef.current; + if (!map) return; + + if (!settings?.enabled) { + if (stateRef.current) { + removeNauticalLayers(map, stateRef.current); + stateRef.current = null; + kickRepaint(map); + } + return; + } + + if (!styleData) return; + + const stop = onMapStyleReady(map, () => { + if (stateRef.current) { + const firstLayer = stateRef.current.layerIds[0]; + if (!firstLayer || !map.getLayer(firstLayer)) { + stateRef.current = null; + } + } + + if (!stateRef.current) { + stateRef.current = injectNauticalLayers(map, styleData); + } + + moveNauticalToTop(map, stateRef.current.layerIds); + applyS52Theme(map, stateRef.current.layerIds, settings.theme); + kickRepaint(map); + }); + + const onIdle = () => { + if (stateRef.current && map.getLayer(stateRef.current.layerIds[0])) { + moveNauticalToTop(map, stateRef.current.layerIds); + } + }; + map.once('idle', onIdle); + + return () => { + stop(); + try { map.off('idle', onIdle); } catch { /* ignore */ } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings?.enabled, settings?.theme, mapSyncEpoch, styleData]); + + useEffect(() => { + const mapInstance = mapRef.current; + return () => { + if (mapInstance && stateRef.current) { + removeNauticalLayers(mapInstance, stateRef.current); + stateRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +} diff --git a/frontend/wing-gis-web/src/features/nauticalChart/lib/fetchNauticalStyle.ts b/frontend/wing-gis-web/src/features/nauticalChart/lib/fetchNauticalStyle.ts new file mode 100644 index 0000000..5cb0487 --- /dev/null +++ b/frontend/wing-gis-web/src/features/nauticalChart/lib/fetchNauticalStyle.ts @@ -0,0 +1,48 @@ +import type { StyleSpecification } from 'maplibre-gl'; + +const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json'; + +const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular']; + +export interface NauticalStyleData { + sources: Record; + layers: unknown[]; + sprite: string; + glyphs: string; +} + +let cachedFull: StyleSpecification | null = null; +let cachedParts: NauticalStyleData | null = null; + +export async function fetchEncStyle(signal?: AbortSignal): Promise { + if (cachedFull) return cachedFull; + + const res = await fetch(NAUTICAL_STYLE_URL, { signal }); + if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`); + const style = (await res.json()) as StyleSpecification; + + for (const layer of style.layers) { + const layout = (layer as { layout?: Record }).layout; + if (!layout) continue; + const tf = layout['text-font']; + if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) { + layout['text-font'] = SERVER_FONTS; + } + } + + cachedFull = style; + return style; +} + +export async function fetchNauticalStyle(signal?: AbortSignal): Promise { + if (cachedParts) return cachedParts; + + const style = await fetchEncStyle(signal); + cachedParts = { + sources: (style.sources ?? {}) as Record, + layers: style.layers as unknown[], + sprite: (style.sprite ?? '') as string, + glyphs: (style.glyphs ?? '') as string, + }; + return cachedParts; +} diff --git a/frontend/wing-gis-web/src/features/nauticalChart/lib/nauticalLayerManager.ts b/frontend/wing-gis-web/src/features/nauticalChart/lib/nauticalLayerManager.ts new file mode 100644 index 0000000..af9a1f8 --- /dev/null +++ b/frontend/wing-gis-web/src/features/nauticalChart/lib/nauticalLayerManager.ts @@ -0,0 +1,88 @@ +import type maplibregl from 'maplibre-gl'; +import type { S52Theme } from '../model/types'; +import { S52_THEME_COLORS } from '../model/types'; + +const NAUTICAL_SPRITE_ID = 'nautical-enc'; + +export interface NauticalLayerState { + sourceIds: string[]; + layerIds: string[]; + spriteId: string; +} + +export function injectNauticalLayers( + map: maplibregl.Map, + data: { sources: Record; layers: unknown[]; sprite: string }, +): NauticalLayerState { + const sourceIds: string[] = []; + const layerIds: string[] = []; + + for (const [id, spec] of Object.entries(data.sources)) { + if (!map.getSource(id)) { + map.addSource(id, spec as maplibregl.SourceSpecification); + sourceIds.push(id); + } + } + + try { + map.addSprite(NAUTICAL_SPRITE_ID, data.sprite); + } catch { /* sprite already exists */ } + + for (const layer of data.layers) { + const l = layer as Record; + if (!l.id || typeof l.id !== 'string') continue; + if (l.type === 'background') continue; + if (!map.getLayer(l.id)) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { minzoom, maxzoom, ...rest } = l; + map.addLayer(rest as maplibregl.LayerSpecification); + layerIds.push(l.id as string); + } catch { /* ignore */ } + } + } + + return { sourceIds, layerIds, spriteId: NAUTICAL_SPRITE_ID }; +} + +export function removeNauticalLayers(map: maplibregl.Map, state: NauticalLayerState | null) { + if (!state) return; + + for (let i = state.layerIds.length - 1; i >= 0; i--) { + try { if (map.getLayer(state.layerIds[i])) map.removeLayer(state.layerIds[i]); } catch { /* ignore */ } + } + try { map.removeSprite(state.spriteId); } catch { /* ignore */ } + for (const id of state.sourceIds) { + try { if (map.getSource(id)) map.removeSource(id); } catch { /* ignore */ } + } +} + +export function moveNauticalToTop(map: maplibregl.Map, layerIds: string[]) { + for (const id of layerIds) { + try { if (map.getLayer(id)) map.moveLayer(id); } catch { /* ignore */ } + } +} + +export function applyS52Theme(map: maplibregl.Map, layerIds: string[], theme: S52Theme) { + const colors = S52_THEME_COLORS[theme]; + + for (const id of layerIds) { + if (!map.getLayer(id)) continue; + try { + const lower = id.toLowerCase(); + if (lower.includes('depare') && lower.includes('very') && lower.includes('shallow')) { + map.setPaintProperty(id, 'fill-color', colors.DEPVS); + } else if (lower.includes('depare') && lower.includes('medium') && lower.includes('shallow')) { + map.setPaintProperty(id, 'fill-color', colors.DEPMS); + } else if (lower.includes('depare') && lower.includes('medium') && lower.includes('deep')) { + map.setPaintProperty(id, 'fill-color', colors.DEPMD); + } else if (lower.includes('depare') && lower.includes('deep')) { + map.setPaintProperty(id, 'fill-color', colors.DEPDW); + } else if (lower.includes('lndare')) { + map.setPaintProperty(id, 'fill-color', colors.LANDA); + } else if (lower.includes('coalne') || lower.includes('cstln')) { + map.setPaintProperty(id, 'line-color', colors.CSTLN); + } + } catch { /* ignore */ } + } +} diff --git a/frontend/wing-gis-web/src/features/nauticalChart/model/types.ts b/frontend/wing-gis-web/src/features/nauticalChart/model/types.ts new file mode 100644 index 0000000..9e7f9e5 --- /dev/null +++ b/frontend/wing-gis-web/src/features/nauticalChart/model/types.ts @@ -0,0 +1,46 @@ +export type S52Theme = 'day' | 'dusk' | 'night'; + +export interface NauticalChartSettings { + enabled: boolean; + theme: S52Theme; +} + +export const DEFAULT_NAUTICAL_CHART_SETTINGS: NauticalChartSettings = { + enabled: false, + theme: 'day', +}; + +export const S52_THEMES: { value: S52Theme; label: string }[] = [ + { value: 'day', label: '주간' }, + { value: 'dusk', label: '황혼' }, + { value: 'night', label: '야간' }, +]; + +export const MARTIN_BASE_PATH = '/martin'; + +export const S52_THEME_COLORS: Record> = { + day: { + DEPVS: '#73B6EF', + DEPMS: '#98C5F2', + DEPMD: '#BAD5E1', + DEPDW: '#D4EAEE', + LANDA: '#C9B97A', + CSTLN: '#525A5C', + }, + dusk: { + DEPVS: '#16232F', + DEPMS: '#151B21', + DEPMD: '#0C0E0F', + DEPDW: '#070707', + LANDA: '#2C291B', + CSTLN: '#363C3D', + }, + night: { + DEPVS: '#030413', + DEPMS: '#030413', + DEPMD: '#070707', + DEPDW: '#070707', + LANDA: '#0D0A08', + CSTLN: '#252929', + }, +}; diff --git a/frontend/wing-gis-web/src/features/nauticalChart/ui/NauticalChartToggle.tsx b/frontend/wing-gis-web/src/features/nauticalChart/ui/NauticalChartToggle.tsx new file mode 100644 index 0000000..342b9ee --- /dev/null +++ b/frontend/wing-gis-web/src/features/nauticalChart/ui/NauticalChartToggle.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { useStore } from '../../../hooks/useStore'; +import { S52_THEMES } from '../model/types'; + +const NauticalChartToggle: React.FC = () => { + const settings = useStore((s) => s.nauticalChartSettings); + const setSettings = useStore((s) => s.setNauticalChartSettings); + + return ( +
+ + {settings.enabled && ( +
+ {S52_THEMES.map((t) => ( + + ))} +
+ )} +
+ ); +}; + +export default NauticalChartToggle; diff --git a/frontend/wing-gis-web/src/features/shipImage/api/shipImageApi.ts b/frontend/wing-gis-web/src/features/shipImage/api/shipImageApi.ts new file mode 100644 index 0000000..5fe7729 --- /dev/null +++ b/frontend/wing-gis-web/src/features/shipImage/api/shipImageApi.ts @@ -0,0 +1,34 @@ +export interface ShipImageInfo { + picId: number; + path: string; + copyright: string; + date: string; +} + +const BASE = '/signal-batch'; + +export async function fetchShipImagesByImo( + imo: number, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${BASE}/api/v2/shipimg/${imo}`, { signal, headers: { accept: 'application/json' } }); + if (!res.ok) return []; + const json: unknown = await res.json(); + return Array.isArray(json) ? json : []; +} + +const ensureJpg = (path: string, suffix: '_1.jpg' | '_2.jpg'): string => { + if (/\.jpe?g$/i.test(path)) return path; + return `${path}${suffix}`; +}; + +export const toThumbnailUrl = (path: string): string => { + const normalized = ensureJpg(path, '_1.jpg'); + return normalized.startsWith('http') || normalized.startsWith('/') ? normalized : `/shipimg/${normalized}`; +}; + +export const toHighResUrl = (path: string): string => { + const withExt = ensureJpg(path, '_2.jpg'); + const resolved = withExt.startsWith('http') || withExt.startsWith('/') ? withExt : `/shipimg/${withExt}`; + return resolved.replace(/_1\.jpg$/i, '_2.jpg'); +}; diff --git a/frontend/wing-gis-web/src/features/shipImage/ui/ShipImageModal.tsx b/frontend/wing-gis-web/src/features/shipImage/ui/ShipImageModal.tsx new file mode 100644 index 0000000..7117816 --- /dev/null +++ b/frontend/wing-gis-web/src/features/shipImage/ui/ShipImageModal.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import type { ShipImageInfo } from '../api/shipImageApi'; +import { fetchShipImagesByImo, toHighResUrl, toThumbnailUrl } from '../api/shipImageApi'; + +interface ShipImageModalProps { + images?: ShipImageInfo[]; + initialIndex?: number; + initialImagePath?: string; + totalCount?: number; + imo?: number; + vesselName?: string; + onClose: () => void; +} + +const S = { + overlay: { position: 'fixed' as const, inset: 0, background: 'rgba(0,20,30,.85)', zIndex: 9999, display: 'flex', alignItems: 'center', justifyContent: 'center' }, + content: { background: '#1a3a4a', borderRadius: 10, width: '92vw', maxWidth: 860, maxHeight: '90vh', display: 'flex', flexDirection: 'column' as const, overflow: 'hidden', boxShadow: '0 12px 40px rgba(0,0,0,.5)', border: '1px solid rgba(0,147,178,.3)' }, + header: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '10px 16px', borderBottom: '1px solid rgba(0,147,178,.2)' }, + title: { color: '#fff', fontSize: 14, fontWeight: 700, fontFamily: "'Noto Sans KR',sans-serif" }, + counter: { color: '#b3e4ee', fontSize: 12, fontWeight: 400, marginLeft: 10 }, + closeBtn: { background: 'none', border: 'none', color: 'rgba(255,255,255,.6)', fontSize: 20, cursor: 'pointer', padding: '0 4px', lineHeight: 1 }, + body: { position: 'relative' as const, flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: 360, background: '#0e2535' }, + imgWrap: { display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', minHeight: 360 }, + img: { maxWidth: '100%', maxHeight: '65vh', objectFit: 'contain' as const, borderRadius: 4, transition: 'opacity .3s' }, + nav: { position: 'absolute' as const, top: '50%', transform: 'translateY(-50%)', background: 'rgba(0,147,178,.5)', border: 'none', color: '#fff', fontSize: 28, width: 36, height: 52, borderRadius: 4, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }, + carousel: { display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, padding: '10px 0', borderTop: '1px solid rgba(0,147,178,.15)', background: '#1a3a4a' }, + thumb: (active: boolean) => ({ width: 56, height: 42, objectFit: 'cover' as const, borderRadius: 4, cursor: 'pointer', border: active ? '2px solid #0093b2' : '2px solid transparent', opacity: active ? 1 : 0.6, transition: '.2s' }), + footer: { display: 'flex', justifyContent: 'center', gap: 16, padding: '6px 16px 10px', fontSize: 11, color: 'rgba(255,255,255,.5)', fontFamily: "'Noto Sans KR',sans-serif" }, + spinner: { width: 32, height: 32, border: '3px solid rgba(0,147,178,.3)', borderTop: '3px solid #0093b2', borderRadius: '50%', animation: 'spin 1s linear infinite' }, + error: { color: 'rgba(255,255,255,.5)', fontSize: 13 }, +}; + +const ShipImageModal: React.FC = ({ + images: preloaded, + initialIndex = 0, + initialImagePath, + totalCount, + imo, + vesselName, + onClose, +}) => { + const [index, setIndex] = useState(initialIndex); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + const [fetched, setFetched] = useState(null); + const [fetching, setFetching] = useState(!preloaded && !!imo && imo > 0); + + const allImages = preloaded ?? fetched; + const total = allImages ? allImages.length : (totalCount ?? 1); + + const currentUrl = (() => { + if (allImages?.[index]) return toHighResUrl(allImages[index].path); + if (index === 0 && initialImagePath) return toHighResUrl(initialImagePath); + return null; + })(); + + useEffect(() => { + if (preloaded || !imo || imo <= 0) return; + const ac = new AbortController(); + fetchShipImagesByImo(imo, ac.signal) + .then((r) => { + if (!ac.signal.aborted) { setFetched(r.length > 0 ? r : null); setFetching(false); } + }) + .catch((e) => { + if ((e as Error).name !== 'AbortError') console.warn('[ShipImage] fetch failed:', e); + }); + return () => ac.abort(); + }, [preloaded, imo]); + + const goPrev = useCallback(() => { if (total > 1) { setIndex((i) => (i - 1 + total) % total); setLoading(true); setError(false); } }, [total]); + const goNext = useCallback(() => { if (total > 1) { setIndex((i) => (i + 1) % total); setLoading(true); setError(false); } }, [total]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + else if (e.key === 'ArrowLeft') goPrev(); + else if (e.key === 'ArrowRight') goNext(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose, goPrev, goNext]); + + const thumbs = useMemo(() => allImages?.map((img) => toThumbnailUrl(img.path)) ?? [], [allImages]); + const meta = allImages?.[index] ?? null; + + return ( +
+
e.stopPropagation()}> +
+ + {vesselName && {vesselName}} + {total > 1 && {index + 1} / {total}} + + +
+ +
+ {total > 1 && } +
+ {(loading || fetching) && !error &&
} + {error &&
이미지를 불러올 수 없습니다
} + {currentUrl && ( + {vesselName setLoading(false)} + onError={() => { setLoading(false); setError(true); }} + /> + )} +
+ {total > 1 && } +
+ + {thumbs.length > 1 && ( +
+ {thumbs.map((src, i) => ( + { setIndex(i); setLoading(true); setError(false); }} /> + ))} +
+ )} + +
+ {meta?.copyright && {meta.copyright}} + {meta?.date && {meta.date}} +
+
+
+ ); +}; + +export default ShipImageModal; diff --git a/frontend/wing-gis-web/src/features/vesselLayer/hooks/useVesselDeckLayer.ts b/frontend/wing-gis-web/src/features/vesselLayer/hooks/useVesselDeckLayer.ts new file mode 100644 index 0000000..f8a4720 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/hooks/useVesselDeckLayer.ts @@ -0,0 +1,129 @@ +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import type { LayersList } from '@deck.gl/core'; +import type maplibregl from 'maplibre-gl'; +import { useStore } from '../../../hooks/useStore'; +import { toVesselFeatures, type VesselFeature } from '../lib/vesselAdapter'; +import { buildVesselDeckLayers } from '../lib/vesselDeckLayers'; +import { toAisFeatures, aisTargetToVessel, type AisFeature } from '../lib/aisAdapter'; +import { buildAisDeckLayers } from '../lib/aisDeckLayers'; +import { ShipBatchRenderer, type ViewportBounds } from '../lib/ShipBatchRenderer'; + +export function useVesselDeckLayer( + mapRef: React.MutableRefObject, + mapSyncEpoch: number, +): LayersList { + const vessels = useStore((s) => s.vessels); + const aisTargets = useStore((s) => s.aisTargets); + const signalState = useStore((s) => s.signalState); + const selectedVessel = useStore((s) => s.selectedVessel); + const setSelectedVessel = useStore((s) => s.setSelectedVessel); + + // Non-AIS dummy vessel renderer + const nonAisRendererRef = useRef | null>(null); + const [renderedNonAis, setRenderedNonAis] = useState([]); + + useEffect(() => { + const renderer = new ShipBatchRenderer(); + renderer.initialize((ships) => setRenderedNonAis(ships)); + nonAisRendererRef.current = renderer; + return () => { renderer.dispose(); nonAisRendererRef.current = null; }; + }, []); + + useEffect(() => { + const nonAis = vessels.filter((v) => v.source !== 'AIS'); + nonAisRendererRef.current?.setData(toVesselFeatures(nonAis)); + }, [vessels]); + + // AIS renderer + const aisRendererRef = useRef | null>(null); + const [renderedAis, setRenderedAis] = useState([]); + + useEffect(() => { + const renderer = new ShipBatchRenderer(); + renderer.initialize((ships) => setRenderedAis(ships)); + aisRendererRef.current = renderer; + return () => { renderer.dispose(); aisRendererRef.current = null; }; + }, []); + + useEffect(() => { + const features = toAisFeatures(aisTargets); + const renderer = aisRendererRef.current; + if (renderer) { + renderer.setData(features); + } else { + // Renderer not ready yet, set directly + setRenderedAis(features); + } + }, [aisTargets]); + + // Viewport sync for both renderers + useEffect(() => { + const map = mapRef.current; + if (!map || mapSyncEpoch === 0) return; + + const updateViewport = () => { + const bounds = map.getBounds(); + const vp: ViewportBounds = { + minLon: bounds.getWest(), + maxLon: bounds.getEast(), + minLat: bounds.getSouth(), + maxLat: bounds.getNorth(), + }; + const zoom = map.getZoom(); + + for (const r of [nonAisRendererRef.current, aisRendererRef.current]) { + if (!r) continue; + r.setViewportBounds(vp); + r.setZoom(zoom); + r.requestRender(); + } + }; + + map.on('moveend', updateViewport); + updateViewport(); + return () => { map.off('moveend', updateViewport); }; + }, [mapRef, mapSyncEpoch]); + + // Click handlers + const handleNonAisClick = useCallback( + (info: { object?: VesselFeature }) => { + if (info.object) setSelectedVessel(info.object.raw); + }, + [setSelectedVessel], + ); + + const handleAisClick = useCallback( + (info: { object?: AisFeature }) => { + if (info.object) setSelectedVessel(aisTargetToVessel(info.object.raw)); + }, + [setSelectedVessel], + ); + + const selectedMmsi = selectedVessel?.mmsi ?? null; + const highlightedMmsis = useMemo(() => new Set(), []); + + const nonAisLayers = useMemo( + () => buildVesselDeckLayers({ + ships: renderedNonAis, + signalState, + selectedMmsi, + highlightedMmsis, + onClick: handleNonAisClick, + }), + [renderedNonAis, signalState, selectedMmsi, highlightedMmsis, handleNonAisClick], + ); + + const selectedMmsiNum = selectedMmsi ? Number(selectedMmsi) : null; + + const aisLayers = useMemo( + () => buildAisDeckLayers({ + ships: renderedAis, + aisVisible: signalState['AIS'] !== false, + selectedMmsi: Number.isFinite(selectedMmsiNum) ? selectedMmsiNum : null, + onClick: handleAisClick, + }), + [renderedAis, signalState, selectedMmsiNum, handleAisClick], + ); + + return useMemo(() => [...aisLayers, ...nonAisLayers], [aisLayers, nonAisLayers]); +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/ShipBatchRenderer.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/ShipBatchRenderer.ts new file mode 100644 index 0000000..5e0b083 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/ShipBatchRenderer.ts @@ -0,0 +1,145 @@ +export interface ViewportBounds { + minLon: number; + maxLon: number; + minLat: number; + maxLat: number; +} + +interface HasPosition { + lon: number; + lat: number; +} + +type RenderCallback = (ships: T[], trigger: number) => void; + +const ZOOM_MIN_INTERVAL: Record = { + 7: 4000, 8: 3500, 9: 3000, 10: 2500, 11: 2000, 12: 1500, 13: 1500, 14: 1000, +}; + +const DENSITY_LIMITS = [ + { maxZoom: 5, maxPerCell: 28, gridMult: 120 }, + { maxZoom: 6, maxPerCell: 35, gridMult: 100 }, + { maxZoom: 7, maxPerCell: 45, gridMult: 80 }, + { maxZoom: 8, maxPerCell: 50, gridMult: 75 }, + { maxZoom: 9, maxPerCell: 55, gridMult: 70 }, + { maxZoom: 10, maxPerCell: 60, gridMult: 55 }, + { maxZoom: 11, maxPerCell: 65, gridMult: 40 }, + { maxZoom: Infinity, maxPerCell: Infinity, gridMult: 30 }, +]; + +function getMinInterval(zoom: number): number { + return ZOOM_MIN_INTERVAL[Math.min(Math.max(Math.round(zoom), 7), 14)] ?? 2000; +} + +function getDensityConfig(zoom: number) { + return DENSITY_LIMITS.find((d) => zoom <= d.maxZoom) ?? DENSITY_LIMITS[DENSITY_LIMITS.length - 1]; +} + +function isInViewport(s: T, b: ViewportBounds): boolean { + if (s.lat < b.minLat || s.lat > b.maxLat) return false; + if (b.minLon <= b.maxLon) return s.lon >= b.minLon && s.lon <= b.maxLon; + return s.lon >= b.minLon || s.lon <= b.maxLon; +} + +function applyDensityLimit(ships: T[], zoom: number): T[] { + const cfg = getDensityConfig(zoom); + if (cfg.maxPerCell === Infinity) return ships; + + const gridSize = Math.pow(2, -zoom) * cfg.gridMult; + const counts = new Map(); + const result: T[] = []; + + for (const s of ships) { + const key = `${Math.floor(s.lon / gridSize)},${Math.floor(s.lat / gridSize)}`; + const c = counts.get(key) ?? 0; + if (c < cfg.maxPerCell) { + result.push(s); + counts.set(key, c + 1); + } + } + return result; +} + +export class ShipBatchRenderer { + private data: T[] = []; + private callback: RenderCallback | null = null; + private viewport: ViewportBounds | null = null; + private zoom = 10; + private interval = 1000; + private handle: ReturnType | null = null; + private lastRenderTime = 0; + private trigger = 0; + private rendering = false; + + initialize(cb: RenderCallback) { + this.callback = cb; + } + + setData(data: T[]) { + this.data = data; + this.requestRender(); + } + + setViewportBounds(bounds: ViewportBounds | null) { + this.viewport = bounds; + } + + setZoom(zoom: number): boolean { + const prev = Math.round(this.zoom); + this.zoom = zoom; + return Math.round(zoom) !== prev; + } + + requestRender() { + if (this.handle !== null) return; + const elapsed = Date.now() - this.lastRenderTime; + const delay = Math.max(0, this.interval - elapsed); + this.handle = setTimeout(() => { + this.handle = null; + requestAnimationFrame(() => this.executeRender()); + }, delay); + } + + immediateRender() { + if (this.handle !== null) { + clearTimeout(this.handle); + this.handle = null; + } + this.executeRender(); + } + + private executeRender() { + if (this.rendering || !this.callback) return; + this.rendering = true; + const start = performance.now(); + + try { + let ships = this.data; + if (this.viewport) { + ships = ships.filter((s) => isInViewport(s, this.viewport!)); + } + ships = applyDensityLimit(ships, this.zoom); + this.trigger += 1; + this.callback(ships, this.trigger); + + const elapsed = performance.now() - start; + const minInterval = getMinInterval(this.zoom); + if (elapsed > 500) { + this.interval = Math.min(this.interval * 1.2, 5000); + } else if (elapsed < 100) { + this.interval = Math.max(this.interval * 0.9, minInterval); + } + } finally { + this.rendering = false; + this.lastRenderTime = Date.now(); + } + } + + dispose() { + if (this.handle !== null) { + clearTimeout(this.handle); + this.handle = null; + } + this.callback = null; + } +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/aisAdapter.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisAdapter.ts new file mode 100644 index 0000000..f603991 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisAdapter.ts @@ -0,0 +1,93 @@ +import type { AisTarget, SignalKindCode } from '../../../types/ais'; +import { SIGNAL_KIND_COLORS, SIGNAL_KIND_LABELS } from '../../../types/ais'; +import type { Vessel } from '../../../types/vessel'; + +export interface AisFeature { + mmsi: number; + imo: number; + name: string; + lon: number; + lat: number; + sog: number; + cog: number; + heading: number; + vesselType: string; + signalKindCode: SignalKindCode; + color: [number, number, number, number]; + shipImagePath?: string | null; + shipImageCount: number; + messageTimestamp: number; + raw: AisTarget; +} + +function hexToRgba(hex: string): [number, number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, 255]; +} + +const COLOR_CACHE = new Map(); +function getColorRgba(code: SignalKindCode): [number, number, number, number] { + let c = COLOR_CACHE.get(code); + if (!c) { + c = hexToRgba(SIGNAL_KIND_COLORS[code] || '#607D8B'); + COLOR_CACHE.set(code, c); + } + return c; +} + +export function toAisFeatures(targets: Map): AisFeature[] { + const out: AisFeature[] = []; + for (const t of targets.values()) { + if (!Number.isFinite(t.lat) || !Number.isFinite(t.lon)) continue; + out.push({ + mmsi: t.mmsi, + imo: t.imo, + name: t.name, + lon: t.lon, + lat: t.lat, + sog: t.sog, + cog: t.cog, + heading: t.heading, + vesselType: t.vesselType, + signalKindCode: t.signalKindCode, + color: getColorRgba(t.signalKindCode), + shipImagePath: t.shipImagePath, + shipImageCount: t.shipImageCount ?? 0, + messageTimestamp: t.messageTimestamp, + raw: t, + }); + } + return out; +} + +export function aisTargetToVessel(target: AisTarget): Vessel { + return { + id: target.mmsi, + mmsi: String(target.mmsi), + imo: '', + name: target.name, + callsign: '', + shipType: SIGNAL_KIND_LABELS[target.signalKindCode] || target.vesselType, + flag: target.nationalCode || 'KR', + status: target.sog > 1 ? '항해중' : '정박중', + source: 'AIS', + latitude: target.lat, + longitude: target.lon, + sog: target.sog, + cog: target.cog, + heading: target.heading, + gt: 0, + dwt: 0, + loa: 0, + beam: 0, + draft: 0, + navStatus: '', + destination: '', + eta: '', + owner: '', + operator: '', + lastUpdated: new Date(target.messageTimestamp).toISOString(), + }; +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/aisDeckLayers.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisDeckLayers.ts new file mode 100644 index 0000000..ee64b8e --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisDeckLayers.ts @@ -0,0 +1,93 @@ +import { IconLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { AisFeature } from './aisAdapter'; +import { + getAisIconSpec, + getAisIconAngle, + AIS_ICON_SIZE_MOVING, + AIS_ICON_SIZE_STOPPED, + AIS_ICON_SIZE_SELECTED, +} from './aisIcons'; +import { HALO_RADIUS_SELECTED } from './vesselIcons'; + +const SPEED_THRESHOLD_KN = 1; +const SELECTED_COLOR: [number, number, number, number] = [34, 211, 238, 230]; + +const DEPTH_DISABLED = { + depthWriteEnabled: false, + depthCompare: 'always' as const, +}; + +interface AisLayerContext { + ships: AisFeature[]; + aisVisible: boolean; + selectedMmsi: number | null; + onHover?: (info: { object?: AisFeature; x: number; y: number }) => void; + onClick?: (info: { object?: AisFeature; x: number; y: number }) => void; +} + +export function buildAisDeckLayers(ctx: AisLayerContext) { + if (!ctx.aisVisible) return []; + + const overlayShips = ctx.selectedMmsi + ? ctx.ships.filter((s) => s.mmsi === ctx.selectedMmsi) + : []; + + const shipsLayer = new IconLayer({ + id: 'wing-ais', + data: ctx.ships, + pickable: true, + billboard: false, + parameters: DEPTH_DISABLED, + sizeUnits: 'pixels', + sizeMinPixels: 6, + sizeMaxPixels: 50, + getPosition: (d) => [d.lon, d.lat], + getIcon: (d) => getAisIconSpec(d.signalKindCode, d.sog), + getAngle: (d) => getAisIconAngle(d.signalKindCode, d.cog), + getSize: (d) => (d.sog > SPEED_THRESHOLD_KN ? AIS_ICON_SIZE_MOVING : AIS_ICON_SIZE_STOPPED), + onHover: ctx.onHover, + onClick: ctx.onClick, + updateTriggers: { + getIcon: [ctx.ships.length], + }, + }); + + const haloLayer = new ScatterplotLayer({ + id: 'wing-ais-halo', + data: overlayShips, + pickable: false, + billboard: false, + parameters: DEPTH_DISABLED, + getPosition: (d) => [d.lon, d.lat], + radiusUnits: 'pixels', + getRadius: HALO_RADIUS_SELECTED, + filled: false, + stroked: true, + lineWidthUnits: 'pixels', + getLineWidth: 2, + getLineColor: SELECTED_COLOR, + updateTriggers: { + getRadius: [ctx.selectedMmsi], + }, + }); + + const overlayLayer = new IconLayer({ + id: 'wing-ais-overlay', + data: overlayShips, + pickable: false, + billboard: false, + parameters: DEPTH_DISABLED, + sizeUnits: 'pixels', + sizeMinPixels: 10, + sizeMaxPixels: 60, + getPosition: (d) => [d.lon, d.lat], + getIcon: (d) => getAisIconSpec(d.signalKindCode, d.sog), + getAngle: (d) => getAisIconAngle(d.signalKindCode, d.cog), + getSize: AIS_ICON_SIZE_SELECTED, + updateTriggers: { + getIcon: [ctx.selectedMmsi], + }, + }); + + return [shipsLayer, haloLayer, overlayLayer]; +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/aisIcons.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisIcons.ts new file mode 100644 index 0000000..de35d10 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/aisIcons.ts @@ -0,0 +1,65 @@ +import type { SignalKindCode } from '../../../types/ais'; +import { SIGNAL_KIND, SIGNAL_KIND_COLORS } from '../../../types/ais'; +import type { VesselIconSpec } from './vesselIcons'; + +const ICON_CACHE = new Map(); +const SPEED_THRESHOLD_KN = 1; + +function makeMovingShipSvg(fill: string): string { + return ` + + `; +} + +function makeStoppedShipSvg(fill: string): string { + return ` + + `; +} + +function makeBuoySvg(): string { + return ` + + + + + `; +} + +function toDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +export function getAisIconSpec(signalKindCode: SignalKindCode, sog: number): VesselIconSpec { + if (signalKindCode === SIGNAL_KIND.BUOY) { + const key = 'buoy'; + const cached = ICON_CACHE.get(key); + if (cached) return cached; + const spec: VesselIconSpec = { url: toDataUri(makeBuoySvg()), width: 32, height: 44, anchorX: 16, anchorY: 22 }; + ICON_CACHE.set(key, spec); + return spec; + } + + const isMoving = sog > SPEED_THRESHOLD_KN; + const cacheKey = `${signalKindCode}-${isMoving ? 'mov' : 'stp'}`; + const cached = ICON_CACHE.get(cacheKey); + if (cached) return cached; + + const color = SIGNAL_KIND_COLORS[signalKindCode] || '#607D8B'; + + const spec: VesselIconSpec = isMoving + ? { url: toDataUri(makeMovingShipSvg(color)), width: 32, height: 48, anchorX: 16, anchorY: 24 } + : { url: toDataUri(makeStoppedShipSvg(color)), width: 16, height: 16, anchorX: 8, anchorY: 8 }; + + ICON_CACHE.set(cacheKey, spec); + return spec; +} + +export function getAisIconAngle(signalKindCode: SignalKindCode, cog: number): number { + if (signalKindCode === SIGNAL_KIND.BUOY) return 0; + return -cog; +} + +export const AIS_ICON_SIZE_MOVING = 24; +export const AIS_ICON_SIZE_STOPPED = 14; +export const AIS_ICON_SIZE_SELECTED = 36; diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/shipTooltip.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/shipTooltip.ts new file mode 100644 index 0000000..3a24360 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/shipTooltip.ts @@ -0,0 +1,29 @@ +import type { AisFeature } from './aisAdapter'; +import { SIGNAL_KIND_LABELS } from '../../../types/ais'; +import { toThumbnailUrl } from '../../shipImage/api/shipImageApi'; + +function fmtTimestamp(ts: number): string { + const d = new Date(ts); + return d.toLocaleString('ko-KR', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +export function getShipTooltipHtml(ship: AisFeature): { html: string } | null { + const kindLabel = SIGNAL_KIND_LABELS[ship.signalKindCode] || ship.vesselType || ''; + + const photoHtml = ship.shipImagePath + ? `
+ +
` + : ''; + + return { + html: `
+ ${photoHtml} +
${ship.name || `MMSI ${ship.mmsi}`}
+
MMSI: ${ship.mmsi}${kindLabel ? ` · ${kindLabel}` : ''}
+
SOG: ${ship.sog.toFixed(1)} kn · COG: ${ship.cog.toFixed(0)}°
+ ${ship.shipImageCount ? `
사진: ${ship.shipImageCount}
` : ''} +
${fmtTimestamp(ship.messageTimestamp)}
+
`, + }; +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselAdapter.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselAdapter.ts new file mode 100644 index 0000000..7506c28 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselAdapter.ts @@ -0,0 +1,59 @@ +import type { Vessel, VesselSource } from '../../../types/vessel'; +import { VESSEL_COLORS } from '../../../types/vessel'; + +export interface VesselFeature { + mmsi: string; + name: string; + source: VesselSource; + lon: number; + lat: number; + sog: number; + cog: number; + heading: number; + status: string; + shipType: string; + color: [number, number, number, number]; + raw: Vessel; +} + +function hexToRgba(hex: string, alpha = 255): [number, number, number, number] { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return [r, g, b, alpha]; +} + +const SOURCE_COLORS_RGBA: Record = Object.fromEntries( + (Object.entries(VESSEL_COLORS) as [VesselSource, string][]).map(([src, hex]) => [src, hexToRgba(hex)]) +) as Record; + +export function toVesselFeature(v: Vessel): VesselFeature | null { + if (!Number.isFinite(v.latitude) || !Number.isFinite(v.longitude)) return null; + return { + mmsi: v.mmsi, + name: v.name, + source: v.source, + lon: v.longitude, + lat: v.latitude, + sog: Number.isFinite(v.sog) ? v.sog : 0, + cog: Number.isFinite(v.cog) ? v.cog : 0, + heading: Number.isFinite(v.heading) ? v.heading : 0, + status: v.status, + shipType: v.shipType, + color: SOURCE_COLORS_RGBA[v.source] ?? [102, 102, 102, 255], + raw: v, + }; +} + +export function toVesselFeatures(vessels: Vessel[]): VesselFeature[] { + const out: VesselFeature[] = []; + for (const v of vessels) { + const f = toVesselFeature(v); + if (f) out.push(f); + } + return out; +} + +export function getSourceColorRgba(source: VesselSource): [number, number, number, number] { + return SOURCE_COLORS_RGBA[source] ?? [102, 102, 102, 255]; +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselDeckLayers.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselDeckLayers.ts new file mode 100644 index 0000000..6b7da8b --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselDeckLayers.ts @@ -0,0 +1,103 @@ +import { IconLayer, ScatterplotLayer } from '@deck.gl/layers'; +import type { VesselFeature } from './vesselAdapter'; +import type { VesselSource } from '../../../types/vessel'; +import { + getVesselIconSpec, + getVesselIconAngle, + ICON_SIZE_MOVING, + ICON_SIZE_STOPPED, + ICON_SIZE_SELECTED, + HALO_RADIUS_SELECTED, + HALO_RADIUS_HIGHLIGHTED, +} from './vesselIcons'; + +const SPEED_THRESHOLD_KN = 1; +const SELECTED_COLOR: [number, number, number, number] = [34, 211, 238, 230]; +const HIGHLIGHTED_COLOR: [number, number, number, number] = [245, 158, 11, 210]; + +const DEPTH_DISABLED = { + depthWriteEnabled: false, + depthCompare: 'always' as const, +}; + +interface VesselLayerContext { + ships: VesselFeature[]; + signalState: Record; + selectedMmsi: string | null; + highlightedMmsis: Set; + onHover?: (info: { object?: VesselFeature; x: number; y: number }) => void; + onClick?: (info: { object?: VesselFeature; x: number; y: number }) => void; +} + +export function buildVesselDeckLayers(ctx: VesselLayerContext) { + const visibleShips = ctx.ships.filter((s) => ctx.signalState[s.source] !== false); + + const overlayShips = visibleShips.filter( + (s) => s.mmsi === ctx.selectedMmsi || ctx.highlightedMmsis.has(s.mmsi), + ); + + const shipsLayer = new IconLayer({ + id: 'wing-vessels', + data: visibleShips, + pickable: true, + billboard: false, + parameters: DEPTH_DISABLED, + sizeUnits: 'pixels', + sizeMinPixels: 6, + sizeMaxPixels: 50, + getPosition: (d) => [d.lon, d.lat], + getIcon: (d) => getVesselIconSpec(d.source, d.sog), + getAngle: (d) => getVesselIconAngle(d.source, d.cog), + getSize: (d) => (d.sog > SPEED_THRESHOLD_KN ? ICON_SIZE_MOVING : ICON_SIZE_STOPPED), + onHover: ctx.onHover, + onClick: ctx.onClick, + updateTriggers: { + getIcon: [ctx.signalState], + getSize: [ctx.signalState], + }, + }); + + const haloLayer = new ScatterplotLayer({ + id: 'wing-vessels-halo', + data: overlayShips, + pickable: false, + billboard: false, + parameters: DEPTH_DISABLED, + getPosition: (d) => [d.lon, d.lat], + radiusUnits: 'pixels', + getRadius: (d) => { + if (d.mmsi === ctx.selectedMmsi) return HALO_RADIUS_SELECTED; + if (ctx.highlightedMmsis.has(d.mmsi)) return HALO_RADIUS_HIGHLIGHTED; + return 0; + }, + filled: false, + stroked: true, + lineWidthUnits: 'pixels', + getLineWidth: 2, + getLineColor: (d) => (d.mmsi === ctx.selectedMmsi ? SELECTED_COLOR : HIGHLIGHTED_COLOR), + updateTriggers: { + getRadius: [ctx.selectedMmsi, ctx.highlightedMmsis], + getLineColor: [ctx.selectedMmsi], + }, + }); + + const overlayLayer = new IconLayer({ + id: 'wing-vessels-overlay', + data: overlayShips, + pickable: false, + billboard: false, + parameters: DEPTH_DISABLED, + sizeUnits: 'pixels', + sizeMinPixels: 10, + sizeMaxPixels: 60, + getPosition: (d) => [d.lon, d.lat], + getIcon: (d) => getVesselIconSpec(d.source, d.sog), + getAngle: (d) => getVesselIconAngle(d.source, d.cog), + getSize: () => ICON_SIZE_SELECTED, + updateTriggers: { + getIcon: [ctx.selectedMmsi, ctx.highlightedMmsis], + }, + }); + + return [shipsLayer, haloLayer, overlayLayer]; +} diff --git a/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselIcons.ts b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselIcons.ts new file mode 100644 index 0000000..ac63262 --- /dev/null +++ b/frontend/wing-gis-web/src/features/vesselLayer/lib/vesselIcons.ts @@ -0,0 +1,96 @@ +import type { VesselSource } from '../../../types/vessel'; +import { VESSEL_COLORS, VESSEL_SHAPES } from '../../../types/vessel'; + +export interface VesselIconSpec { + url: string; + width: number; + height: number; + anchorX: number; + anchorY: number; +} + +const ICON_CACHE = new Map(); + +function buildMovingSvg(color: string): string { + return ` + + `; +} + +function buildStoppedSvg(color: string): string { + return ` + + `; +} + +function buildSquareSvg(color: string): string { + return ` + + `; +} + +function buildDiamondSvg(color: string): string { + return ` + + `; +} + +function buildPlaneSvg(color: string): string { + return ` + + `; +} + +function toDataUri(svg: string): string { + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; +} + +const SPEED_THRESHOLD_KN = 1; + +export function getVesselIconSpec(source: VesselSource, sog: number): VesselIconSpec { + const shape = VESSEL_SHAPES[source] || 'triangle'; + const color = VESSEL_COLORS[source] || '#666'; + const isMoving = sog > SPEED_THRESHOLD_KN; + + const cacheKey = `${source}-${isMoving ? 'mov' : 'stp'}`; + const cached = ICON_CACHE.get(cacheKey); + if (cached) return cached; + + let spec: VesselIconSpec; + + switch (shape) { + case 'triangle': + if (isMoving) { + spec = { url: toDataUri(buildMovingSvg(color)), width: 32, height: 48, anchorX: 16, anchorY: 24 }; + } else { + spec = { url: toDataUri(buildStoppedSvg(color)), width: 16, height: 16, anchorX: 8, anchorY: 8 }; + } + break; + case 'square': + spec = { url: toDataUri(buildSquareSvg(color)), width: 24, height: 24, anchorX: 12, anchorY: 12 }; + break; + case 'diamond': + spec = { url: toDataUri(buildDiamondSvg(color)), width: 24, height: 24, anchorX: 12, anchorY: 12 }; + break; + case 'plane': + spec = { url: toDataUri(buildPlaneSvg(color)), width: 32, height: 32, anchorX: 16, anchorY: 16 }; + break; + default: + spec = { url: toDataUri(buildStoppedSvg(color)), width: 16, height: 16, anchorX: 8, anchorY: 8 }; + } + + ICON_CACHE.set(cacheKey, spec); + return spec; +} + +export function getVesselIconAngle(source: VesselSource, cog: number): number { + const shape = VESSEL_SHAPES[source] || 'triangle'; + if (shape === 'square' || shape === 'diamond') return 0; + return -cog; +} + +export const ICON_SIZE_MOVING = 24; +export const ICON_SIZE_STOPPED = 14; +export const ICON_SIZE_SELECTED = 36; +export const HALO_RADIUS_SELECTED = 14; +export const HALO_RADIUS_HIGHLIGHTED = 11; diff --git a/frontend/wing-gis-web/src/hooks/useAisPolling.ts b/frontend/wing-gis-web/src/hooks/useAisPolling.ts new file mode 100644 index 0000000..2d42fe3 --- /dev/null +++ b/frontend/wing-gis-web/src/hooks/useAisPolling.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef } from 'react'; +import { useStore } from './useStore'; +import { fetchRecentPositions } from '../services/aisApi'; +import type { AisTarget } from '../types/ais'; + +const INITIAL_MINUTES = 60; +const POLL_MINUTES = 2; +const POLL_INTERVAL_MS = 60_000; +const RETENTION_MS = 120 * 60 * 1000; + +export function useAisPolling() { + const setAisTargets = useStore((s) => s.setAisTargets); + const storeRef = useRef(useStore.getState); + storeRef.current = useStore.getState; + + useEffect(() => { + const ac = new AbortController(); + let timer: ReturnType | null = null; + + function mergeAndPrune(incoming: AisTarget[]) { + const prev = storeRef.current().aisTargets; + const next = new Map(prev); + const now = Date.now(); + + for (const target of incoming) { + const existing = next.get(target.mmsi); + if (!existing || target.messageTimestamp > existing.messageTimestamp) { + next.set(target.mmsi, target); + } + } + + for (const [mmsi, target] of next) { + if (now - target.messageTimestamp > RETENTION_MS) { + next.delete(mmsi); + } + } + + setAisTargets(next); + } + + async function poll(minutes: number) { + try { + const targets = await fetchRecentPositions(minutes, ac.signal); + mergeAndPrune(targets); + } catch (err) { + if ((err as Error).name !== 'AbortError') { + console.warn('[AIS] Poll failed:', err); + } + } + } + + poll(INITIAL_MINUTES).then(() => { + if (ac.signal.aborted) return; + timer = setInterval(() => poll(POLL_MINUTES), POLL_INTERVAL_MS); + }); + + return () => { + ac.abort(); + if (timer) clearInterval(timer); + }; + }, [setAisTargets]); +} diff --git a/frontend/wing-gis-web/src/hooks/useDeckLayers.ts b/frontend/wing-gis-web/src/hooks/useDeckLayers.ts new file mode 100644 index 0000000..2ef21df --- /dev/null +++ b/frontend/wing-gis-web/src/hooks/useDeckLayers.ts @@ -0,0 +1,28 @@ +import { useEffect } from 'react'; +import type { LayersList, PickingInfo } from '@deck.gl/core'; +import type { MapboxOverlay } from '@deck.gl/mapbox'; + +interface DeckCallbacks { + getTooltip?: (info: PickingInfo) => { html: string } | null; + onClick?: (info: PickingInfo) => void; +} + +export function useDeckLayers( + overlayRef: React.MutableRefObject, + layers: LayersList, + callbacks?: DeckCallbacks, +) { + useEffect(() => { + const overlay = overlayRef.current; + if (!overlay) return; + try { + overlay.setProps({ + layers, + getTooltip: callbacks?.getTooltip, + onClick: callbacks?.onClick, + } as never); + } catch (e) { + console.warn('[useDeckLayers] setProps failed:', e); + } + }, [overlayRef, layers, callbacks]); +} diff --git a/frontend/wing-gis-web/src/hooks/useMap.ts b/frontend/wing-gis-web/src/hooks/useMap.ts new file mode 100644 index 0000000..5455c28 --- /dev/null +++ b/frontend/wing-gis-web/src/hooks/useMap.ts @@ -0,0 +1,62 @@ +import { useRef, useState, useCallback } from 'react'; +import maplibregl from 'maplibre-gl'; +import { MapboxOverlay } from '@deck.gl/mapbox'; +import { DEFAULT_CENTER, DEFAULT_ZOOM, OSM_RASTER_STYLE } from '../lib/map/mapConstants'; +import { fetchEncStyle } from '../features/nauticalChart/lib/fetchNauticalStyle'; + +export function useMap() { + const mapRef = useRef(null); + const overlayRef = useRef(null); + const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + + const initMap = useCallback((container: HTMLDivElement) => { + if (mapRef.current) return; + + const map = new maplibregl.Map({ + container, + style: OSM_RASTER_STYLE, + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM, + maxPitch: 85, + }); + + map.addControl(new maplibregl.NavigationControl({ showCompass: true }), 'top-left'); + map.addControl(new maplibregl.ScaleControl({ maxWidth: 150 }), 'bottom-left'); + + // deck.gl MapboxOverlay (interleaved mode) + const overlay = new MapboxOverlay({ interleaved: true, layers: [] } as never); + map.addControl(overlay); + overlayRef.current = overlay; + + map.on('style.load', () => { + setMapSyncEpoch((e) => e + 1); + }); + + mapRef.current = map; + + fetchEncStyle() + .then((encStyle) => { + if (mapRef.current) { + mapRef.current.setStyle(encStyle); + } + }) + .catch((err) => { + console.warn('[useMap] ENC style load failed, keeping OSM:', err); + }); + }, []); + + const destroyMap = useCallback(() => { + const overlay = overlayRef.current; + if (overlay) { + try { overlay.finalize(); } catch { /* ignore */ } + overlayRef.current = null; + } + const map = mapRef.current; + if (map) { + map.remove(); + mapRef.current = null; + } + }, []); + + return { mapRef, overlayRef, mapSyncEpoch, initMap, destroyMap }; +} diff --git a/frontend/wing-gis-web/src/hooks/useStore.ts b/frontend/wing-gis-web/src/hooks/useStore.ts new file mode 100644 index 0000000..04a4a77 --- /dev/null +++ b/frontend/wing-gis-web/src/hooks/useStore.ts @@ -0,0 +1,85 @@ +import { create } from 'zustand'; +import type { Vessel, VesselSource } from '../types/vessel'; +import type { AisTarget } from '../types/ais'; +import type { NauticalChartSettings } from '../features/nauticalChart/model/types'; +import { DEFAULT_NAUTICAL_CHART_SETTINGS } from '../features/nauticalChart/model/types'; + +interface AppState { + // 현재 활성 네비게이션 + activeNav: 'map' | 'layer' | 'analysis' | 'theme' | 'admin'; + setActiveNav: (nav: AppState['activeNav']) => void; + + // 물표 데이터 + vessels: Vessel[]; + setVessels: (v: Vessel[]) => void; + + // 선택된 물표 + selectedVessel: Vessel | null; + setSelectedVessel: (v: Vessel | null) => void; + + // 물표 소스 ON/OFF 토글 + signalState: Record; + toggleSignal: (source: VesselSource) => void; + + // 사이드바 검색 + searchQuery: string; + setSearchQuery: (q: string) => void; + + // AIS 실시간 데이터 + aisTargets: Map; + setAisTargets: (targets: Map) => void; + + // 선박 사진 모달 + photoModal: { imo: number; name: string; imagePath: string; imageCount: number } | null; + openPhotoModal: (info: { imo: number; name: string; imagePath: string; imageCount: number }) => void; + closePhotoModal: () => void; + + // ENC 전자해도 설정 + nauticalChartSettings: NauticalChartSettings; + setNauticalChartSettings: (s: NauticalChartSettings) => void; + + // 로그인 상태 + user: { name: string; department: string; organization: string } | null; + setUser: (u: AppState['user']) => void; +} + +export const useStore = create((set) => ({ + activeNav: 'map', + setActiveNav: (nav) => set({ activeNav: nav }), + + vessels: [], + setVessels: (vessels) => set({ vessels }), + + selectedVessel: null, + setSelectedVessel: (v) => set({ selectedVessel: v }), + + signalState: { + 'AIS': true, + 'V-Pass': true, + 'E-NAV': true, + 'VTS': true, + 'VTS-RADAR': true, + 'AIR-AIS': true, + 'VHF-DSC': true, + }, + toggleSignal: (source) => + set((state) => ({ + signalState: { ...state.signalState, [source]: !state.signalState[source] }, + })), + + searchQuery: '', + setSearchQuery: (q) => set({ searchQuery: q }), + + aisTargets: new Map(), + setAisTargets: (targets) => set({ aisTargets: targets }), + + photoModal: null, + openPhotoModal: (info) => set({ photoModal: info }), + closePhotoModal: () => set({ photoModal: null }), + + nauticalChartSettings: DEFAULT_NAUTICAL_CHART_SETTINGS, + setNauticalChartSettings: (s) => set({ nauticalChartSettings: s }), + + user: null, + setUser: (u) => set({ user: u }), +})); diff --git a/frontend/wing-gis-web/src/hooks/useVesselStream.ts b/frontend/wing-gis-web/src/hooks/useVesselStream.ts new file mode 100644 index 0000000..168af71 --- /dev/null +++ b/frontend/wing-gis-web/src/hooks/useVesselStream.ts @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { Client } from '@stomp/stompjs'; +import SockJS from 'sockjs-client'; +import { useStore } from './useStore'; +import type { Vessel } from '../types/vessel'; + +const WS_URL = import.meta.env.VITE_WS_URL || 'http://localhost:8080/ws/vessels'; + +/** + * WebSocket STOMP를 통한 실시간 물표 스트리밍 (SFR-05, PER-02) + * /topic/vessels 구독 → 30초마다 전체 물표 위치 수신 + */ +export function useVesselStream() { + const setVessels = useStore((s) => s.setVessels); + const clientRef = useRef(null); + + useEffect(() => { + const client = new Client({ + webSocketFactory: () => new SockJS(WS_URL), + reconnectDelay: 5000, + heartbeatIncoming: 10000, + heartbeatOutgoing: 10000, + onConnect: () => { + console.log('[WING-GIS] WebSocket connected'); + client.subscribe('/topic/vessels', (message) => { + try { + const vessels: Vessel[] = JSON.parse(message.body); + setVessels(vessels); + } catch (e) { + console.error('[WING-GIS] Failed to parse vessel data', e); + } + }); + }, + onStompError: (frame) => { + console.error('[WING-GIS] STOMP error', frame.headers['message']); + }, + }); + + client.activate(); + clientRef.current = client; + + return () => { + client.deactivate(); + }; + }, [setVessels]); + + return clientRef; +} diff --git a/frontend/wing-gis-web/src/index.css b/frontend/wing-gis-web/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/frontend/wing-gis-web/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/frontend/wing-gis-web/src/lib/map/MaplibreDeckCustomLayer.ts b/frontend/wing-gis-web/src/lib/map/MaplibreDeckCustomLayer.ts new file mode 100644 index 0000000..97c3bab --- /dev/null +++ b/frontend/wing-gis-web/src/lib/map/MaplibreDeckCustomLayer.ts @@ -0,0 +1,173 @@ +import { Deck, MapController, View, Viewport, type DeckProps } from '@deck.gl/core'; +import type maplibregl from 'maplibre-gl'; + +type MatrixViewState = { + projectionMatrix: number[]; + viewMatrix?: number[]; + transitionDuration?: number; +}; + +class MatrixView extends View { + getViewportType(viewState: MatrixViewState): typeof Viewport { + void viewState; + return Viewport; + } + + protected get ControllerType(): typeof MapController { + return MapController; + } +} + +const IDENTITY_4x4: number[] = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +type DeckLayerList = NonNullable['layers']>; + +function readMat4(m: ArrayLike): number[] { + const out = new Array(16); + for (let i = 0; i < 16; i++) out[i] = m[i] as number; + return out; +} + +function mat4Changed(a: number[] | undefined, b: ArrayLike): boolean { + if (!a || a.length !== 16) return true; + for (let i = 0; i < 16; i++) { + if (a[i] !== (b[i] as number)) return true; + } + return false; +} + +function sanitizeDeckLayers(value: unknown): DeckLayerList { + if (!Array.isArray(value)) return [] as DeckLayerList; + const out: DeckLayerList = []; + const seen = new Set(); + for (const item of value) { + const id = item && typeof item === 'object' && typeof (item as { id?: unknown }).id === 'string' + ? (item as { id: string }).id + : null; + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(item as DeckLayerList[number]); + } + return out; +} + +export class MaplibreDeckCustomLayer implements maplibregl.CustomLayerInterface { + id: string; + type = 'custom' as const; + renderingMode = '3d' as const; + + private _map: maplibregl.Map | null = null; + private _deck: Deck | null = null; + private _deckProps: Partial> = {}; + private _viewId: string; + private _lastMvp: number[] | undefined; + private _finalizeOnRemove = false; + + constructor(opts: { id: string; viewId?: string; deckProps?: Partial> }) { + this.id = opts.id; + this._viewId = opts.viewId ?? 'wing-deck-view'; + this._deckProps = opts.deckProps ?? {}; + } + + get deck(): Deck | null { + return this._deck; + } + + requestFinalize() { + this._finalizeOnRemove = true; + } + + setProps(next: Partial>) { + const normalized = next.layers ? { ...next, layers: sanitizeDeckLayers(next.layers) } : next; + this._deckProps = { ...this._deckProps, ...normalized }; + if (this._deck) this._deck.setProps(this._deckProps as DeckProps); + this._map?.triggerRepaint(); + } + + onAdd(map: maplibregl.Map, gl: WebGLRenderingContext | WebGL2RenderingContext): void { + this._finalizeOnRemove = false; + this._map = map; + + if (this._deck) { + this._lastMvp = undefined; + const nextProps = { + ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), + canvas: map.getCanvas(), + _customRender: () => map.triggerRepaint(), + }; + this._deck.setProps(nextProps as DeckProps); + return; + } + + this._deck = new Deck({ + ...this._deckProps, + layers: sanitizeDeckLayers(this._deckProps.layers), + gl: gl as WebGL2RenderingContext, + canvas: map.getCanvas(), + width: null, + height: null, + touchAction: 'none', + controller: false, + views: [new MatrixView({ id: this._viewId })], + viewState: { [this._viewId]: { projectionMatrix: IDENTITY_4x4 } }, + _customRender: () => map.triggerRepaint(), + parameters: { + depthWriteEnabled: true, + depthCompare: 'less-equal', + depthBias: 0, + blend: true, + blendColorSrcFactor: 'src-alpha', + blendColorDstFactor: 'one-minus-src-alpha', + blendAlphaSrcFactor: 'one', + blendAlphaDstFactor: 'one-minus-src-alpha', + blendColorOperation: 'add', + blendAlphaOperation: 'add', + ...this._deckProps.parameters, + }, + }); + } + + onRemove(): void { + const deck = this._deck; + const map = this._map; + this._map = null; + this._lastMvp = undefined; + + if (!deck) return; + + if (this._finalizeOnRemove) { + deck.finalize(); + this._deck = null; + return; + } + + try { + deck.setProps({ _customRender: () => void 0 } as Partial>); + } catch { /* ignore */ } + try { + map?.triggerRepaint(); + } catch { /* ignore */ } + } + + render(_gl: WebGLRenderingContext | WebGL2RenderingContext, options: maplibregl.CustomRenderMethodInput): void { + const deck = this._deck; + if (!this._map || !deck || !deck.isInitialized) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internal = deck as any; + if (!internal.layerManager || !internal.viewManager) return; + + if (mat4Changed(this._lastMvp, options.modelViewProjectionMatrix)) { + const projectionMatrix = readMat4(options.modelViewProjectionMatrix); + this._lastMvp = projectionMatrix; + deck.setProps({ viewState: { [this._viewId]: { projectionMatrix, viewMatrix: IDENTITY_4x4 } } }); + } + + try { + deck._drawLayers('maplibre-custom', { clearCanvas: false, clearStack: true }); + } catch (e) { + console.warn('Deck render sync failed, skipping frame:', e); + requestAnimationFrame(() => { this._map?.triggerRepaint(); }); + } + } +} diff --git a/frontend/wing-gis-web/src/lib/map/mapConstants.ts b/frontend/wing-gis-web/src/lib/map/mapConstants.ts new file mode 100644 index 0000000..9d2ed96 --- /dev/null +++ b/frontend/wing-gis-web/src/lib/map/mapConstants.ts @@ -0,0 +1,32 @@ +import type maplibregl from 'maplibre-gl'; + +/** 인천 해역 기본 중심 좌표 [lng, lat] */ +export const DEFAULT_CENTER: [number, number] = [126.7052, 37.4563]; + +/** 기본 줌 레벨 */ +export const DEFAULT_ZOOM = 10; + +/** Martin ENC 타일 서버 프록시 경로 */ +export const MARTIN_BASE_PATH = '/martin'; + +/** 기본 OSM 래스터 타일 스타일 (MapLibre 초기화용 폴백) */ +export const OSM_RASTER_STYLE: maplibregl.StyleSpecification = { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + attribution: '© OpenStreetMap contributors', + }, + }, + layers: [ + { + id: 'osm-tiles', + type: 'raster', + source: 'osm', + minzoom: 0, + maxzoom: 19, + }, + ], +}; diff --git a/frontend/wing-gis-web/src/lib/map/mapCore.ts b/frontend/wing-gis-web/src/lib/map/mapCore.ts new file mode 100644 index 0000000..70372b7 --- /dev/null +++ b/frontend/wing-gis-web/src/lib/map/mapCore.ts @@ -0,0 +1,76 @@ +import maplibregl from 'maplibre-gl'; + +export function kickRepaint(map: maplibregl.Map | null) { + if (!map) return; + try { + if (map.isStyleLoaded()) map.triggerRepaint(); + } catch { + // ignore + } + requestAnimationFrame(() => { + try { + if (map?.isStyleLoaded()) map.triggerRepaint(); + } catch { + // ignore + } + }); +} + +export function onMapStyleReady(map: maplibregl.Map | null, callback: () => void) { + if (!map) return () => {}; + + if (map.isStyleLoaded()) { + callback(); + return () => {}; + } + + let fired = false; + const runOnce = () => { + if (!map || fired || !map.isStyleLoaded()) return; + fired = true; + callback(); + try { + map.off('style.load', runOnce); + map.off('styledata', runOnce); + } catch { + // ignore + } + }; + + map.on('style.load', runOnce); + map.on('styledata', runOnce); + + return () => { + if (fired) return; + fired = true; + try { + map.off('style.load', runOnce); + map.off('styledata', runOnce); + } catch { + // ignore + } + }; +} + +export function sanitizeDeckLayerList(value: unknown): unknown[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const out: unknown[] = []; + + for (const layer of value) { + const isDeckLayerLike = + !!layer && + typeof layer === 'object' && + typeof (layer as { id?: unknown }).id === 'string' && + typeof (layer as { clone?: unknown }).clone === 'function' && + typeof (layer as { props?: unknown }).props === 'object'; + if (!isDeckLayerLike) continue; + + const layerId = (layer as { id: string }).id; + if (seen.has(layerId)) continue; + seen.add(layerId); + out.push(layer); + } + + return out; +} diff --git a/frontend/wing-gis-web/src/main.tsx b/frontend/wing-gis-web/src/main.tsx new file mode 100644 index 0000000..aa331f7 --- /dev/null +++ b/frontend/wing-gis-web/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render() diff --git a/frontend/wing-gis-web/src/services/aisApi.ts b/frontend/wing-gis-web/src/services/aisApi.ts new file mode 100644 index 0000000..9f0eeb2 --- /dev/null +++ b/frontend/wing-gis-web/src/services/aisApi.ts @@ -0,0 +1,47 @@ +import type { AisTarget, RecentVesselPositionDto } from '../types/ais'; +import { normalizeSignalKindCode, mapVesselTypeToSignalKind } from '../types/ais'; + +const BASE_URL = '/signal-batch/api/v2/vessels'; + +function dtoToAisTarget(dto: RecentVesselPositionDto): AisTarget | null { + if (!Number.isFinite(dto.lat) || !Number.isFinite(dto.lon)) return null; + if (!dto.mmsi) return null; + + const code = dto.shipKindCode + ? normalizeSignalKindCode(dto.shipKindCode) + : mapVesselTypeToSignalKind(dto.shipTy); + + return { + mmsi: typeof dto.mmsi === 'string' ? Number(dto.mmsi) : dto.mmsi, + imo: typeof dto.imo === 'number' ? dto.imo : 0, + name: (dto.shipNm || '').trim(), + lat: dto.lat, + lon: dto.lon, + sog: Number.isFinite(dto.sog) ? dto.sog : 0, + cog: Number.isFinite(dto.cog) ? dto.cog : 0, + heading: Number.isFinite(dto.cog) ? dto.cog : 0, + vesselType: dto.shipTy || '', + signalKindCode: code, + nationalCode: dto.nationalCode || '', + messageTimestamp: dto.lastUpdate ? new Date(dto.lastUpdate).getTime() : Date.now(), + shipImagePath: dto.shipImagePath || null, + shipImageCount: dto.shipImageCount ?? 0, + source: 'AIS', + }; +} + +export async function fetchRecentPositions( + minutes: number, + signal?: AbortSignal, +): Promise { + const res = await fetch(`${BASE_URL}/recent-positions?minutes=${minutes}`, { signal }); + if (!res.ok) throw new Error(`AIS fetch failed: ${res.status}`); + + const dtos: RecentVesselPositionDto[] = await res.json(); + const out: AisTarget[] = []; + for (const dto of dtos) { + const target = dtoToAisTarget(dto); + if (target) out.push(target); + } + return out; +} diff --git a/frontend/wing-gis-web/src/services/api.ts b/frontend/wing-gis-web/src/services/api.ts new file mode 100644 index 0000000..4ce4a53 --- /dev/null +++ b/frontend/wing-gis-web/src/services/api.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; + +const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8080'; + +export const api = axios.create({ + baseURL: `${API_BASE}/api/v1`, + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, +}); + +// 인증 토큰 자동 첨부 +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// 에러 처리 +api.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401) { + localStorage.removeItem('token'); + window.location.href = '/login'; + } + return Promise.reject(err); + } +); diff --git a/frontend/wing-gis-web/src/services/vesselApi.ts b/frontend/wing-gis-web/src/services/vesselApi.ts new file mode 100644 index 0000000..b015cea --- /dev/null +++ b/frontend/wing-gis-web/src/services/vesselApi.ts @@ -0,0 +1,26 @@ +import { api } from './api'; +import type { Vessel, VesselTrack, VesselSearchRequest, VesselCountBySource } from '../types/vessel'; + +/** 물표 목록 (소스 필터) */ +export const getVessels = (source?: string) => + api.get('/vessels', { params: source ? { source } : {} }).then(r => r.data); + +/** MMSI로 상세 조회 */ +export const getVesselByMmsi = (mmsi: string) => + api.get(`/vessels/${mmsi}`).then(r => r.data); + +/** SFR-08: 선박 통합 검색 */ +export const searchVessels = (req: VesselSearchRequest) => + api.get<{ content: Vessel[]; totalElements: number }>('/vessels/search', { params: req }).then(r => r.data); + +/** 영역(bbox) 내 물표 */ +export const getVesselsByBbox = (minLon: number, minLat: number, maxLon: number, maxLat: number) => + api.get('/vessels/bbox', { params: { minLon, minLat, maxLon, maxLat } }).then(r => r.data); + +/** 항적 조회 */ +export const getVesselTracks = (mmsi: string, hours = 24) => + api.get(`/vessels/${mmsi}/tracks`, { params: { hours } }).then(r => r.data); + +/** 소스별 카운트 */ +export const getVesselCounts = () => + api.get('/vessels/count').then(r => r.data); diff --git a/frontend/wing-gis-web/src/types/ais.ts b/frontend/wing-gis-web/src/types/ais.ts new file mode 100644 index 0000000..bb56a81 --- /dev/null +++ b/frontend/wing-gis-web/src/types/ais.ts @@ -0,0 +1,95 @@ +export type SignalKindCode = + | '000020' // FISHING + | '000021' // KCGV + | '000022' // PASSENGER + | '000023' // CARGO + | '000024' // TANKER + | '000025' // GOV + | '000027' // NORMAL + | '000028'; // BUOY + +export const SIGNAL_KIND = { + FISHING: '000020', + KCGV: '000021', + PASSENGER: '000022', + CARGO: '000023', + TANKER: '000024', + GOV: '000025', + NORMAL: '000027', + BUOY: '000028', +} as const; + +export const SIGNAL_KIND_LABELS: Record = { + '000020': '어선', + '000021': '경비함정', + '000022': '여객선', + '000023': '화물선', + '000024': '유조선', + '000025': '관공선', + '000027': '일반', + '000028': '부이', +}; + +export const SIGNAL_KIND_COLORS: Record = { + '000020': '#00C853', + '000021': '#FF5722', + '000022': '#2196F3', + '000023': '#9C27B0', + '000024': '#F44336', + '000025': '#FF9800', + '000027': '#607D8B', + '000028': '#795548', +}; + +export interface RecentVesselPositionDto { + mmsi: number; + imo?: number; + lon: number; + lat: number; + sog: number; + cog: number; + shipNm: string; + shipTy: string; + shipKindCode: string; + nationalCode: string; + lastUpdate: string; + shipImagePath?: string | null; + shipImageCount?: number; +} + +export interface AisTarget { + mmsi: number; + imo: number; + name: string; + lat: number; + lon: number; + sog: number; + cog: number; + heading: number; + vesselType: string; + signalKindCode: SignalKindCode; + nationalCode: string; + messageTimestamp: number; + shipImagePath?: string | null; + shipImageCount?: number; + source: 'AIS'; +} + +const VALID_CODES = new Set(Object.values(SIGNAL_KIND)); + +export function normalizeSignalKindCode(code: string | undefined): SignalKindCode { + if (code && VALID_CODES.has(code)) return code as SignalKindCode; + return SIGNAL_KIND.NORMAL; +} + +export function mapVesselTypeToSignalKind(vesselType: string | undefined): SignalKindCode { + if (!vesselType) return SIGNAL_KIND.NORMAL; + const vt = vesselType.toLowerCase(); + if (vt.includes('fishing')) return SIGNAL_KIND.FISHING; + if (vt.includes('passenger')) return SIGNAL_KIND.PASSENGER; + if (vt.includes('cargo')) return SIGNAL_KIND.CARGO; + if (vt.includes('tanker')) return SIGNAL_KIND.TANKER; + if (vt.includes('military') || vt.includes('law') || vt.includes('government')) return SIGNAL_KIND.GOV; + if (vt.includes('buoy')) return SIGNAL_KIND.BUOY; + return SIGNAL_KIND.NORMAL; +} diff --git a/frontend/wing-gis-web/src/types/layer.ts b/frontend/wing-gis-web/src/types/layer.ts new file mode 100644 index 0000000..0a907f1 --- /dev/null +++ b/frontend/wing-gis-web/src/types/layer.ts @@ -0,0 +1,36 @@ +/** 레이어 그룹 + 레이어 트리 */ +export interface LayerGroup { + id: number; + name: string; + nameEn?: string; + icon?: string; + children: LayerItem[]; +} + +export interface LayerItem { + id: number; + groupId: number; + name: string; + nameEn?: string; + layerType: 'VECTOR' | 'RASTER' | 'WMS' | 'S100'; + s100Product?: string; + geometryType?: string; + isVisible: boolean; + isDefaultOn: boolean; + featureCount?: number; + sourceUrl?: string; +} + +/** S-100 제품 목록 */ +export const S100_PRODUCTS = [ + { code: 'S-101', name: '전자해도 (ENC)', color: '#005fa3' }, + { code: 'S-102', name: '수심처리', color: '#0077a0' }, + { code: 'S-104', name: '조석처리', color: '#0099aa' }, + { code: 'S-111', name: '해류처리', color: '#007755' }, + { code: 'S-122', name: '해양보호구역처리', color: '#336699' }, + { code: 'S-123', name: '해양무선처리', color: '#996633' }, + { code: 'S-124', name: '항행경보구역처리', color: '#2d6e4e' }, + { code: 'S-127', name: '해상교통관리처리', color: '#884466' }, + { code: 'S-412', name: '해상기상위험구역처리', color: '#8b4513' }, + { code: 'S-414', name: '해상기상관측처리', color: '#4a6080' }, +] as const; diff --git a/frontend/wing-gis-web/src/types/vessel.ts b/frontend/wing-gis-web/src/types/vessel.ts new file mode 100644 index 0000000..123e31a --- /dev/null +++ b/frontend/wing-gis-web/src/types/vessel.ts @@ -0,0 +1,84 @@ +/** 물표/선박 타입 (SFR-05) */ +export interface Vessel { + id: number; + mmsi: string; + imo: string; + name: string; + callsign: string; + shipType: string; + flag: string; + status: string; + source: VesselSource; + latitude: number; + longitude: number; + sog: number; + cog: number; + heading: number; + gt: number; + dwt: number; + loa: number; + beam: number; + draft: number; + navStatus: string; + destination: string; + eta: string; + owner: string; + operator: string; + lastUpdated: string; +} + +/** 물표 소스 7종 (KHOA 표준) */ +export type VesselSource = + | 'AIS' + | 'V-Pass' + | 'E-NAV' + | 'VTS' + | 'VTS-RADAR' + | 'AIR-AIS' + | 'VHF-DSC'; + +/** 물표 소스별 색상 (WING-GIS 프로토타입 기준) */ +export const VESSEL_COLORS: Record = { + 'AIS': '#0055cc', + 'V-Pass': '#1a8a3a', + 'E-NAV': '#8833cc', + 'VTS': '#cc6600', + 'VTS-RADAR': '#cc3300', + 'AIR-AIS': '#0099aa', + 'VHF-DSC': '#d63030', +}; + +/** 물표 소스별 아이콘 형태 */ +export const VESSEL_SHAPES: Record = { + 'AIS': 'triangle', + 'V-Pass': 'triangle', + 'E-NAV': 'triangle', + 'VTS': 'square', + 'VTS-RADAR': 'diamond', + 'AIR-AIS': 'plane', + 'VHF-DSC': 'triangle', +}; + +/** 항적 포인트 */ +export interface VesselTrack { + latitude: number; + longitude: number; + sog: number; + cog: number; + heading: number; + source: string; + recordedAt: string; +} + +/** SFR-08: 선박 검색 요청 */ +export interface VesselSearchRequest { + query?: string; + shipType?: string; + flag?: string; + source?: string; + page?: number; + size?: number; +} + +/** 소스별 카운트 */ +export type VesselCountBySource = Record; diff --git a/frontend/wing-gis-web/src/utils/coordinate.ts b/frontend/wing-gis-web/src/utils/coordinate.ts new file mode 100644 index 0000000..d6c737d --- /dev/null +++ b/frontend/wing-gis-web/src/utils/coordinate.ts @@ -0,0 +1,33 @@ +/** + * 십진도 → DMS(도분초) 변환 + * 예: 37.4563 → 37°27'22"N + */ +export function toDMS(decimal: number, isLat: boolean): string { + const abs = Math.abs(decimal); + const d = Math.floor(abs); + const m = Math.floor((abs - d) * 60); + const s = Math.round(((abs - d) * 60 - m) * 60); + const dir = isLat ? (decimal >= 0 ? 'N' : 'S') : (decimal >= 0 ? 'E' : 'W'); + return `${d}°${m.toString().padStart(2, '0')}'${s.toString().padStart(2, '0')}"${dir}`; +} + +/** + * 좌표 포맷 (DMS 표기) + */ +export function formatCoord(lat: number, lon: number): string { + return `${toDMS(lat, true)} ${toDMS(lon, false)}`; +} + +/** + * 축척 계산 (줌 레벨 → 축척) + */ +export function zoomToScale(zoom: number): string { + const scales: Record = { + 5: '1:5,000,000', 6: '1:2,500,000', 7: '1:1,000,000', + 8: '1:500,000', 9: '1:250,000', 10: '1:100,000', + 11: '1:50,000', 12: '1:25,000', 13: '1:12,000', + 14: '1:6,000', 15: '1:3,000', 16: '1:1,500', + 17: '1:750', 18: '1:350', + }; + return scales[Math.round(zoom)] || `1:${Math.round(156543.03 * Math.cos(37 * Math.PI / 180) / Math.pow(2, zoom)).toLocaleString()}`; +} diff --git a/frontend/wing-gis-web/src/utils/s52Colors.ts b/frontend/wing-gis-web/src/utils/s52Colors.ts new file mode 100644 index 0000000..e63bcc1 --- /dev/null +++ b/frontend/wing-gis-web/src/utils/s52Colors.ts @@ -0,0 +1,31 @@ +/** + * IHO S-52 Portrayal Catalogue 색상 팔레트 + * 수심구역 표현 표준 색상 + */ +export const S52 = { + drying: '#98D898', // 간출지 + d0_5: '#C6ECEC', // 0~5m (천해) + d5_10: '#A0D0E8', // 5~10m + d10_20: '#7CB8D4', // 10~20m + d20_50: '#5898BC', // 20~50m + d50_100: '#3878A0', // 50~100m + d100: '#2A5880', // 100m+ + land: '#F0D8A8', // 육지 (황갈색) + sand: '#E8D070', // 모래 +} as const; + +/** + * S-100 제품별 배지 색상 + */ +export const S100_BADGE_COLORS: Record = { + 'S-101': '#005fa3', + 'S-102': '#0077a0', + 'S-104': '#0099aa', + 'S-111': '#007755', + 'S-122': '#336699', + 'S-123': '#996633', + 'S-124': '#2d6e4e', + 'S-127': '#884466', + 'S-412': '#8b4513', + 'S-414': '#4a6080', +}; diff --git a/frontend/wing-gis-web/src/utils/vesselIcon.ts b/frontend/wing-gis-web/src/utils/vesselIcon.ts new file mode 100644 index 0000000..60cd54b --- /dev/null +++ b/frontend/wing-gis-web/src/utils/vesselIcon.ts @@ -0,0 +1,44 @@ +import { VESSEL_COLORS, VESSEL_SHAPES, type VesselSource } from '../types/vessel'; + +/** + * 물표 소스별 SVG 아이콘 생성 (KHOA 스타일) + * AIS/V-Pass/E-NAV = 삼각형, VTS = 사각형, VTS-RADAR = 다이아몬드, AIR-AIS = 비행기 + */ +export function createVesselSvg(source: VesselSource, size = 14): string { + const color = VESSEL_COLORS[source] || '#666'; + const shape = VESSEL_SHAPES[source] || 'triangle'; + + switch (shape) { + case 'triangle': + return ` + + `; + case 'square': + return ` + + `; + case 'diamond': + return ` + + `; + case 'plane': + return ` + + `; + default: + return ` + + `; + } +} + +/** + * VHF-DSC 조난 전용 깜빡이 SVG + */ +export function createDistressSvg(size = 14): string { + return ` + + + + `; +} diff --git a/frontend/wing-gis-web/src/vite-env.d.ts b/frontend/wing-gis-web/src/vite-env.d.ts new file mode 100644 index 0000000..536639b --- /dev/null +++ b/frontend/wing-gis-web/src/vite-env.d.ts @@ -0,0 +1,10 @@ +/// + +interface ImportMetaEnv { + readonly VITE_API_BASE: string; + readonly VITE_WS_URL: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/frontend/wing-gis-web/tsconfig.app.json b/frontend/wing-gis-web/tsconfig.app.json new file mode 100644 index 0000000..af516fc --- /dev/null +++ b/frontend/wing-gis-web/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2023", + "useDefineForClassFields": true, + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/wing-gis-web/tsconfig.json b/frontend/wing-gis-web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/wing-gis-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/wing-gis-web/tsconfig.node.json b/frontend/wing-gis-web/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/wing-gis-web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/wing-gis-web/vite.config.ts b/frontend/wing-gis-web/vite.config.ts new file mode 100644 index 0000000..bfddfed --- /dev/null +++ b/frontend/wing-gis-web/vite.config.ts @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], + server: { + port: 5174, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/martin': { + target: 'http://192.168.1.18:3030', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/martin/, ''), + }, + '/signal-batch': { + target: 'https://wing.gc-si.dev', + changeOrigin: true, + secure: false, + }, + '/shipimg': { + target: 'https://wing.gc-si.dev', + changeOrigin: true, + secure: false, + }, + }, + }, +}) diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..9186f30 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,116 @@ +version: '3.8' + +# WING-GIS Development Environment +# 해양경찰청 통합 GIS 위치정보시스템 + +services: + + # ============================================================ + # PostgreSQL + PostGIS + # ============================================================ + postgres: + image: postgis/postgis:16-3.4 + container_name: wing-gis-db + restart: unless-stopped + environment: + POSTGRES_DB: wing_gis + POSTGRES_USER: wingis + POSTGRES_PASSWORD: wingis_dev_2026 + PGDATA: /var/lib/postgresql/data/pgdata + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + - ./sql/001_init_schema.sql:/docker-entrypoint-initdb.d/001_init_schema.sql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U wingis -d wing_gis"] + interval: 10s + timeout: 5s + retries: 5 + + # ============================================================ + # Redis (물표 캐시 + 세션) + # ============================================================ + redis: + image: redis:7-alpine + container_name: wing-gis-redis + restart: unless-stopped + ports: + - "6379:6379" + command: redis-server --maxmemory 512mb --maxmemory-policy allkeys-lru + + # ============================================================ + # Kafka (물표 스트리밍) + # ============================================================ + kafka: + image: bitnami/kafka:3.7 + container_name: wing-gis-kafka + restart: unless-stopped + environment: + KAFKA_CFG_NODE_ID: 0 + KAFKA_CFG_PROCESS_ROLES: controller,broker + KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 0@kafka:9093 + KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093,EXTERNAL://:9094 + KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092,EXTERNAL://localhost:9094 + KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,EXTERNAL:PLAINTEXT,PLAINTEXT:PLAINTEXT + KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER + KAFKA_CFG_AUTO_CREATE_TOPICS_ENABLE: "true" + ports: + - "9094:9094" + + # ============================================================ + # MinIO (파일 스토리지 - SHP/GeoJSON 업로드) + # ============================================================ + minio: + image: minio/minio:latest + container_name: wing-gis-minio + restart: unless-stopped + environment: + MINIO_ROOT_USER: wingis + MINIO_ROOT_PASSWORD: wingis_minio_2026 + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + + # ============================================================ + # pg_tileserv (벡터 타일 서빙) + # ============================================================ + tileserv: + image: pramsey/pg_tileserv:latest + container_name: wing-gis-tileserv + restart: unless-stopped + environment: + DATABASE_URL: postgres://wingis:wingis_dev_2026@postgres:5432/wing_gis + ports: + - "7800:7800" + depends_on: + postgres: + condition: service_healthy + + # ============================================================ + # Keycloak (SSO/GPKI 인증 - INR-04) + # ============================================================ + keycloak: + image: quay.io/keycloak/keycloak:24.0 + container_name: wing-gis-keycloak + restart: unless-stopped + environment: + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin_2026 + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres:5432/wing_gis + KC_DB_USERNAME: wingis + KC_DB_PASSWORD: wingis_dev_2026 + command: start-dev + ports: + - "8180:8080" + depends_on: + postgres: + condition: service_healthy + +volumes: + pgdata: + minio_data: diff --git a/infra/sql/001_init_schema.sql b/infra/sql/001_init_schema.sql new file mode 100644 index 0000000..0de6d05 --- /dev/null +++ b/infra/sql/001_init_schema.sql @@ -0,0 +1,284 @@ +-- WING-GIS Database Schema +-- PostgreSQL 16 + PostGIS 3.4 +-- 해양경찰청 통합 GIS 위치정보시스템 + +-- Extensions +CREATE EXTENSION IF NOT EXISTS postgis; +CREATE EXTENSION IF NOT EXISTS pg_trgm; -- 부분 검색용 trigram + +-- ============================================================ +-- 1. 물표/선박 (SFR-05, SFR-08) +-- ============================================================ +CREATE TABLE vessel ( + id BIGSERIAL PRIMARY KEY, + mmsi VARCHAR(20) UNIQUE, + imo VARCHAR(20), + name VARCHAR(100), + callsign VARCHAR(20), + ship_type VARCHAR(50), + flag VARCHAR(5), + gt DECIMAL, + dwt DECIMAL, + loa DECIMAL, + beam DECIMAL, + draft DECIMAL, + status VARCHAR(30), + source VARCHAR(20) NOT NULL DEFAULT 'AIS', + position GEOMETRY(Point, 4326), + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + nav_status VARCHAR(30), + destination VARCHAR(100), + eta TIMESTAMP, + owner VARCHAR(200), + operator VARCHAR(200), + last_updated TIMESTAMP DEFAULT NOW(), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_vessel_position ON vessel USING GIST(position); +CREATE INDEX idx_vessel_mmsi ON vessel(mmsi); +CREATE INDEX idx_vessel_name_trgm ON vessel USING GIN(name gin_trgm_ops); +CREATE INDEX idx_vessel_source ON vessel(source); +CREATE INDEX idx_vessel_flag ON vessel(flag); +CREATE INDEX idx_vessel_ship_type ON vessel(ship_type); + +COMMENT ON TABLE vessel IS '물표/선박 정보 (AIS/V-Pass/VTS/Radar/E-Nav/VHF-DSC/항공기AIS)'; +COMMENT ON COLUMN vessel.source IS 'AIS, V-Pass, E-NAV, VTS, VTS-RADAR, AIR-AIS, VHF-DSC'; + +-- ============================================================ +-- 2. 항적 이력 (SFR-05) +-- ============================================================ +CREATE TABLE vessel_track ( + id BIGSERIAL, + vessel_id BIGINT NOT NULL REFERENCES vessel(id) ON DELETE CASCADE, + position GEOMETRY(Point, 4326) NOT NULL, + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + source VARCHAR(20), + recorded_at TIMESTAMP NOT NULL DEFAULT NOW(), + PRIMARY KEY (id, recorded_at) +); + +CREATE INDEX idx_track_vessel_time ON vessel_track(vessel_id, recorded_at DESC); +CREATE INDEX idx_track_position ON vessel_track USING GIST(position); + +COMMENT ON TABLE vessel_track IS '선박 항적 이력 (TimescaleDB hypertable 전환 권장)'; + +-- ============================================================ +-- 3. 레이어 관리 (SFR-03, SFR-07) +-- ============================================================ +CREATE TABLE layer_group ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + name_en VARCHAR(100), + parent_id INTEGER REFERENCES layer_group(id), + sort_order INTEGER DEFAULT 0, + icon VARCHAR(10), + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE layer ( + id BIGSERIAL PRIMARY KEY, + group_id INTEGER REFERENCES layer_group(id), + name VARCHAR(200) NOT NULL, + name_en VARCHAR(200), + layer_type VARCHAR(30) NOT NULL DEFAULT 'VECTOR', + s100_product VARCHAR(10), + geometry_type VARCHAR(20), + srid INTEGER DEFAULT 4326, + style_sld TEXT, + min_scale INTEGER, + max_scale INTEGER, + is_visible BOOLEAN DEFAULT TRUE, + is_default_on BOOLEAN DEFAULT FALSE, + feature_count INTEGER DEFAULT 0, + source_url VARCHAR(500), + file_path VARCHAR(500), + created_by BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +COMMENT ON COLUMN layer.s100_product IS 'S-101, S-102, S-104, S-111, S-122, S-124, S-127, S-412, S-414'; + +-- ============================================================ +-- 4. S-100 전자해도 데이터셋 (SFR-04) +-- ============================================================ +CREATE TABLE chart_dataset ( + id BIGSERIAL PRIMARY KEY, + cell_name VARCHAR(20) NOT NULL, + product VARCHAR(10) NOT NULL DEFAULT 'S-101', + edition INTEGER NOT NULL DEFAULT 1, + update_num INTEGER DEFAULT 0, + scale INTEGER, + status VARCHAR(20) DEFAULT 'CURRENT', + coverage GEOMETRY(Polygon, 4326), + fc_version VARCHAR(20), + pc_version VARCHAR(20), + s100_version VARCHAR(20) DEFAULT '5.2.1', + feature_count INTEGER DEFAULT 0, + file_path VARCHAR(500), + issued_date DATE, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_chart_coverage ON chart_dataset USING GIST(coverage); +CREATE INDEX idx_chart_cell ON chart_dataset(cell_name); + +-- ============================================================ +-- 5. 해도 피처 (SFR-03, SFR-04) +-- ============================================================ +CREATE TABLE chart_feature ( + id BIGSERIAL PRIMARY KEY, + dataset_id BIGINT REFERENCES chart_dataset(id) ON DELETE CASCADE, + feature_type VARCHAR(50) NOT NULL, + category VARCHAR(30), + feature_name_ko VARCHAR(200), + feature_name_en VARCHAR(200), + geometry GEOMETRY(Geometry, 4326) NOT NULL, + attributes JSONB DEFAULT '{}', + scamin INTEGER, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_feature_geom ON chart_feature USING GIST(geometry); +CREATE INDEX idx_feature_type ON chart_feature(feature_type); +CREATE INDEX idx_feature_dataset ON chart_feature(dataset_id); +CREATE INDEX idx_feature_attrs ON chart_feature USING GIN(attributes); + +COMMENT ON COLUMN chart_feature.feature_type IS 'S-101 FC: Lighthouse, DepthArea, DepthContour, Buoy, Rock, Wreck, FairwaySystem, AnchorageArea, RestrictedArea, etc.'; + +-- ============================================================ +-- 6. 경계/관할구역 (SFR-03) +-- ============================================================ +CREATE TABLE boundary ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + name_en VARCHAR(100), + boundary_type VARCHAR(30) NOT NULL, + geometry GEOMETRY(MultiPolygon, 4326), + organization VARCHAR(100), + parent_id INTEGER REFERENCES boundary(id), + attributes JSONB DEFAULT '{}', + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_boundary_geom ON boundary USING GIST(geometry); +CREATE INDEX idx_boundary_type ON boundary(boundary_type); + +COMMENT ON COLUMN boundary.boundary_type IS 'EEZ, NLL, TERRITORIAL, JURISDICTION, PATROL_ROUTE, RESTRICTED, MILITARY'; + +-- ============================================================ +-- 7. 분석 결과 (SFR-09) +-- ============================================================ +CREATE TABLE analysis_result ( + id BIGSERIAL PRIMARY KEY, + analysis_type VARCHAR(50) NOT NULL, + sub_type VARCHAR(50), + name VARCHAR(200), + geometry GEOMETRY(Geometry, 4326), + result_data JSONB DEFAULT '{}', + confidence DECIMAL, + status VARCHAR(20) DEFAULT 'ACTIVE', + created_by BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + expires_at TIMESTAMP +); + +CREATE INDEX idx_analysis_geom ON analysis_result USING GIST(geometry); +CREATE INDEX idx_analysis_type ON analysis_result(analysis_type); + +COMMENT ON COLUMN analysis_result.analysis_type IS 'ILLEGAL_FISHING, TRANSSHIPMENT, DARK_VESSEL, DOKDO, PASSENGER, CN_FISHING, TRAFFIC'; + +-- ============================================================ +-- 8. 사용자 (INR-04) +-- ============================================================ +CREATE TABLE app_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(200), + name VARCHAR(50) NOT NULL, + rank VARCHAR(30), + department VARCHAR(100), + organization VARCHAR(100), + role VARCHAR(30) NOT NULL DEFAULT 'VIEWER', + jurisdiction_id INTEGER REFERENCES boundary(id), + sso_id VARCHAR(100), + gpki_dn VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +COMMENT ON COLUMN app_user.role IS 'ADMIN, OPERATOR, VIEWER'; +COMMENT ON COLUMN app_user.organization IS '해양경찰청, 서해지방청, 인천해양경찰서, ...'; + +-- ============================================================ +-- 9. 경보/알림 +-- ============================================================ +CREATE TABLE alert ( + id BIGSERIAL PRIMARY KEY, + alert_type VARCHAR(30) NOT NULL, + severity VARCHAR(10) NOT NULL DEFAULT 'INFO', + title VARCHAR(200) NOT NULL, + description TEXT, + vessel_id BIGINT REFERENCES vessel(id), + position GEOMETRY(Point, 4326), + is_acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_by BIGINT REFERENCES app_user(id), + acknowledged_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_alert_severity ON alert(severity, created_at DESC); +CREATE INDEX idx_alert_vessel ON alert(vessel_id); + +COMMENT ON COLUMN alert.alert_type IS 'SOS, ZONE_VIOLATION, SIGNAL_LOST, EQUIPMENT_FAULT, WEATHER, AI_DETECTION'; + +-- ============================================================ +-- 10. 감사 로그 (SER-09) +-- ============================================================ +CREATE TABLE audit_log ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT REFERENCES app_user(id), + action VARCHAR(50) NOT NULL, + target_type VARCHAR(50), + target_id VARCHAR(50), + detail JSONB, + ip_address VARCHAR(45), + user_agent VARCHAR(500), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_audit_user ON audit_log(user_id, created_at DESC); +CREATE INDEX idx_audit_action ON audit_log(action, created_at DESC); + +-- ============================================================ +-- 초기 데이터 +-- ============================================================ + +-- 레이어 그룹 +INSERT INTO layer_group (name, name_en, icon, is_system, sort_order) VALUES +('사용자 레이어', 'User Layers', '📁', FALSE, 1), +('S-100 차세대 전자해도', 'S-100 ENC Layers', '🗺', TRUE, 2), +('물표 레이어', 'Vessel Layers', '🚢', TRUE, 3), +('해양안전 레이어', 'Maritime Safety', '🔴', TRUE, 4), +('연계 레이어', 'Integration Layers', '🔗', TRUE, 5); + +-- 경계 구역 +INSERT INTO boundary (name, name_en, boundary_type, organization) VALUES +('배타적 경제수역', 'EEZ', 'EEZ', '해양경찰청'), +('북방한계선', 'NLL', 'NLL', '해양경찰청'), +('영해 기선', 'Territorial Sea', 'TERRITORIAL', '해양경찰청'), +('인천해양경찰서 관할', 'Incheon KCG', 'JURISDICTION', '인천해양경찰서'), +('태안해양경찰서 관할', 'Taean KCG', 'JURISDICTION', '태안해양경찰서'), +('군산해양경찰서 관할', 'Gunsan KCG', 'JURISDICTION', '군산해양경찰서'), +('목포해양경찰서 관할', 'Mokpo KCG', 'JURISDICTION', '목포해양경찰서'); + +-- 기본 관리자 +INSERT INTO app_user (username, name, rank, department, organization, role) VALUES +('admin', '김영수', '사무관', '정보통신과', '해양경찰청', 'ADMIN'); diff --git a/services/wing-gis-map/Dockerfile b/services/wing-gis-map/Dockerfile new file mode 100644 index 0000000..9e113e0 --- /dev/null +++ b/services/wing-gis-map/Dockerfile @@ -0,0 +1,5 @@ +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +COPY build/libs/wing-gis-map-*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/services/wing-gis-map/build.gradle b/services/wing-gis-map/build.gradle new file mode 100644 index 0000000..6604737 --- /dev/null +++ b/services/wing-gis-map/build.gradle @@ -0,0 +1,64 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'kr.go.kcg' +version = '0.1.0' +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { extendsFrom annotationProcessor } +} + +repositories { + mavenCentral() + maven { url 'https://repo.osgeo.org/repository/release/' } +} + +dependencies { + // Spring Boot + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-websocket' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + // Database + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + implementation 'org.hibernate.orm:hibernate-spatial:6.6.4.Final' + + // API Documentation (INR-05) + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.3.0' + + // Jackson GeoJSON + implementation 'org.n52.jackson:jackson-datatype-jts:1.2.10' + + // Kafka (물표 스트리밍) + implementation 'org.springframework.kafka:spring-kafka' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // MapStruct (DTO 매핑) + implementation 'org.mapstruct:mapstruct:1.6.3' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' + + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.testcontainers:postgresql:1.20.4' + testImplementation 'org.testcontainers:junit-jupiter:1.20.4' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/services/wing-gis-map/gradle/wrapper/gradle-wrapper.properties b/services/wing-gis-map/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f78a6 --- /dev/null +++ b/services/wing-gis-map/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/services/wing-gis-map/gradlew b/services/wing-gis-map/gradlew new file mode 100755 index 0000000..adff685 --- /dev/null +++ b/services/wing-gis-map/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/services/wing-gis-map/gradlew.bat b/services/wing-gis-map/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/services/wing-gis-map/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/services/wing-gis-map/settings.gradle b/services/wing-gis-map/settings.gradle new file mode 100644 index 0000000..1a17426 --- /dev/null +++ b/services/wing-gis-map/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'wing-gis-map' diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/WingGisMapApplication.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/WingGisMapApplication.java new file mode 100644 index 0000000..68f91ab --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/WingGisMapApplication.java @@ -0,0 +1,12 @@ +package kr.go.kcg.wingis; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class WingGisMapApplication { + + public static void main(String[] args) { + SpringApplication.run(WingGisMapApplication.class, args); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/CorsConfig.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/CorsConfig.java new file mode 100644 index 0000000..95519ca --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/CorsConfig.java @@ -0,0 +1,28 @@ +package kr.go.kcg.wingis.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + public CorsFilter corsFilter() { + var config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("http://localhost:*", "https://*.kcg.go.kr")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + var source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", config); + source.registerCorsConfiguration("/ws/**", config); + return new CorsFilter(source); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/JacksonConfig.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/JacksonConfig.java new file mode 100644 index 0000000..a0b4260 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/JacksonConfig.java @@ -0,0 +1,19 @@ +package kr.go.kcg.wingis.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + var mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + return mapper; + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/SecurityConfig.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/SecurityConfig.java new file mode 100644 index 0000000..5dd48d4 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/SecurityConfig.java @@ -0,0 +1,29 @@ +package kr.go.kcg.wingis.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() + .requestMatchers("/ws/**").permitAll() + .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/api/**").permitAll() // TODO: 운영 시 인증 적용 + .anyRequest().authenticated() + ); + return http.build(); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/WebSocketConfig.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/WebSocketConfig.java new file mode 100644 index 0000000..7cb4dac --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/config/WebSocketConfig.java @@ -0,0 +1,25 @@ +package kr.go.kcg.wingis.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.messaging.simp.config.MessageBrokerRegistry; +import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker; +import org.springframework.web.socket.config.annotation.StompEndpointRegistry; +import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer; + +@Configuration +@EnableWebSocketMessageBroker +public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { + + @Override + public void configureMessageBroker(MessageBrokerRegistry config) { + config.enableSimpleBroker("/topic"); + config.setApplicationDestinationPrefixes("/app"); + } + + @Override + public void registerStompEndpoints(StompEndpointRegistry registry) { + registry.addEndpoint("/ws/vessels") + .setAllowedOriginPatterns("*") + .withSockJS(); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/analysis/AnalysisResult.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/analysis/AnalysisResult.java new file mode 100644 index 0000000..738eb5f --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/analysis/AnalysisResult.java @@ -0,0 +1,59 @@ +package kr.go.kcg.wingis.domain.analysis; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; + +@Entity +@Table(name = "analysis_result") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AnalysisResult { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "analysis_type", length = 50) + private String analysisType; + + @Column(name = "sub_type", length = 50) + private String subType; + + @Column(length = 200) + private String name; + + @Column(length = 1000) + private String geometry; + + @Column(name = "result_data", columnDefinition = "jsonb") + private String resultData; + + private Double confidence; + + @Column(length = 20) + private String status; + + @Column(name = "created_by") + private Long createdBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "expires_at") + private LocalDateTime expiresAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/boundary/Boundary.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/boundary/Boundary.java new file mode 100644 index 0000000..7d976bc --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/boundary/Boundary.java @@ -0,0 +1,54 @@ +package kr.go.kcg.wingis.domain.boundary; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; + +@Entity +@Table(name = "boundary") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Boundary { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(length = 200, nullable = false) + private String name; + + @Column(name = "name_en", length = 200) + private String nameEn; + + @Column(name = "boundary_type", length = 30) + private String boundaryType; + + @Column(length = 2000) + private String geometry; + + @Column(length = 100) + private String organization; + + @Column(name = "parent_id") + private Integer parentId; + + @Column(columnDefinition = "jsonb") + private String attributes; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartController.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartController.java new file mode 100644 index 0000000..4177871 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartController.java @@ -0,0 +1,56 @@ +package kr.go.kcg.wingis.domain.chart; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Chart", description = "S-100 전자해도 API (SFR-04)") +@RestController +@RequestMapping("/api/v1/charts") +@RequiredArgsConstructor +public class ChartController { + + private final ChartDatasetRepository datasetRepo; + private final ChartFeatureRepository featureRepo; + + @Operation(summary = "S-100 데이터셋 목록") + @GetMapping + public ResponseEntity> getDatasets( + @RequestParam(defaultValue = "S-101") String product) { + return ResponseEntity.ok(datasetRepo.findByProductOrderByCellNameAsc(product)); + } + + @Operation(summary = "셀명으로 데이터셋 조회") + @GetMapping("/{cellName}") + public ResponseEntity getByCell(@PathVariable String cellName) { + return ResponseEntity.ok( + datasetRepo.findByCellNameAndStatus(cellName, "CURRENT") + .orElseThrow(() -> new IllegalArgumentException("Dataset not found: " + cellName)) + ); + } + + @Operation(summary = "해도 피처 조회 (bbox + featureType)") + @GetMapping("/features") + public ResponseEntity> getFeatures( + @RequestParam double minLon, @RequestParam double minLat, + @RequestParam double maxLon, @RequestParam double maxLat, + @RequestParam(required = false) String featureType, + @RequestParam(defaultValue = "1000") int limit) { + return ResponseEntity.ok( + featureRepo.findByBboxAndType(minLon, minLat, maxLon, maxLat, featureType, limit) + ); + } + + @Operation(summary = "데이터셋별 피처 목록") + @GetMapping("/{cellName}/features") + public ResponseEntity> getDatasetFeatures(@PathVariable String cellName) { + var ds = datasetRepo.findByCellNameAndStatus(cellName, "CURRENT") + .orElseThrow(() -> new IllegalArgumentException("Dataset not found")); + return ResponseEntity.ok(featureRepo.findByDatasetId(ds.getId())); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDataset.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDataset.java new file mode 100644 index 0000000..9d3370e --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDataset.java @@ -0,0 +1,71 @@ +package kr.go.kcg.wingis.domain.chart; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "chart_dataset") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChartDataset { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "cell_name", length = 20) + private String cellName; + + @Column(length = 10) + private String product; + + private Integer edition; + + @Column(name = "update_num") + private Integer updateNum; + + private Integer scale; + + @Column(length = 20) + private String status; + + @Column(length = 2000) + private String coverage; + + @Column(name = "fc_version", length = 20) + private String fcVersion; + + @Column(name = "pc_version", length = 20) + private String pcVersion; + + @Column(name = "s100_version", length = 20) + private String s100Version; + + @Column(name = "feature_count") + private Integer featureCount; + + @Column(name = "file_path", length = 500) + private String filePath; + + @Column(name = "issued_date") + private LocalDate issuedDate; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDatasetRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDatasetRepository.java new file mode 100644 index 0000000..f082596 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartDatasetRepository.java @@ -0,0 +1,24 @@ +package kr.go.kcg.wingis.domain.chart; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ChartDatasetRepository extends JpaRepository { + + Optional findByCellNameAndStatus(String cellName, String status); + + List findByProductOrderByCellNameAsc(String product); + + @Query(value = """ + SELECT * FROM chart_dataset + WHERE ST_Intersects(coverage, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326)) + AND status = 'CURRENT' + """, nativeQuery = true) + List findByBbox( + @Param("minLon") double minLon, @Param("minLat") double minLat, + @Param("maxLon") double maxLon, @Param("maxLat") double maxLat); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeature.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeature.java new file mode 100644 index 0000000..50d070a --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeature.java @@ -0,0 +1,56 @@ +package kr.go.kcg.wingis.domain.chart; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; + +@Entity +@Table(name = "chart_feature") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChartFeature { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "dataset_id", nullable = false) + private Long datasetId; + + @Column(name = "feature_type", length = 50) + private String featureType; + + @Column(length = 50) + private String category; + + @Column(name = "feature_name_ko", length = 200) + private String featureNameKo; + + @Column(name = "feature_name_en", length = 200) + private String featureNameEn; + + @Column(length = 1000) + private String geometry; + + @Column(columnDefinition = "jsonb") + private String attributes; + + private Integer scamin; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeatureRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeatureRepository.java new file mode 100644 index 0000000..10ed5e3 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/chart/ChartFeatureRepository.java @@ -0,0 +1,26 @@ +package kr.go.kcg.wingis.domain.chart; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ChartFeatureRepository extends JpaRepository { + + List findByDatasetId(Long datasetId); + + List findByFeatureType(String featureType); + + @Query(value = """ + SELECT * FROM chart_feature + WHERE ST_Intersects(geometry, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326)) + AND (:featureType IS NULL OR feature_type = :featureType) + LIMIT :limit + """, nativeQuery = true) + List findByBboxAndType( + @Param("minLon") double minLon, @Param("minLat") double minLat, + @Param("maxLon") double maxLon, @Param("maxLat") double maxLat, + @Param("featureType") String featureType, + @Param("limit") int limit); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/Layer.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/Layer.java new file mode 100644 index 0000000..e95d64c --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/Layer.java @@ -0,0 +1,88 @@ +package kr.go.kcg.wingis.domain.layer; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "layer") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Layer { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "group_id") + private Integer groupId; + + @Column(length = 200, nullable = false) + private String name; + + @Column(name = "name_en", length = 200) + private String nameEn; + + @Column(name = "layer_type", length = 30) + private String layerType; + + @Column(name = "s100_product", length = 10) + private String s100Product; + + @Column(name = "geometry_type", length = 30) + private String geometryType; + + @Column(columnDefinition = "integer default 4326") + private Integer srid; + + @Column(name = "style_sld", columnDefinition = "text") + private String styleSld; + + @Column(name = "min_scale") + private Integer minScale; + + @Column(name = "max_scale") + private Integer maxScale; + + @Column(name = "is_visible") + private Boolean isVisible; + + @Column(name = "is_default_on") + private Boolean isDefaultOn; + + @Column(name = "feature_count") + private Integer featureCount; + + @Column(name = "source_url", length = 500) + private String sourceUrl; + + @Column(name = "file_path", length = 500) + private String filePath; + + @Column(name = "sort_order") + private Integer sortOrder; + + @Column(name = "created_by") + private Long createdBy; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerController.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerController.java new file mode 100644 index 0000000..926a780 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerController.java @@ -0,0 +1,66 @@ +package kr.go.kcg.wingis.domain.layer; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.go.kcg.wingis.dto.LayerDto; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Layer", description = "레이어 관리 API (SFR-03, SFR-07)") +@RestController +@RequestMapping("/api/v1/layers") +@RequiredArgsConstructor +public class LayerController { + + private final LayerGroupRepository groupRepo; + private final LayerRepository layerRepo; + + @Operation(summary = "레이어 트리 조회", description = "그룹 + 하위 레이어 트리 구조") + @GetMapping + public ResponseEntity> getLayerTree() { + var groups = groupRepo.findByParentIdIsNullOrderBySortOrderAsc(); + var tree = groups.stream().map(g -> { + var layers = layerRepo.findByGroupIdOrderBySortOrderAsc(g.getId()); + return LayerDto.builder() + .id(Long.valueOf(g.getId())) + .name(g.getName()) + .nameEn(g.getNameEn()) + .children(layers.stream().map(this::toDto).toList()) + .build(); + }).toList(); + return ResponseEntity.ok(tree); + } + + @Operation(summary = "S-100 제품별 레이어 조회") + @GetMapping("/s100/{product}") + public ResponseEntity> getByProduct(@PathVariable String product) { + var layers = layerRepo.findByS100ProductOrderByNameAsc(product); + return ResponseEntity.ok(layers.stream().map(this::toDto).toList()); + } + + @Operation(summary = "기본 활성 레이어 목록") + @GetMapping("/defaults") + public ResponseEntity> getDefaults() { + var layers = layerRepo.findByIsDefaultOnTrueOrderBySortOrderAsc(); + return ResponseEntity.ok(layers.stream().map(this::toDto).toList()); + } + + private LayerDto toDto(Layer l) { + return LayerDto.builder() + .id(l.getId()) + .groupId(l.getGroupId()) + .name(l.getName()) + .nameEn(l.getNameEn()) + .layerType(l.getLayerType()) + .s100Product(l.getS100Product()) + .geometryType(l.getGeometryType()) + .isVisible(l.getIsVisible()) + .isDefaultOn(l.getIsDefaultOn()) + .featureCount(l.getFeatureCount()) + .sourceUrl(l.getSourceUrl()) + .build(); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroup.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroup.java new file mode 100644 index 0000000..0dae846 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroup.java @@ -0,0 +1,52 @@ +package kr.go.kcg.wingis.domain.layer; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "layer_group") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class LayerGroup { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + + @Column(length = 100, nullable = false) + private String name; + + @Column(name = "name_en", length = 100) + private String nameEn; + + @Column(name = "parent_id") + private Integer parentId; + + @Column(name = "sort_order") + private Integer sortOrder; + + @Column(length = 50) + private String icon; + + @Column(name = "is_system") + private Boolean isSystem; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroupRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroupRepository.java new file mode 100644 index 0000000..964021c --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerGroupRepository.java @@ -0,0 +1,12 @@ +package kr.go.kcg.wingis.domain.layer; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LayerGroupRepository extends JpaRepository { + + List findByParentIdIsNullOrderBySortOrderAsc(); + + List findByParentIdOrderBySortOrderAsc(Integer parentId); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerRepository.java new file mode 100644 index 0000000..00a2947 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/layer/LayerRepository.java @@ -0,0 +1,14 @@ +package kr.go.kcg.wingis.domain.layer; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface LayerRepository extends JpaRepository { + + List findByGroupIdOrderBySortOrderAsc(Integer groupId); + + List findByS100ProductOrderByNameAsc(String s100Product); + + List findByIsDefaultOnTrueOrderBySortOrderAsc(); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/user/AppUser.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/user/AppUser.java new file mode 100644 index 0000000..add0b8b --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/user/AppUser.java @@ -0,0 +1,70 @@ +package kr.go.kcg.wingis.domain.user; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "app_user") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AppUser { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 50, nullable = false, unique = true) + private String username; + + @Column(name = "password_hash", length = 200) + private String passwordHash; + + @Column(length = 50) + private String name; + + @Column(length = 30) + private String rank; + + @Column(length = 100) + private String department; + + @Column(length = 100) + private String organization; + + @Column(length = 20) + private String role; + + @Column(name = "jurisdiction_id") + private Integer jurisdictionId; + + @Column(name = "sso_id", length = 100) + private String ssoId; + + @Column(name = "gpki_dn", length = 300) + private String gpkiDn; + + @Column(name = "is_active") + private Boolean isActive; + + @Column(name = "last_login") + private LocalDateTime lastLogin; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/Vessel.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/Vessel.java new file mode 100644 index 0000000..de5e1b5 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/Vessel.java @@ -0,0 +1,93 @@ +package kr.go.kcg.wingis.domain.vessel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import java.time.LocalDateTime; + +@Entity +@Table(name = "vessel") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Vessel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 9) + private String mmsi; + + @Column(length = 7) + private String imo; + + @Column(length = 200) + private String name; + + @Column(length = 20) + private String callsign; + + @Column(name = "ship_type", length = 50) + private String shipType; + + @Column(length = 10) + private String flag; + + private Double gt; + + private Double dwt; + + private Double loa; + + private Double beam; + + private Double draft; + + @Column(length = 20) + private String status; + + @Column(length = 20) + private String source; + + @Column(length = 200) + private String position; + + private Double sog; + + private Double cog; + + private Double heading; + + @Column(name = "nav_status", length = 50) + private String navStatus; + + @Column(length = 200) + private String destination; + + private LocalDateTime eta; + + @Column(length = 200) + private String owner; + + @Column(length = 200) + private String operator; + + @Column(name = "last_updated") + private LocalDateTime lastUpdated; + + @CreationTimestamp + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselController.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselController.java new file mode 100644 index 0000000..30ba550 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselController.java @@ -0,0 +1,63 @@ +package kr.go.kcg.wingis.domain.vessel; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.go.kcg.wingis.dto.VesselDto; +import kr.go.kcg.wingis.dto.VesselSearchRequest; +import kr.go.kcg.wingis.dto.VesselTrackDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Tag(name = "Vessel", description = "물표/선박 API (SFR-05, SFR-08)") +@RestController +@RequestMapping("/api/v1/vessels") +@RequiredArgsConstructor +public class VesselController { + + private final VesselService vesselService; + + @Operation(summary = "물표 목록 조회", description = "전체 또는 소스별 물표 목록") + @GetMapping + public ResponseEntity> getAll(@RequestParam(required = false) String source) { + return ResponseEntity.ok(vesselService.getAllVessels(source)); + } + + @Operation(summary = "MMSI로 물표 상세 조회") + @GetMapping("/{mmsi}") + public ResponseEntity getByMmsi(@PathVariable String mmsi) { + return ResponseEntity.ok(vesselService.getByMmsi(mmsi)); + } + + @Operation(summary = "선박 통합 검색 (SFR-08)", description = "선박명/MMSI/호출부호/선종/국적 부분 일치 검색") + @GetMapping("/search") + public ResponseEntity> search(VesselSearchRequest request) { + return ResponseEntity.ok(vesselService.search(request)); + } + + @Operation(summary = "영역(bbox) 내 물표 조회") + @GetMapping("/bbox") + public ResponseEntity> getByBbox( + @RequestParam double minLon, @RequestParam double minLat, + @RequestParam double maxLon, @RequestParam double maxLat) { + return ResponseEntity.ok(vesselService.getWithinBbox(minLon, minLat, maxLon, maxLat)); + } + + @Operation(summary = "항적 조회", description = "최근 N시간 항적 이력") + @GetMapping("/{mmsi}/tracks") + public ResponseEntity> getTracks( + @PathVariable String mmsi, + @RequestParam(defaultValue = "24") int hours) { + return ResponseEntity.ok(vesselService.getTracks(mmsi, hours)); + } + + @Operation(summary = "소스별 물표 카운트") + @GetMapping("/count") + public ResponseEntity> countBySource() { + return ResponseEntity.ok(vesselService.countBySource()); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselRepository.java new file mode 100644 index 0000000..72e1d81 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselRepository.java @@ -0,0 +1,50 @@ +package kr.go.kcg.wingis.domain.vessel; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface VesselRepository extends JpaRepository { + + Optional findByMmsi(String mmsi); + + List findBySource(String source); + + List findByFlag(String flag); + + /** + * SFR-08: 선박 통합 검색 (선박명/MMSI/호출부호 부분 일치) + */ + @Query(""" + SELECT v FROM Vessel v + WHERE LOWER(v.name) LIKE LOWER(CONCAT('%', :q, '%')) + OR REPLACE(v.mmsi, ' ', '') LIKE CONCAT('%', :q, '%') + OR LOWER(v.callsign) LIKE LOWER(CONCAT('%', :q, '%')) + OR LOWER(v.imo) LIKE LOWER(CONCAT('%', :q, '%')) + ORDER BY v.lastUpdated DESC + """) + Page searchByKeyword(@Param("q") String query, Pageable pageable); + + /** + * 영역(bbox) 내 물표 조회 + */ + @Query(value = """ + SELECT * FROM vessel + WHERE ST_Within(position, ST_MakeEnvelope(:minLon, :minLat, :maxLon, :maxLat, 4326)) + ORDER BY last_updated DESC + """, nativeQuery = true) + List findWithinBbox( + @Param("minLon") double minLon, @Param("minLat") double minLat, + @Param("maxLon") double maxLon, @Param("maxLat") double maxLat); + + /** + * 소스별 물표 카운트 + */ + @Query("SELECT v.source, COUNT(v) FROM Vessel v GROUP BY v.source") + List countBySource(); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselService.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselService.java new file mode 100644 index 0000000..032d8b5 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselService.java @@ -0,0 +1,168 @@ +package kr.go.kcg.wingis.domain.vessel; + +import kr.go.kcg.wingis.dto.VesselDto; +import kr.go.kcg.wingis.dto.VesselSearchRequest; +import kr.go.kcg.wingis.dto.VesselTrackDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VesselService { + + private final VesselRepository vesselRepository; + private final VesselTrackRepository trackRepository; + + /** + * 전체 물표 목록 (소스별 필터 가능) + */ + public List getAllVessels(String source) { + List vessels = (source != null && !source.isEmpty()) + ? vesselRepository.findBySource(source) + : vesselRepository.findAll(); + return vessels.stream().map(this::toDto).toList(); + } + + /** + * MMSI로 단일 물표 조회 + */ + public VesselDto getByMmsi(String mmsi) { + return vesselRepository.findByMmsi(mmsi) + .map(this::toDto) + .orElseThrow(() -> new IllegalArgumentException("Vessel not found: " + mmsi)); + } + + /** + * SFR-08: 선박 통합 검색 (선박명/MMSI/호출부호 Partial match) + */ + public Page search(VesselSearchRequest req) { + var pageable = PageRequest.of(req.getPage(), req.getSize(), Sort.by(Sort.Direction.DESC, "lastUpdated")); + + if (req.getQuery() != null && !req.getQuery().isBlank()) { + return vesselRepository.searchByKeyword(req.getQuery().trim(), pageable) + .map(this::toDto); + } + return vesselRepository.findAll(pageable).map(this::toDto); + } + + /** + * 영역(bbox) 내 물표 + */ + public List getWithinBbox(double minLon, double minLat, double maxLon, double maxLat) { + return vesselRepository.findWithinBbox(minLon, minLat, maxLon, maxLat) + .stream().map(this::toDto).toList(); + } + + /** + * 항적 조회 + */ + public List getTracks(String mmsi, int hours) { + var vessel = vesselRepository.findByMmsi(mmsi) + .orElseThrow(() -> new IllegalArgumentException("Vessel not found: " + mmsi)); + var since = LocalDateTime.now().minusHours(hours); + return trackRepository.findByVesselIdAndRecordedAtAfterOrderByRecordedAtDesc(vessel.getId(), since) + .stream().map(this::toTrackDto).toList(); + } + + /** + * 소스별 물표 카운트 + */ + public Map countBySource() { + return vesselRepository.countBySource().stream() + .collect(Collectors.toMap( + r -> (String) r[0], + r -> (Long) r[1] + )); + } + + /** + * 물표 위치 갱신 (통합연계모듈에서 호출) + */ + @Transactional + public void updatePosition(String mmsi, double lat, double lon, Double sog, Double cog, String source) { + var vessel = vesselRepository.findByMmsi(mmsi).orElse(null); + if (vessel == null) { + vessel = new Vessel(); + vessel.setMmsi(mmsi); + vessel.setSource(source); + } + var position = lon + "," + lat; + vessel.setPosition(position); + vessel.setSog(sog); + vessel.setCog(cog); + vessel.setLastUpdated(LocalDateTime.now()); + vesselRepository.save(vessel); + + // 항적 기록 + var track = new VesselTrack(); + track.setVesselId(vessel.getId()); + track.setPosition(position); + track.setSog(sog); + track.setCog(cog); + track.setSource(source); + track.setRecordedAt(LocalDateTime.now()); + trackRepository.save(track); + } + + private VesselDto toDto(Vessel v) { + var dto = VesselDto.builder() + .id(v.getId()) + .mmsi(v.getMmsi()) + .imo(v.getImo()) + .name(v.getName()) + .callsign(v.getCallsign()) + .shipType(v.getShipType()) + .flag(v.getFlag()) + .status(v.getStatus()) + .source(v.getSource()) + .sog(v.getSog()) + .cog(v.getCog()) + .heading(v.getHeading()) + .gt(v.getGt()) + .dwt(v.getDwt()) + .loa(v.getLoa()) + .beam(v.getBeam()) + .draft(v.getDraft()) + .navStatus(v.getNavStatus()) + .destination(v.getDestination()) + .eta(v.getEta()) + .owner(v.getOwner()) + .operator(v.getOperator()) + .lastUpdated(v.getLastUpdated()) + .build(); + if (v.getPosition() != null && v.getPosition().contains(",")) { + String[] parts = v.getPosition().split(","); + dto.setLongitude(Double.parseDouble(parts[0])); + dto.setLatitude(Double.parseDouble(parts[1])); + } + return dto; + } + + private VesselTrackDto toTrackDto(VesselTrack t) { + var dto = VesselTrackDto.builder() + .sog(t.getSog()) + .cog(t.getCog()) + .heading(t.getHeading()) + .source(t.getSource()) + .recordedAt(t.getRecordedAt()) + .build(); + if (t.getPosition() != null && t.getPosition().contains(",")) { + String[] parts = t.getPosition().split(","); + dto.setLongitude(Double.parseDouble(parts[0])); + dto.setLatitude(Double.parseDouble(parts[1])); + } + return dto; + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselStreamController.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselStreamController.java new file mode 100644 index 0000000..89b1421 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselStreamController.java @@ -0,0 +1,32 @@ +package kr.go.kcg.wingis.domain.vessel; + +import kr.go.kcg.wingis.dto.VesselDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * WebSocket을 통한 실시간 물표 스트리밍 (SFR-05, PER-02) + * /topic/vessels 로 30초마다 전체 물표 위치 브로드캐스트 + */ +@Slf4j +@Component +@EnableScheduling +@RequiredArgsConstructor +public class VesselStreamController { + + private final VesselService vesselService; + private final SimpMessagingTemplate messagingTemplate; + + @Scheduled(fixedRate = 30000) + public void broadcastVessels() { + List vessels = vesselService.getAllVessels(null); + messagingTemplate.convertAndSend("/topic/vessels", vessels); + log.debug("Broadcast {} vessels via WebSocket", vessels.size()); + } +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrack.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrack.java new file mode 100644 index 0000000..5fd2460 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrack.java @@ -0,0 +1,46 @@ +package kr.go.kcg.wingis.domain.vessel; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import java.time.LocalDateTime; + +@Entity +@Table(name = "vessel_track") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class VesselTrack { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "vessel_id", nullable = false) + private Long vesselId; + + @Column(length = 200) + private String position; + + private Double sog; + + private Double cog; + + private Double heading; + + @Column(length = 20) + private String source; + + @Column(name = "recorded_at", nullable = false) + private LocalDateTime recordedAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrackRepository.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrackRepository.java new file mode 100644 index 0000000..265a07e --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/domain/vessel/VesselTrackRepository.java @@ -0,0 +1,25 @@ +package kr.go.kcg.wingis.domain.vessel; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.List; + +public interface VesselTrackRepository extends JpaRepository { + + List findByVesselIdAndRecordedAtAfterOrderByRecordedAtDesc( + Long vesselId, LocalDateTime after); + + @Query(value = """ + SELECT * FROM vessel_track + WHERE vessel_id = :vesselId + AND recorded_at BETWEEN :from AND :to + ORDER BY recorded_at ASC + """, nativeQuery = true) + List findTracksBetween( + @Param("vesselId") Long vesselId, + @Param("from") LocalDateTime from, + @Param("to") LocalDateTime to); +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/LayerDto.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/LayerDto.java new file mode 100644 index 0000000..8cd8163 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/LayerDto.java @@ -0,0 +1,29 @@ +package kr.go.kcg.wingis.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class LayerDto { + + private Long id; + private Integer groupId; + private String groupName; + private String name; + private String nameEn; + private String layerType; + private String s100Product; + private String geometryType; + private Boolean isVisible; + private Boolean isDefaultOn; + private Integer featureCount; + private String sourceUrl; + private List children; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselDto.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselDto.java new file mode 100644 index 0000000..0368ef3 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselDto.java @@ -0,0 +1,46 @@ +package kr.go.kcg.wingis.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VesselDto { + + private Long id; + private String mmsi; + private String imo; + private String name; + private String callsign; + private String shipType; + private String flag; + private String status; + private String source; + + // Position + private Double latitude; + private Double longitude; + private Double sog; + private Double cog; + private Double heading; + + // Vessel specs + private Double gt; + private Double dwt; + private Double loa; + private Double beam; + private Double draft; + + private String navStatus; + private String destination; + private LocalDateTime eta; + private String owner; + private String operator; + private LocalDateTime lastUpdated; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselSearchRequest.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselSearchRequest.java new file mode 100644 index 0000000..b8fa349 --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselSearchRequest.java @@ -0,0 +1,28 @@ +package kr.go.kcg.wingis.dto; + +import lombok.Data; + +/** + * SFR-08: 선박 통합 검색 요청 + * 선박명, MMSI, 호출부호, 선종, 국적, 단말유형(소스) 검색 + */ +@Data +public class VesselSearchRequest { + + private String query; // 통합 검색어 (선박명/MMSI/호출부호 부분 일치) + private String shipType; // 선종 필터 + private String flag; // 국적 필터 + private String source; // 소스 필터 (AIS/V-Pass/VTS/...) + private String status; // 상태 필터 + + // 영역 검색 + private Double minLat; + private Double minLon; + private Double maxLat; + private Double maxLon; + + // 페이징 + private int page = 0; + private int size = 50; + private String sort = "lastUpdated,desc"; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselTrackDto.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselTrackDto.java new file mode 100644 index 0000000..fcbca2c --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/dto/VesselTrackDto.java @@ -0,0 +1,23 @@ +package kr.go.kcg.wingis.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VesselTrackDto { + + private Double latitude; + private Double longitude; + private Double sog; + private Double cog; + private Double heading; + private String source; + private LocalDateTime recordedAt; +} diff --git a/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/exception/GlobalExceptionHandler.java b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..025bc7a --- /dev/null +++ b/services/wing-gis-map/src/main/java/kr/go/kcg/wingis/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package kr.go.kcg.wingis.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleNotFound(IllegalArgumentException ex) { + log.warn("Not found: {}", ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of( + "error", "NOT_FOUND", + "message", ex.getMessage(), + "timestamp", LocalDateTime.now().toString() + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error("Internal error", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of( + "error", "INTERNAL_ERROR", + "message", ex.getMessage() != null ? ex.getMessage() : "Unknown error", + "timestamp", LocalDateTime.now().toString() + )); + } +} diff --git a/services/wing-gis-map/src/main/resources/application-dev.yml b/services/wing-gis-map/src/main/resources/application-dev.yml new file mode 100644 index 0000000..a1c9124 --- /dev/null +++ b/services/wing-gis-map/src/main/resources/application-dev.yml @@ -0,0 +1,71 @@ +# 개발환경: H2 인메모리 DB (PostgreSQL 없이 기동) +server: + port: 8080 + +spring: + application: + name: wing-gis-map + + datasource: + url: jdbc:h2:mem:wingis;DB_CLOSE_DELAY=-1;MODE=PostgreSQL + username: sa + password: + driver-class-name: org.h2.Driver + + h2: + console: + enabled: true + path: /h2-console + + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + properties: + hibernate: + format_sql: true + show-sql: true + open-in-view: false + defer-datasource-initialization: true + + sql: + init: + mode: always + data-locations: classpath:data-h2.sql + + data: + redis: + host: localhost + port: 6379 + + kafka: + bootstrap-servers: localhost:9094 + + jackson: + serialization: + write-dates-as-timestamps: false + default-property-inclusion: non_null + + autoconfigure: + exclude: + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration + +logging: + level: + kr.go.kcg.wingis: DEBUG + +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + +wingis: + vessel: + cache-ttl-seconds: 30 + map: + default-center-lat: 37.4563 + default-center-lon: 126.7052 + default-zoom: 10 + srid: 4326 diff --git a/services/wing-gis-map/src/main/resources/application.yml b/services/wing-gis-map/src/main/resources/application.yml new file mode 100644 index 0000000..0f347ea --- /dev/null +++ b/services/wing-gis-map/src/main/resources/application.yml @@ -0,0 +1,71 @@ +server: + port: 8080 + +spring: + application: + name: wing-gis-map + + datasource: + url: jdbc:postgresql://localhost:5432/wing_gis + username: wingis + password: wingis_dev_2026 + driver-class-name: org.postgresql.Driver + + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.spatial.dialect.postgis.PostgisPG10Dialect + format_sql: true + default_schema: public + show-sql: false + open-in-view: false + + data: + redis: + host: localhost + port: 6379 + + kafka: + bootstrap-servers: localhost:9094 + consumer: + group-id: wing-gis-map + auto-offset-reset: latest + key-deserializer: org.apache.kafka.common.serialization.StringDeserializer + value-deserializer: org.apache.kafka.common.serialization.StringDeserializer + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.apache.kafka.common.serialization.StringSerializer + + jackson: + serialization: + write-dates-as-timestamps: false + default-property-inclusion: non_null + +# Logging +logging: + level: + kr.go.kcg.wingis: DEBUG + org.hibernate.SQL: DEBUG + org.hibernate.type.descriptor.sql.BasicBinder: TRACE + +# OpenAPI / Swagger (INR-05) +springdoc: + api-docs: + path: /api-docs + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: method + +# WING-GIS Custom +wingis: + vessel: + cache-ttl-seconds: 30 + track-retention-days: 365 + map: + default-center-lat: 37.4563 + default-center-lon: 126.7052 + default-zoom: 10 + srid: 4326 diff --git a/services/wing-gis-map/src/main/resources/data-h2.sql b/services/wing-gis-map/src/main/resources/data-h2.sql new file mode 100644 index 0000000..c033133 --- /dev/null +++ b/services/wing-gis-map/src/main/resources/data-h2.sql @@ -0,0 +1,68 @@ +-- WING-GIS 개발용 샘플 데이터 + +-- 레이어 그룹 +INSERT INTO layer_group (name, name_en, icon, is_system, sort_order) VALUES +('사용자 레이어', 'User Layers', '📁', FALSE, 1), +('S-100 차세대 전자해도', 'S-100 ENC Layers', '🗺', TRUE, 2), +('물표 레이어', 'Vessel Layers', '🚢', TRUE, 3), +('해양안전 레이어', 'Maritime Safety', '🔴', TRUE, 4), +('연계 레이어', 'Integration Layers', '🔗', TRUE, 5); + +-- 경계 구역 +INSERT INTO boundary (name, name_en, boundary_type, organization) VALUES +('배타적 경제수역', 'EEZ', 'EEZ', '해양경찰청'), +('북방한계선', 'NLL', 'NLL', '해양경찰청'), +('인천해양경찰서 관할', 'Incheon KCG', 'JURISDICTION', '인천해양경찰서'); + +-- 관리자 +INSERT INTO app_user (username, name, rank, department, organization, role) VALUES +('admin', '김영수', '사무관', '정보통신과', '해양경찰청', 'ADMIN'); + +-- S-100 데이터셋 +INSERT INTO chart_dataset (cell_name, product, edition, update_num, scale, status, fc_version, pc_version, s100_version, feature_count, issued_date) VALUES +('KR4G3A40', 'S-101', 4, 12, 22000, 'CURRENT', '2.0.0', '2.0.0', '5.2.1', 2847, '2026-03-20'), +('KR4G3A41', 'S-101', 3, 8, 22000, 'CURRENT', '2.0.0', '2.0.0', '5.2.1', 1923, '2026-03-18'), +('KR5G4B20', 'S-101', 2, 15, 45000, 'CURRENT', '2.0.0', '2.0.0', '5.2.1', 4156, '2026-03-15'); + +-- AIS 물표 (7척) +INSERT INTO vessel (mmsi, imo, name, callsign, ship_type, flag, gt, dwt, loa, beam, draft, status, source, sog, cog, heading, nav_status, destination, owner) VALUES +('440234001', '9234567', '동아호', 'HLAA1', 'Bulk', 'KR', 42800, 68200, 225.0, 32.4, 10.3, '항해중', 'AIS', 12.4, 170, 168, '항해중', '포항항', '대한해운(주)'), +('440891002', '9345678', '한성호', 'HLAB2', 'Tanker', 'KR', 28500, 48200, 180.0, 32.2, 8.6, '항해중', 'AIS', 9.8, 220, 222, '항해중', '여수항', '한진해운(주)'), +('440312003', '9456789', '청해호', 'HLCA3', 'Cargo', 'KR', 18400, 28600, 158.0, 24.2, 7.1, '항해중', 'AIS', 11.2, 185, 183, '항해중', '부산항', '대한해운(주)'), +('440501004', '9567890', '서해301', 'HLDA4', 'Cargo', 'KR', 12000, 18500, 130.0, 20.5, 6.4, '항해중', 'AIS', 8.3, 155, 153, '항해중', '목포항', '서해해운(주)'), +('440702005', '9678901', '남아호', 'HLEA5', 'Bulk', 'KR', 55000, 82000, 250.0, 40.0, 11.2, '정박중', 'AIS', 0.0, 0, 0, '정박중', '인천항', '남아해운(주)'), +('440103006', '9789012', '태평호', 'HLFA6', 'Cargo', 'KR', 8500, 12000, 110.0, 18.0, 5.8, '항해중', 'AIS', 10.1, 200, 198, '항해중', '인천항', '태평해운(주)'); + +-- V-Pass 어선 (3척) +INSERT INTO vessel (mmsi, name, ship_type, flag, draft, status, source, sog, cog, heading, nav_status) VALUES +('441001101', '북성201', '어선', 'KR', 2.1, '조업중', 'V-Pass', 3.2, 90, 88, '조업중'), +('441002102', '전남어01', '어선', 'KR', 1.9, '조업중', 'V-Pass', 2.8, 45, 43, '조업중'), +('441003103', '충남3호', '어선', 'KR', 2.4, '입항중', 'V-Pass', 5.1, 310, 308, '입항중'); + +-- E-NAV (2척) +INSERT INTO vessel (mmsi, name, ship_type, flag, status, source, sog, cog) VALUES +('442001201', 'e-Nav 선박', 'Cargo', 'KR', '항해중', 'E-NAV', 8.5, 135), +('442001202', '인천1234', 'Cargo', 'KR', '항해중', 'E-NAV', 7.2, 210); + +-- VTS (2건) +INSERT INTO vessel (name, ship_type, status, source, sog, cog) VALUES +('TGT-VTS#021', 'VTS', '관제중', 'VTS', 8.2, 90), +('TGT-VTS#008', 'VTS', '관제중', 'VTS', 6.5, 180); + +-- VTS-레이더 (3건) +INSERT INTO vessel (name, ship_type, status, source, sog, cog) VALUES +('RADAR#047', '레이더', '위험', 'VTS-RADAR', 4.1, 270), +('RADAR#012', '레이더', '추적중', 'VTS-RADAR', 6.8, 150), +('RADAR#033', '레이더', '추적중', 'VTS-RADAR', 3.5, 320); + +-- 항공기AIS (2기) +INSERT INTO vessel (mmsi, name, ship_type, flag, status, source, sog, cog) VALUES +('447601001', 'KCG-601', '항공기', 'KR', '비행중', 'AIR-AIS', 120, 265), +('447202001', 'KCG-202', '항공기', 'KR', '비행중', 'AIR-AIS', 135, 180); + +-- VHF-DSC 조난 (1건) +INSERT INTO vessel (mmsi, imo, name, ship_type, flag, draft, status, source, sog, cog, nav_status) VALUES +('440123456', '9112233', '화성호', '화물선', 'KR', 6.8, 'SOS 경보', 'VHF-DSC', 0.2, 0, 'SOS 경보 발령'); + +-- 경보 (Alert 엔티티 추가 후 활성화) +-- INSERT INTO alert ... diff --git a/services/wing-gis-map/src/main/resources/schema-h2.sql b/services/wing-gis-map/src/main/resources/schema-h2.sql new file mode 100644 index 0000000..ad82961 --- /dev/null +++ b/services/wing-gis-map/src/main/resources/schema-h2.sql @@ -0,0 +1,162 @@ +-- H2 개발용 스키마 (PostGIS geometry → VARCHAR 대체) + +CREATE TABLE IF NOT EXISTS vessel ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + mmsi VARCHAR(20) UNIQUE, + imo VARCHAR(20), + name VARCHAR(100), + callsign VARCHAR(20), + ship_type VARCHAR(50), + flag VARCHAR(5), + gt DECIMAL, + dwt DECIMAL, + loa DECIMAL, + beam DECIMAL, + draft DECIMAL, + status VARCHAR(30), + source VARCHAR(20) DEFAULT 'AIS', + position VARCHAR(200), + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + nav_status VARCHAR(30), + destination VARCHAR(100), + eta TIMESTAMP, + owner VARCHAR(200), + operator VARCHAR(200), + last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS vessel_track ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + vessel_id BIGINT, + position VARCHAR(200), + sog DECIMAL, + cog DECIMAL, + heading DECIMAL, + source VARCHAR(20), + recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS layer_group ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100), + name_en VARCHAR(100), + parent_id INT, + sort_order INT DEFAULT 0, + icon VARCHAR(10), + is_system BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS layer ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + group_id INT, + name VARCHAR(200), + name_en VARCHAR(200), + layer_type VARCHAR(30) DEFAULT 'VECTOR', + s100_product VARCHAR(10), + geometry_type VARCHAR(20), + srid INT DEFAULT 4326, + style_sld CLOB, + min_scale INT, + max_scale INT, + is_visible BOOLEAN DEFAULT TRUE, + is_default_on BOOLEAN DEFAULT FALSE, + feature_count INT DEFAULT 0, + source_url VARCHAR(500), + file_path VARCHAR(500), + sort_order INT DEFAULT 0, + created_by BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS chart_dataset ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + cell_name VARCHAR(20), + product VARCHAR(10) DEFAULT 'S-101', + edition INT DEFAULT 1, + update_num INT DEFAULT 0, + scale INT, + status VARCHAR(20) DEFAULT 'CURRENT', + coverage VARCHAR(500), + fc_version VARCHAR(20), + pc_version VARCHAR(20), + s100_version VARCHAR(20) DEFAULT '5.2.1', + feature_count INT DEFAULT 0, + file_path VARCHAR(500), + issued_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS chart_feature ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + dataset_id BIGINT, + feature_type VARCHAR(50), + category VARCHAR(30), + feature_name_ko VARCHAR(200), + feature_name_en VARCHAR(200), + geometry VARCHAR(1000), + attributes CLOB DEFAULT '{}', + scamin INT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS boundary ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100), + name_en VARCHAR(100), + boundary_type VARCHAR(30), + geometry VARCHAR(2000), + organization VARCHAR(100), + parent_id INT, + attributes CLOB DEFAULT '{}', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS analysis_result ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + analysis_type VARCHAR(50), + sub_type VARCHAR(50), + name VARCHAR(200), + geometry VARCHAR(1000), + result_data CLOB DEFAULT '{}', + confidence DECIMAL, + status VARCHAR(20) DEFAULT 'ACTIVE', + created_by BIGINT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS app_user ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(50) UNIQUE, + password_hash VARCHAR(200), + name VARCHAR(50), + rank VARCHAR(30), + department VARCHAR(100), + organization VARCHAR(100), + role VARCHAR(30) DEFAULT 'VIEWER', + jurisdiction_id INT, + sso_id VARCHAR(100), + gpki_dn VARCHAR(500), + is_active BOOLEAN DEFAULT TRUE, + last_login TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS alert ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + alert_type VARCHAR(30), + severity VARCHAR(10) DEFAULT 'INFO', + title VARCHAR(200), + description CLOB, + vessel_id BIGINT, + position VARCHAR(200), + is_acknowledged BOOLEAN DEFAULT FALSE, + acknowledged_by BIGINT, + acknowledged_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +);