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 <noreply@anthropic.com>
This commit is contained in:
커밋
b9d924e81e
51
.claude/settings.json
Normal file
51
.claude/settings.json
Normal file
@ -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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
295
.claude/skills/init-project/SKILL.md
Normal file
295
.claude/skills/init-project/SKILL.md
Normal file
@ -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 <입력된 토큰>"
|
||||
```
|
||||
- 성공: `✅ <login> (<full_name>) 인증 확인` 출력
|
||||
- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
|
||||
|
||||
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/<project-hash>/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`
|
||||
165
.claude/skills/sync-team-workflow/SKILL.md
Normal file
165
.claude/skills/sync-team-workflow/SKILL.md
Normal file
@ -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": "<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은 인증 불필요)
|
||||
6
.claude/workflow-version.json
Normal file
6
.claude/workflow-version.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-31",
|
||||
"project_type": "react-ts",
|
||||
"custom_pre_commit": true
|
||||
}
|
||||
60
.githooks/commit-msg
Executable file
60
.githooks/commit-msg
Executable file
@ -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
|
||||
25
.githooks/post-checkout
Executable file
25
.githooks/post-checkout
Executable file
@ -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
|
||||
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@ -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/
|
||||
738
ARCHITECTURE.md
Normal file
738
ARCHITECTURE.md
Normal file
@ -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명 동시접속)
|
||||
- [ ] 보안 감사 로그
|
||||
- [ ] 운영 전환 + 교육
|
||||
24
frontend/wing-gis-web/.gitignore
vendored
Normal file
24
frontend/wing-gis-web/.gitignore
vendored
Normal file
@ -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?
|
||||
5
frontend/wing-gis-web/.npmrc
Normal file
5
frontend/wing-gis-web/.npmrc
Normal file
@ -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
|
||||
199
frontend/wing-gis-web/README.md
Normal file
199
frontend/wing-gis-web/README.md
Normal file
@ -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<mmsi, AisTarget>)
|
||||
│
|
||||
▼
|
||||
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 타일 서버 |
|
||||
23
frontend/wing-gis-web/eslint.config.js
Normal file
23
frontend/wing-gis-web/eslint.config.js
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/wing-gis-web/index.html
Normal file
13
frontend/wing-gis-web/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>wing-gis-web</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5001
frontend/wing-gis-web/package-lock.json
generated
Normal file
5001
frontend/wing-gis-web/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
43
frontend/wing-gis-web/package.json
Normal file
43
frontend/wing-gis-web/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
1
frontend/wing-gis-web/public/favicon.svg
Normal file
1
frontend/wing-gis-web/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | 크기: 9.3 KiB |
24
frontend/wing-gis-web/public/icons.svg
Normal file
24
frontend/wing-gis-web/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | 크기: 4.9 KiB |
85
frontend/wing-gis-web/src/App.css
Normal file
85
frontend/wing-gis-web/src/App.css
Normal file
@ -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;
|
||||
}
|
||||
34
frontend/wing-gis-web/src/App.tsx
Normal file
34
frontend/wing-gis-web/src/App.tsx
Normal file
@ -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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh', width: '100vw', overflow: 'hidden', fontFamily: "'Noto Sans KR',sans-serif", fontSize: 13, color: '#1a2e38' }}>
|
||||
<TitleBar />
|
||||
<SubToolbar />
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
<Sidebar />
|
||||
<MapViewML />
|
||||
<RightPanel />
|
||||
</div>
|
||||
<BottomBar />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
BIN
frontend/wing-gis-web/src/assets/hero.png
Normal file
BIN
frontend/wing-gis-web/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | 크기: 44 KiB |
1
frontend/wing-gis-web/src/assets/react.svg
Normal file
1
frontend/wing-gis-web/src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | 크기: 4.0 KiB |
1
frontend/wing-gis-web/src/assets/vite.svg
Normal file
1
frontend/wing-gis-web/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | 크기: 8.5 KiB |
52
frontend/wing-gis-web/src/components/layout/BottomBar.tsx
Normal file
52
frontend/wing-gis-web/src/components/layout/BottomBar.tsx
Normal file
@ -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 (
|
||||
<footer style={{
|
||||
height:22,flexShrink:0,background:'#1a3a4a',borderTop:'1px solid #006f87',
|
||||
display:'flex',alignItems:'center',padding:'0 10px',gap:14
|
||||
}}>
|
||||
{/* Status dots */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
<div style={{width:5,height:5,borderRadius:'50%',background:'#4caf50'}} />시스템 정상
|
||||
</div>
|
||||
<div style={{width:1,height:12,background:'rgba(255,255,255,.15)'}} />
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
<div style={{width:5,height:5,borderRadius:'50%',background:'#4caf50'}} />AIS 수신 정상
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
<div style={{width:5,height:5,borderRadius:'50%',background:'#4caf50'}} />V-Pass 정상
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
<div style={{width:5,height:5,borderRadius:'50%',background:'#e07020'}} />VHF-DSC 점검중
|
||||
</div>
|
||||
<div style={{width:1,height:12,background:'rgba(255,255,255,.15)'}} />
|
||||
|
||||
{/* Center */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
물표 갱신: <span>{time}</span>
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:4,fontSize:10,color:'rgba(255,255,255,.55)',fontFamily:'var(--mono)'}}>
|
||||
통합연계모듈 v2.1
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div style={{marginLeft:'auto',fontSize:10,color:'rgba(255,255,255,.3)'}}>
|
||||
{'㈜지씨 WING-GIS 시안 v0.3'}
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BottomBar;
|
||||
225
frontend/wing-gis-web/src/components/layout/RightPanel.tsx
Normal file
225
frontend/wing-gis-web/src/components/layout/RightPanel.tsx
Normal file
@ -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<PanelTab>('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 (
|
||||
<aside style={{
|
||||
width:220,flexShrink:0,background:'#fff',borderLeft:'1px solid #dde5ea',
|
||||
display:'flex',flexDirection:'column',overflow:'hidden'
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{background:'#1a3a4a',color:'#fff',padding:'6px 10px',fontSize:12,fontWeight:700,borderBottom:'2px solid #0093b2'}}>
|
||||
현황 정보
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{display:'flex',borderBottom:'1px solid #dde5ea'}}>
|
||||
{tabs.map(tab=>(
|
||||
<div key={tab.key}
|
||||
onClick={()=>setActiveTab(tab.key)}
|
||||
style={{
|
||||
flex:1,textAlign:'center',padding:'5px 0',fontSize:11,cursor:'pointer',transition:'.12s',
|
||||
color:activeTab===tab.key?'#0093b2':'#4a6070',
|
||||
borderBottom:activeTab===tab.key?'2px solid #0093b2':'2px solid transparent',
|
||||
fontWeight:activeTab===tab.key?500:400
|
||||
}}
|
||||
>{tab.label}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div style={{flex:1,overflowY:'auto'}}>
|
||||
|
||||
{/* ══ 물표현황 ══ */}
|
||||
{activeTab === 'stat' && <>
|
||||
{/* Stat grid */}
|
||||
<div style={{display:'flex',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{flex:1,padding:'7px 8px',borderRight:'1px solid #dde5ea',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>AIS</div>
|
||||
<div style={{fontSize:17,fontWeight:700,fontFamily:'var(--mono)',color:'#0093b2',lineHeight:1}}>{aisCount.toLocaleString()}</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>척</div>
|
||||
</div>
|
||||
<div style={{flex:1,padding:'7px 8px',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>V-Pass</div>
|
||||
<div style={{fontSize:17,fontWeight:700,fontFamily:'var(--mono)',color:'#2a9050',lineHeight:1}}>384</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>척</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{flex:1,padding:'7px 8px',borderRight:'1px solid #dde5ea',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>E-Nav</div>
|
||||
<div style={{fontSize:17,fontWeight:700,fontFamily:'var(--mono)',color:'#8833cc',lineHeight:1}}>76</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>척</div>
|
||||
</div>
|
||||
<div style={{flex:1,padding:'7px 8px',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>VTS</div>
|
||||
<div style={{fontSize:17,fontWeight:700,fontFamily:'var(--mono)',color:'#cc6600',lineHeight:1}}>142</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>척</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{flex:1,padding:'7px 8px',borderRight:'1px solid #dde5ea',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>VTS-레이더</div>
|
||||
<div style={{fontSize:15,fontWeight:700,fontFamily:'var(--mono)',color:'#cc3300',lineHeight:1}}>62</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>개</div>
|
||||
</div>
|
||||
<div style={{flex:1,padding:'7px 8px',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>항공기AIS</div>
|
||||
<div style={{fontSize:15,fontWeight:700,fontFamily:'var(--mono)',color:'#0099aa',lineHeight:1}}>8</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>기</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{display:'flex',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{flex:2,padding:'7px 8px',borderRight:'1px solid #dde5ea',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>VHF-DSC 조난</div>
|
||||
<div style={{fontSize:22,fontWeight:700,fontFamily:'var(--mono)',color:'#d63030',lineHeight:1}}>3</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>건 경보</div>
|
||||
</div>
|
||||
<div style={{flex:1,padding:'7px 8px',textAlign:'center'}}>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginBottom:2}}>전체</div>
|
||||
<div style={{fontSize:15,fontWeight:700,fontFamily:'var(--mono)',color:'#1a2e38',lineHeight:1}}>{(aisCount + DUMMY_TOTAL).toLocaleString()}</div>
|
||||
<div style={{fontSize:9,color:'#8a9eaa',marginTop:2}}>합계</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AIS chart */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5}}>시간대별 AIS 수신량</div>
|
||||
<div style={{display:'flex',alignItems:'flex-end',gap:3,height:42}}>
|
||||
{[55,40,68,58,84,74,92,100].map((h,i)=>(
|
||||
<div key={i} style={{flex:1,borderRadius:'2px 2px 0 0',background:'#0093b2',opacity:.75,minHeight:4,height:`${h}%`,cursor:'pointer',transition:'.12s'}} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{display:'flex',gap:3,marginTop:2}}>
|
||||
{['02','04','06','08','10','12','14','16'].map((l,i)=>(
|
||||
<div key={i} style={{flex:1,textAlign:'center',fontSize:9,color:'#8a9eaa',fontFamily:'var(--mono)'}}>{l}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ships breakdown */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5,display:'flex',alignItems:'center',gap:4}}>🚢 관할 내 선박</div>
|
||||
{shipBreakdown.map((r,i)=>(
|
||||
<div key={i} style={{display:'flex',justifyContent:'space-between',padding:'2px 0',fontSize:11,borderBottom:i<3?'1px solid #f0f0f0':'none'}}>
|
||||
<span style={{color:'#8a9eaa'}}>{r.k}</span>
|
||||
<span style={{color:'#1a2e38',fontFamily:'var(--mono)'}}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Module status */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5,display:'flex',alignItems:'center',gap:4}}>📡 연계모듈 상태</div>
|
||||
{[
|
||||
{k:'AIS 수신',v:'정상',color:'#2a9050'},
|
||||
{k:'V-Pass',v:'정상',color:'#2a9050'},
|
||||
{k:'VHF-DSC',v:'점검중',color:'#e07020'},
|
||||
{k:'갱신주기',v:'30s',color:'#1a2e38'},
|
||||
].map((r,i)=>(
|
||||
<div key={i} style={{display:'flex',justifyContent:'space-between',padding:'2px 0',fontSize:11,borderBottom:i<3?'1px solid #f0f0f0':'none'}}>
|
||||
<span style={{color:'#8a9eaa'}}>{r.k}</span>
|
||||
<span style={{color:r.color,fontFamily:'var(--mono)'}}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* ══ 경보 ══ */}
|
||||
{activeTab === 'alarm' && <>
|
||||
{[
|
||||
{dot:'#d63030',title:'홍성호 — SOS 경보',meta:'MMSI 440123456 · 37.2°N',time:'08:42'},
|
||||
{dot:'#e07020',title:'TGT#047 위험구역 진입',meta:'레이더 · EEZ 내',time:'09:11'},
|
||||
{dot:'#e07020',title:'충남3호 단말 고장신고',meta:'V-Pass · VPT20240312',time:'09:18'},
|
||||
{dot:'#2a9050',title:'3009함 현장 도착 확인',meta:'구조함 출동 완료',time:'09:25'},
|
||||
].map((a,i)=>(
|
||||
<div key={i} style={{display:'flex',gap:7,padding:'6px 10px',borderBottom:'1px solid #dde5ea',alignItems:'flex-start',cursor:'pointer',transition:'.1s'}}>
|
||||
<div style={{width:7,height:7,borderRadius:'50%',background:a.dot,flexShrink:0,marginTop:3}} />
|
||||
<div style={{flex:1}}>
|
||||
<div style={{fontSize:11,color:'#1a2e38',lineHeight:1.35}}>{a.title}</div>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',marginTop:1,fontFamily:'var(--mono)'}}>{a.meta}</div>
|
||||
</div>
|
||||
<div style={{fontSize:10,color:'#8a9eaa',fontFamily:'var(--mono)',whiteSpace:'nowrap'}}>{a.time}</div>
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
|
||||
{/* ══ 해양정보 ══ */}
|
||||
{activeTab === 'marine' && <>
|
||||
{/* Weather */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5,display:'flex',alignItems:'center',gap:4}}>🌊 현재 해상 기상 (인천 관할)</div>
|
||||
{[
|
||||
{k:'풍속',v:'12.4 m/s NW'},{k:'파고',v:'1.8 m'},{k:'시정',v:'8.2 km'},{k:'기온',v:'6.4 °C'},{k:'수온',v:'10.1 °C'}
|
||||
].map((r,i)=>(
|
||||
<div key={i} style={{display:'flex',justifyContent:'space-between',padding:'2px 0',fontSize:11,borderBottom:i<4?'1px solid #f0f0f0':'none'}}>
|
||||
<span style={{color:'#8a9eaa'}}>{r.k}</span><span style={{color:'#1a2e38',fontFamily:'var(--mono)'}}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tide */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5,display:'flex',alignItems:'center',gap:4}}>🌊 조류 · 조석 (인천항)</div>
|
||||
{[
|
||||
{k:'조류 방향',v:'NNE 0.8 kn'},{k:'현재 조위',v:'+3.2 m'},{k:'만조 예측',v:'12:48 (+8.4m)'},{k:'간조 예측',v:'19:22 (+0.3m)'}
|
||||
].map((r,i)=>(
|
||||
<div key={i} style={{display:'flex',justifyContent:'space-between',padding:'2px 0',fontSize:11,borderBottom:i<3?'1px solid #f0f0f0':'none'}}>
|
||||
<span style={{color:'#8a9eaa'}}>{r.k}</span><span style={{color:'#1a2e38',fontFamily:'var(--mono)'}}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Jurisdiction */}
|
||||
<div style={{padding:'7px 10px',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{fontSize:11,fontWeight:700,color:'#4a6070',marginBottom:5,display:'flex',alignItems:'center',gap:4}}>⚓ 관할 해역 현황</div>
|
||||
{[
|
||||
{k:'관할 면적',v:'38,420 km²'},{k:'순찰 함정',v:'12척 출동중'},{k:'수색구조',v:'1건 진행중'},{k:'해양오염',v:'이상없음'}
|
||||
].map((r,i)=>(
|
||||
<div key={i} style={{display:'flex',justifyContent:'space-between',padding:'2px 0',fontSize:11,borderBottom:i<3?'1px solid #f0f0f0':'none'}}>
|
||||
<span style={{color:'#8a9eaa'}}>{r.k}</span><span style={{color:'#1a2e38',fontFamily:'var(--mono)'}}>{r.v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default RightPanel;
|
||||
371
frontend/wing-gis-web/src/components/layout/Sidebar.tsx
Normal file
371
frontend/wing-gis-web/src/components/layout/Sidebar.tsx
Normal file
@ -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}) => (
|
||||
<div onClick={onClick} style={{
|
||||
width:13,height:13,borderRadius:2,flexShrink:0,
|
||||
border:on?'1.5px solid #0093b2':'1.5px solid #bbb',
|
||||
background:on?'#0093b2':'transparent',
|
||||
display:'flex',alignItems:'center',justifyContent:'center',cursor:'pointer'
|
||||
}}>
|
||||
{on && <div style={{width:7,height:4,borderLeft:'1.5px solid #fff',borderBottom:'1.5px solid #fff',transform:'rotate(-45deg) translateY(-1px)'}} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
/* ── S-100 number badge ── */
|
||||
const S100Badge: React.FC<{num:string;bg:string}> = ({num,bg}) => (
|
||||
<span style={{
|
||||
display:'inline-flex',alignItems:'center',justifyContent:'center',width:26,height:16,
|
||||
borderRadius:3,fontSize:9,fontWeight:700,color:'#fff',fontFamily:'var(--mono)',
|
||||
flexShrink:0,letterSpacing:'-.2px',background:bg
|
||||
}}>{num}</span>
|
||||
);
|
||||
|
||||
/* ── Tooltip badge ── */
|
||||
const TipBadge: React.FC<{bg:string;color:string;title:string}> = ({bg,color,title:tipText}) => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<span
|
||||
onMouseEnter={()=>setShow(true)} onMouseLeave={()=>setShow(false)}
|
||||
style={{fontSize:9,padding:'1px 5px',borderRadius:9,background:bg,color,fontWeight:700,cursor:'help',position:'relative'}}
|
||||
>
|
||||
?
|
||||
{show && (
|
||||
<div style={{
|
||||
position:'fixed',background:'#1a3a4a',color:'#fff',fontSize:11,padding:'7px 10px',borderRadius:4,
|
||||
maxWidth:220,lineHeight:1.55,zIndex:9999,boxShadow:'0 3px 10px rgba(0,0,0,.3)',
|
||||
borderLeft:'3px solid #0093b2',pointerEvents:'none',fontFamily:'var(--sans)',fontWeight:400,
|
||||
top:0,left:0,transform:'translate(14px,-8px)'
|
||||
}}>{tipText}</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar: React.FC = () => {
|
||||
const { searchQuery, setSearchQuery } = useStore();
|
||||
const [activeTab, setActiveTab] = useState<SidebarTab>('layers');
|
||||
const [expanded, setExpanded] = useState<Record<string,boolean>>({
|
||||
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 (
|
||||
<aside style={{
|
||||
width:228,flexShrink:0,background:'#f7f9fa',borderRight:'1px solid #d4dde2',
|
||||
display:'flex',flexDirection:'column',overflow:'hidden'
|
||||
}}>
|
||||
{/* Search */}
|
||||
<div style={{background:'#0093b2',padding:'7px 9px 6px'}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:5,background:'#fff',borderRadius:3,padding:'3px 8px',height:27}}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위치 / MMSI / 선박명 검색"
|
||||
value={searchQuery}
|
||||
onChange={e=>setSearchQuery(e.target.value)}
|
||||
style={{flex:1,border:'none',outline:'none',fontSize:12,fontFamily:'var(--sans)',color:'#1a2e38',background:'transparent'}}
|
||||
/>
|
||||
<button style={{background:'none',border:'none',cursor:'pointer',color:'#0093b2',fontSize:13}}>🔍</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div style={{display:'flex',background:'#006f87'}}>
|
||||
{tabs.map(tab=>(
|
||||
<div key={tab.key}
|
||||
onClick={()=>setActiveTab(tab.key)}
|
||||
style={{
|
||||
flex:1,textAlign:'center',padding:'5px 0',fontSize:11,cursor:'pointer',transition:'.12s',
|
||||
color:activeTab===tab.key?'#fff':'rgba(255,255,255,.6)',
|
||||
background:activeTab===tab.key?'rgba(0,0,0,.15)':'transparent',
|
||||
fontWeight:activeTab===tab.key?500:400
|
||||
}}
|
||||
>{tab.label}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Scroll content */}
|
||||
<div style={{flex:1,overflowY:'auto'}}>
|
||||
|
||||
{/* ══ LAYERS TAB ══ */}
|
||||
{activeTab === 'layers' && <>
|
||||
|
||||
{/* ── 사용자 레이어 ── */}
|
||||
<div style={{borderBottom:'1px solid #dde5ea'}}>
|
||||
<div onClick={()=>toggle('user')} style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',cursor:'pointer',background:'#fff',fontSize:11,fontWeight:700,color:'#4a6070',letterSpacing:'.3px',userSelect:'none',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span>사용자 레이어</span>
|
||||
<span style={{marginLeft:'auto',fontSize:10,fontFamily:'var(--mono)',color:'#8a9eaa',fontWeight:400}}>2</span>
|
||||
<span style={{color:'#8a9eaa',fontSize:9,transition:'.2s',transform:expanded.user?'rotate(90deg)':'rotate(0deg)'}}>▶</span>
|
||||
</div>
|
||||
{expanded.user && <>
|
||||
<div style={{...liStyle(true)}}>
|
||||
<Checkbox on={true} /><span style={{fontSize:12}}>📍</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>구조사고 발생지점</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#fde',color:'#d63030',fontFamily:'var(--mono)'}}>12</span>
|
||||
</div>
|
||||
<div style={{...liStyle()}}>
|
||||
<Checkbox on={true} /><span style={{fontSize:12}}>🔴</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>불심선박 위치</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#e6f7fa',color:'#0093b2',fontFamily:'var(--mono)'}}>47</span>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── S-100 전자해도 레이어 ── */}
|
||||
<div style={{borderBottom:'1px solid #dde5ea'}}>
|
||||
<div onClick={()=>toggle('s100')} style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',cursor:'pointer',background:'#fff',fontSize:11,fontWeight:700,color:'#4a6070',letterSpacing:'.3px',userSelect:'none',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span>S-100 전자해도 레이어</span>
|
||||
<span style={{color:'#8a9eaa',fontSize:9,marginLeft:'auto',transition:'.2s',transform:expanded.s100?'rotate(90deg)':'rotate(0deg)'}}>▶</span>
|
||||
</div>
|
||||
{expanded.s100 && <>
|
||||
{/* S-101 */}
|
||||
<div style={{...liStyle(true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={true} /><S100Badge num="101" bg="#005fa3" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-101 전자해도 (ENC)</span>
|
||||
<TipBadge bg="#e3f0fb" color="#005fa3" title="S-57 ENC PS를 대체하는 차세대 전자해도. 콘텐츠·구조·데이터 인코딩·메타데이터 및 ECDIS 묘사 요구사항 포함." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>📏</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>항로 · 수심 · 항로표지</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>⚓</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>방파제 · 항만시설</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🏝</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>도서 · 암초 · 위험물</span></div>
|
||||
|
||||
{/* S-102 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="102" bg="#0077a0" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-102 수심처리</span>
|
||||
<TipBadge bg="#e3f5fb" color="#0077a0" title="BAG(Bathymetric Attributed Grid) 기반 수심 측정 표면. ONSWG 작업 기반. 격자형 수심 데이터 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌊</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>등심선 (5·10·20·50·100m)</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📊</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>BAG 수심 격자 (색상표출)</span></div>
|
||||
|
||||
{/* S-104 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="104" bg="#0099aa" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-104 조위처리</span>
|
||||
<TipBadge bg="#e3fafc" color="#0099aa" title="ECDIS 및 동적 조수 애플리케이션용 조수·수위 데이터 캡슐화. 실시간 수위 변화 및 예측 격자 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📈</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>실시간 조위 레이어</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌀</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>조석 예측 격자</span></div>
|
||||
|
||||
{/* S-111 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="111" bg="#007755" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-111 해황처리</span>
|
||||
<TipBadge bg="#e3f7f0" color="#007755" title="표층 해류 정보(유향·유속) 제공. ECDIS에서 해류 벡터 및 격자 형태로 표출. 항로 계획에 활용." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>➡</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>해류 벡터 레이어</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌡</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>표층 수온 격자</span></div>
|
||||
|
||||
{/* S-122 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="122" bg="#336699" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-122 해양보호구역처리</span>
|
||||
<TipBadge bg="#edf3fa" color="#336699" title="Marine Protected Areas. 해양 생태 보호구역·보전구역 경계 및 속성 정보를 ECDIS 표준 형식으로 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🔵</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>보호구역 경계·등급</span></div>
|
||||
|
||||
{/* S-123 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="123" bg="#996633" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-123 해양전파처리</span>
|
||||
<TipBadge bg="#fdf4ec" color="#996633" title="VHF·MF·HF 무선통신 커버리지 및 GMDSS 무선범위 구역 표출. 조난 통신 가용 구역 확인에 활용." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📡</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>무선통신 커버리지 구역</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📻</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>GMDSS 무선범위</span></div>
|
||||
|
||||
{/* S-124 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="124" bg="#2d6e4e" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-124 해항해경보구역처리</span>
|
||||
<TipBadge bg="#e8f5ef" color="#2d6e4e" title="Navigational Warnings. 항행경보(NAVTEX·NavArea) 발령 구역 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>⚠</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>항행경보 발령 구역</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🚫</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>임시 위험·훈련구역</span></div>
|
||||
|
||||
{/* S-127 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="127" bg="#884466" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-127 해양교통관리처리</span>
|
||||
<TipBadge bg="#faedf4" color="#884466" title="Traffic Separation Scheme(TSS) 및 VTS 관할구역·항로 분리대 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>↔</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>TSS 항로분리대</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🚢</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>VTS 관할·보고구역</span></div>
|
||||
|
||||
{/* S-412 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="412" bg="#8b4513" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-412 해양기상위험구역처리</span>
|
||||
<TipBadge bg="#fdf0e8" color="#8b4513" title="Weather Overlay. 태풍·폭풍·짙은 안개 등 해양기상 위험 예보 구역을 격자 및 폴리곤으로 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌪</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>기상위험구역 격자</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌩</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>태풍·폭풍 예보구역</span></div>
|
||||
|
||||
{/* S-414 */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><S100Badge num="414" bg="#4a6080" />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>S-414 해양기상관측처리</span>
|
||||
<TipBadge bg="#edf2f8" color="#4a6080" title="Marine Weather Observations. 해상 기상 관측소·부이·기상선 등의 실측 기상 데이터 표출." />
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌬</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>해상 기상 관측 포인트</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📍</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>기상 부이 · 관측선</span></div>
|
||||
|
||||
{/* Boundary layers separator */}
|
||||
<div style={{borderTop:'1px solid #dde5ea',margin:'6px 10px 4px'}} />
|
||||
<div style={{...liStyle()}}><Checkbox on={true} /><span style={{fontSize:12}}>📐</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>EEZ · NLL 경계</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={true} /><span style={{fontSize:12}}>🏛</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>관할구역 (인천)</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={false} /><span style={{fontSize:12}}>🚢</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>순찰항로</span></div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── 물표 레이어 ── */}
|
||||
<div style={{borderBottom:'1px solid #dde5ea'}}>
|
||||
<div onClick={()=>toggle('signal')} style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',cursor:'pointer',background:'#fff',fontSize:11,fontWeight:700,color:'#4a6070',letterSpacing:'.3px',userSelect:'none',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span>물표 레이어</span>
|
||||
<span style={{color:'#8a9eaa',fontSize:9,marginLeft:'auto',transition:'.2s',transform:expanded.signal?'rotate(90deg)':'rotate(0deg)'}}>▶</span>
|
||||
</div>
|
||||
{expanded.signal && <>
|
||||
{[
|
||||
{icon:'▲',color:'#0055cc',name:'AIS',count:'1,247',tagBg:'#e6eeff',tagColor:'#0055cc'},
|
||||
{icon:'▲',color:'#1a8a3a',name:'V-Pass',count:'384',tagBg:'#dfd',tagColor:'#2a9050'},
|
||||
{icon:'▲',color:'#8833cc',name:'E-Navigation',count:'76',tagBg:'#f3e8ff',tagColor:'#8833cc'},
|
||||
{icon:'■',color:'#cc6600',name:'VTS',count:'142',tagBg:'#fff3e6',tagColor:'#cc6600',size:10},
|
||||
{icon:'◆',color:'#cc3300',name:'VTS-레이더타겟',count:'62',tagBg:'#ffe8e6',tagColor:'#cc3300',size:10},
|
||||
{icon:'✈',color:'#0099aa',name:'항공기AIS',count:'8',tagBg:'#e6f8fa',tagColor:'#0099aa'},
|
||||
{icon:'⚠',color:'#d63030',name:'VHF-DSC 조난',count:'3',tagBg:'#fde',tagColor:'#d63030'},
|
||||
].map((s,i)=>(
|
||||
<div key={i} style={{...liStyle()}}>
|
||||
<Checkbox on={true} />
|
||||
<span style={{color:s.color,fontSize:s.size||11,flexShrink:0}}>{s.icon}</span>
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>{s.name}</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:s.tagBg,color:s.tagColor,fontFamily:'var(--mono)'}}>{s.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── 해양안전 레이어 ── */}
|
||||
<div style={{borderBottom:'1px solid #dde5ea'}}>
|
||||
<div onClick={()=>toggle('safety')} style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',cursor:'pointer',background:'#fff',fontSize:11,fontWeight:700,color:'#4a6070',letterSpacing:'.3px',userSelect:'none',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span>해양안전 레이어</span>
|
||||
<span style={{color:'#8a9eaa',fontSize:9,marginLeft:'auto',transition:'.2s',transform:expanded.safety?'rotate(90deg)':'rotate(0deg)'}}>▶</span>
|
||||
</div>
|
||||
{expanded.safety && <>
|
||||
<div style={{...liStyle()}}><Checkbox on={true} /><span style={{fontSize:12}}>🔴</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>수색구조 구역 (SAR)</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={true} /><span style={{fontSize:12}}>⛔</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>출입금지 구역</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={false} /><span style={{fontSize:12}}>⚠</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>위험구역 · 금지구역</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={false} /><span style={{fontSize:12}}>🌊</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>조류 정보</span></div>
|
||||
<div style={{...liStyle()}}><Checkbox on={false} /><span style={{fontSize:12}}>🌬</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>해상기상 격자</span></div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
{/* ── 연계 레이어 ── */}
|
||||
<div style={{borderBottom:'1px solid #dde5ea'}}>
|
||||
<div onClick={()=>toggle('linked')} style={{display:'flex',alignItems:'center',gap:5,padding:'5px 10px',cursor:'pointer',background:'#fff',fontSize:11,fontWeight:700,color:'#4a6070',letterSpacing:'.3px',userSelect:'none',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span>연계 레이어</span>
|
||||
<span style={{marginLeft:'auto',background:'#e6f7fa',color:'#006f87',borderRadius:8,padding:'0 5px',fontSize:9,fontFamily:'var(--mono)',fontWeight:400}}>SFR-10</span>
|
||||
<span style={{color:'#8a9eaa',fontSize:9,transition:'.2s',transform:expanded.linked?'rotate(90deg)':'rotate(0deg)'}}>▶</span>
|
||||
</div>
|
||||
{expanded.linked && <>
|
||||
{/* V-Pass SOS */}
|
||||
<div style={{...liStyle(false,false),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={true} /><span style={{color:'#d63030',fontSize:13}}>⚠</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>V-Pass SOS 경보</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#fde',color:'#d63030',fontFamily:'var(--mono)'}}>3</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>🆘</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>SOS · 위험 · 소실 경보</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>🚢</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>출입항 · 위험구역 이력</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🔧</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>단말기 고장신고 위치</span></div>
|
||||
|
||||
{/* LRIT */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><span style={{color:'#0077cc',fontSize:12}}>📡</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>LRIT 위성추적</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#e6eeff',color:'#0055cc',fontFamily:'var(--mono)'}}>28</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🛰</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>국내 선박 위성 AIS 위치</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🌐</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>타국 입항 · 연안 통항</span></div>
|
||||
|
||||
{/* Aircraft */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={true} /><span style={{color:'#0099aa',fontSize:12}}>✈</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>항공기 비행이력</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#e6f8fa',color:'#0099aa',fontFamily:'var(--mono)'}}>8</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>✈</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>항공기 실시간 위치·항적</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>📋</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>비행 이력 조회</span></div>
|
||||
|
||||
{/* Big data */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={false} /><span style={{color:'#884466',fontSize:12}}>📊</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>빅데이터 통항량</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🔥</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>항적 · 통항량 분석 표출</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🗺</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>관할 특성 주제도</span></div>
|
||||
|
||||
{/* Reports */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={true} /><span style={{color:'#cc6600',fontSize:12}}>📣</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>신고처리</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#fff3e6',color:'#cc6600',fontFamily:'var(--mono)'}}>7</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>📍</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>사고 · 신고 발생 위치</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={false} /><span style={{fontSize:12}}>🔗</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>API 표준 연계 데이터</span></div>
|
||||
|
||||
{/* Distress */}
|
||||
<div style={{...liStyle(false,false,true),background:'rgba(0,0,0,.018)'}}>
|
||||
<Checkbox on={true} /><span style={{color:'#d63030',fontSize:12}}>🆘</span><span style={{fontSize:12,color:'#1a2e38',flex:1}}>국제조난 위치</span>
|
||||
<span style={{fontSize:9,padding:'1px 4px',borderRadius:9,background:'#fde',color:'#d63030',fontFamily:'var(--mono)'}}>3</span>
|
||||
</div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>📻</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>조난 통신 발생 위치 표출</span></div>
|
||||
<div style={{...liStyle(false,true)}}><Checkbox on={true} /><span style={{fontSize:12}}>🎯</span><span style={{fontSize:11,color:'#4a6070',flex:1}}>조난 관련 물표·대상 위치</span></div>
|
||||
</>}
|
||||
</div>
|
||||
</>}
|
||||
|
||||
{/* ══ POI TAB ══ */}
|
||||
{activeTab === 'poi' && <>
|
||||
<div style={{padding:'6px 10px',fontSize:11,color:'#8a9eaa',borderBottom:'1px solid #dde5ea'}}>해양시설 · 관심지점</div>
|
||||
{[
|
||||
{color:'#d63030',name:'홍성호 — SOS 경보',dist:'2.3km'},
|
||||
{color:'#cc3300',name:'레이더 TGT#047',dist:'8.7km'},
|
||||
{color:'#0093b2',name:'인천 해경 전용부두',dist:'5.8km'},
|
||||
{color:'#1a8a3a',name:'덕적도 파출소',dist:'34.5km'},
|
||||
{color:'#cc4400',name:'영흥도 등대 (Fl.4s)',dist:'41.2km'},
|
||||
{color:'#0066cc',name:'인천항 VTS 센터',dist:'12.1km'},
|
||||
{color:'#8833cc',name:'인천 SAR 구조본부',dist:'9.4km'},
|
||||
].map((p,i)=>(
|
||||
<div key={i} style={{display:'flex',alignItems:'center',gap:7,padding:'5px 10px',cursor:'pointer',borderBottom:'1px solid #dde5ea'}}>
|
||||
<div style={{width:9,height:9,borderRadius:'50%',background:p.color,flexShrink:0}} />
|
||||
<span style={{fontSize:12,color:'#1a2e38',flex:1}}>{p.name}</span>
|
||||
<span style={{fontSize:10,color:'#8a9eaa',fontFamily:'var(--mono)'}}>{p.dist}</span>
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
|
||||
{/* ══ FAV TAB ══ */}
|
||||
{activeTab === 'fav' && <>
|
||||
<div style={{padding:'6px 10px',fontSize:11,color:'#8a9eaa',borderBottom:'1px solid #dde5ea'}}>즐겨찾기 목록</div>
|
||||
{['인천 관할해역 기본뷰','EEZ 전경 뷰','덕적도 주변 확대','서해 EEZ 어선 현황'].map((f,i)=>(
|
||||
<div key={i} style={{display:'flex',alignItems:'center',gap:7,padding:'5px 10px',cursor:'pointer',borderBottom:'1px solid #dde5ea'}}>
|
||||
<span style={{fontSize:12}}>⭐</span>
|
||||
<span style={{fontSize:12,color:'#1a2e38'}}>{f}</span>
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
111
frontend/wing-gis-web/src/components/layout/SubToolbar.tsx
Normal file
111
frontend/wing-gis-web/src/components/layout/SubToolbar.tsx
Normal file
@ -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<string, (ToolButton | 'sep')[]> = {
|
||||
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<string>('move');
|
||||
|
||||
const tools = toolSets[activeNav] || toolSets.map;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background:'#fff',borderBottom:'1px solid #dde5ea',height:36,flexShrink:0,
|
||||
display:'flex',alignItems:'center',padding:'0 6px',gap:1
|
||||
}}>
|
||||
{tools.map((tool, i) => {
|
||||
if (tool === 'sep') {
|
||||
return <div key={`sep-${i}`} style={{width:1,height:20,background:'#dde5ea',margin:'0 3px',flexShrink:0}} />;
|
||||
}
|
||||
const isActive = activeTool === tool.id;
|
||||
return (
|
||||
<button
|
||||
key={tool.id}
|
||||
onClick={() => setActiveTool(isActive ? '' : tool.id)}
|
||||
style={{
|
||||
display:'flex',alignItems:'center',gap:4,padding:'3px 9px',borderRadius:3,
|
||||
border:isActive?'1px solid #b3e4ee':'1px solid transparent',
|
||||
background:isActive?'#e6f7fa':'none',fontSize:11,
|
||||
color:isActive?'#0093b2':'#4a6070',cursor:'pointer',
|
||||
fontFamily:"'Noto Sans KR',sans-serif",transition:'.12s',whiteSpace:'nowrap'
|
||||
}}
|
||||
>
|
||||
<span style={{fontSize:13}}>{tool.icon}</span>
|
||||
{tool.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubToolbar;
|
||||
245
frontend/wing-gis-web/src/components/layout/TitleBar.tsx
Normal file
245
frontend/wing-gis-web/src/components/layout/TitleBar.tsx
Normal file
@ -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<NavKey, { label: string; icon: string; items: DropdownItem[]; wide?: boolean; cols?: boolean; right?: boolean }> = {
|
||||
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}) => (
|
||||
<div style={{
|
||||
position:'absolute',top:34,left:right?'auto':0,right:right?0:'auto',
|
||||
background:'#fff',border:'1px solid #dde5ea',borderTop:'2px solid #0093b2',
|
||||
boxShadow:'0 2px 8px rgba(0,0,0,.12)',zIndex:100,
|
||||
minWidth:wide?220:160,borderRadius:'0 0 4px 4px',display:'block'
|
||||
}}>
|
||||
{items.map((item,i)=>{
|
||||
if (item.sep) return <div key={i} style={{height:1,background:'#dde5ea',margin:'3px 0'}} />;
|
||||
if (item.section) return <div key={i} style={{padding:'3px 14px 3px',fontSize:10,fontWeight:700,color:'#8a9eaa',letterSpacing:'.5px',marginTop:3}}>{item.section}</div>;
|
||||
return (
|
||||
<div key={i} style={{
|
||||
display:'flex',alignItems:'center',gap:7,padding:'6px 14px',fontSize:12,
|
||||
color:item.active?'#0093b2':'#1a2e38',cursor:'pointer',whiteSpace:'nowrap',
|
||||
borderBottom:'1px solid #dde5ea',fontWeight:item.active?500:400
|
||||
}}>
|
||||
<span style={{fontSize:13,width:18,textAlign:'center'}}>{item.icon}</span>{item.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const AnalysisDropdown: React.FC = () => (
|
||||
<div style={{
|
||||
position:'absolute',top:34,left:0,
|
||||
background:'#fff',border:'1px solid #dde5ea',borderTop:'2px solid #0093b2',
|
||||
boxShadow:'0 2px 8px rgba(0,0,0,.12)',zIndex:100,
|
||||
minWidth:320,borderRadius:'0 0 4px 4px',
|
||||
display:'grid',gridTemplateColumns:'1fr 1fr'
|
||||
}}>
|
||||
<div>
|
||||
{analysisCol1.map((item,i)=>{
|
||||
if (item.sep) return <div key={i} style={{height:1,background:'#dde5ea',margin:'3px 0'}} />;
|
||||
if (item.section) return <div key={i} style={{padding:'3px 14px 3px',fontSize:10,fontWeight:700,color:'#8a9eaa',letterSpacing:'.5px',marginTop:3}}>{item.section}</div>;
|
||||
return <div key={i} style={{display:'flex',alignItems:'center',gap:7,padding:'6px 14px',fontSize:12,color:'#1a2e38',cursor:'pointer',whiteSpace:'nowrap',borderBottom:'1px solid #dde5ea'}}><span style={{fontSize:13,width:18,textAlign:'center'}}>{item.icon}</span>{item.label}</div>;
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
{analysisCol2.map((item,i)=>{
|
||||
if (item.sep) return <div key={i} style={{height:1,background:'#dde5ea',margin:'3px 0'}} />;
|
||||
if (item.section) return <div key={i} style={{padding:'3px 14px 3px',fontSize:10,fontWeight:700,color:'#8a9eaa',letterSpacing:'.5px',marginTop:3}}>{item.section}</div>;
|
||||
return <div key={i} style={{display:'flex',alignItems:'center',gap:7,padding:'6px 14px',fontSize:12,color:'#1a2e38',cursor:'pointer',whiteSpace:'nowrap',borderBottom:'1px solid #dde5ea'}}><span style={{fontSize:13,width:18,textAlign:'center'}}>{item.icon}</span>{item.label}</div>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const TitleBar: React.FC = () => {
|
||||
const { activeNav, setActiveNav } = useStore();
|
||||
const [clock, setClock] = useState('');
|
||||
const [hoveredNav, setHoveredNav] = useState<NavKey | null>(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 (
|
||||
<header style={{
|
||||
background:'#0e2535',height:34,flexShrink:0,display:'flex',alignItems:'center',
|
||||
padding:'0 12px',borderBottom:'2px solid #0093b2'
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,paddingRight:16,borderRight:'1px solid rgba(255,255,255,.15)',marginRight:14}}>
|
||||
<div style={{background:'#0093b2',color:'#fff',fontWeight:700,fontSize:11,padding:'2px 7px',borderRadius:3,fontFamily:'var(--mono)',letterSpacing:'.5px'}}>WING-GIS</div>
|
||||
<div style={{lineHeight:1.2}}>
|
||||
<div style={{color:'#fff',fontSize:13,fontWeight:700,lineHeight:1}}>WING-GIS</div>
|
||||
<div style={{color:'rgba(255,255,255,.45)',fontSize:10,marginTop:1,lineHeight:1}}>해양경찰청 통합 GIS 위치정보시스템</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav style={{display:'flex',alignItems:'stretch',height:34}}>
|
||||
{navKeys.map(key => {
|
||||
const menu = navMenus[key];
|
||||
const isActive = activeNav === key;
|
||||
const isHovered = hoveredNav === key;
|
||||
return (
|
||||
<div key={key}
|
||||
style={{display:'flex',alignItems:'stretch',position:'relative'}}
|
||||
onMouseEnter={() => setHoveredNav(key)}
|
||||
onMouseLeave={() => setHoveredNav(null)}
|
||||
>
|
||||
<div
|
||||
onClick={() => setActiveNav(key)}
|
||||
style={{
|
||||
display:'flex',alignItems:'center',justifyContent:'center',gap:5,
|
||||
padding:'0 13px',height:34,lineHeight:1,
|
||||
color:isActive?'#fff':'rgba(255,255,255,.6)',fontSize:12,cursor:'pointer',
|
||||
position:'relative',borderRight:'1px solid rgba(255,255,255,.07)',
|
||||
transition:'.15s',whiteSpace:'nowrap',
|
||||
background:isActive?'rgba(0,147,178,.3)':'transparent',
|
||||
}}
|
||||
>
|
||||
{menu.icon} {menu.label}
|
||||
{isActive && <div style={{position:'absolute',bottom:0,left:0,right:0,height:2,background:'#0093b2'}} />}
|
||||
</div>
|
||||
{isHovered && key === 'analysis' && <AnalysisDropdown />}
|
||||
{isHovered && key !== 'analysis' && (
|
||||
<DropdownMenu items={menu.items} wide={menu.wide} right={menu.right} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Right section */}
|
||||
<div style={{marginLeft:'auto',display:'flex',alignItems:'center',gap:12}}>
|
||||
{/* Bell */}
|
||||
<div style={{position:'relative',cursor:'pointer',fontSize:14,color:'rgba(255,255,255,.65)',width:26,height:26,display:'flex',alignItems:'center',justifyContent:'center',borderRadius:'50%',background:'rgba(255,255,255,.08)'}}>
|
||||
🔔
|
||||
<div style={{position:'absolute',top:-1,right:-1,width:13,height:13,background:'#d63030',borderRadius:'50%',border:'1.5px solid #0e2535',fontSize:8,color:'#fff',display:'flex',alignItems:'center',justifyContent:'center',fontFamily:'var(--mono)'}}>3</div>
|
||||
</div>
|
||||
{/* User */}
|
||||
<div style={{display:'flex',alignItems:'center',gap:5,fontSize:11,color:'rgba(255,255,255,.65)'}}>
|
||||
<div style={{width:6,height:6,borderRadius:'50%',background:'#4caf50'}} />
|
||||
{'인천지방청 \u00A0|\u00A0 관제관'}
|
||||
</div>
|
||||
{/* Clock */}
|
||||
<div style={{fontFamily:'var(--mono)',fontSize:12,color:'rgba(255,255,255,.8)'}}>{clock}</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default TitleBar;
|
||||
18
frontend/wing-gis-web/src/components/map/BaseMapSelector.tsx
Normal file
18
frontend/wing-gis-web/src/components/map/BaseMapSelector.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
const BASE_MAPS = ['기본지도', '속성지도', '위성지도', '야간지도', '하이브리드'];
|
||||
|
||||
interface BaseMapSelectorProps {
|
||||
activeIndex?: number;
|
||||
}
|
||||
|
||||
const BaseMapSelector: React.FC<BaseMapSelectorProps> = ({ activeIndex = 0 }) => (
|
||||
<div style={{ position: 'absolute', top: 10, right: 10, display: 'flex', background: 'rgba(255,255,255,.93)', borderRadius: 3, border: '1px solid #dde5ea', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,.08)', zIndex: 10 }}>
|
||||
{BASE_MAPS.map((t, i) => (
|
||||
<div key={i} style={{ padding: '4px 9px', fontSize: 11, color: i === activeIndex ? '#fff' : '#4a6070', cursor: 'pointer', borderRight: '1px solid #dde5ea', background: i === activeIndex ? '#0093b2' : 'transparent' }}>{t}</div>
|
||||
))}
|
||||
<div style={{ padding: '4px 9px', fontSize: 11, fontWeight: 600, color: '#0093b2', cursor: 'pointer', borderLeft: '1px solid #b3e4ee' }}>3D지도</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default BaseMapSelector;
|
||||
27
frontend/wing-gis-web/src/components/map/CoordStatusBar.tsx
Normal file
27
frontend/wing-gis-web/src/components/map/CoordStatusBar.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
|
||||
interface CoordStatusBarProps {
|
||||
coord?: string;
|
||||
epsg?: string;
|
||||
scale?: string;
|
||||
zoom?: number;
|
||||
}
|
||||
|
||||
const CoordStatusBar: React.FC<CoordStatusBarProps> = ({
|
||||
coord = '37°28\'14"N 126°38\'52"E',
|
||||
epsg = 'EPSG:4326',
|
||||
scale = '1:50,000',
|
||||
zoom = 10,
|
||||
}) => (
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(255,255,255,.92)', borderTop: '1px solid #dde5ea', padding: '3px 10px', display: 'flex', alignItems: 'center', gap: 12, fontSize: 10, fontFamily: 'var(--mono)', color: '#4a6070', zIndex: 10 }}>
|
||||
<span>{coord}</span>
|
||||
<div style={{ width: 1, height: 12, background: '#dde5ea' }} />
|
||||
<span>{epsg}</span>
|
||||
<div style={{ width: 1, height: 12, background: '#dde5ea' }} />
|
||||
<span>축척 {scale}</span>
|
||||
<div style={{ width: 1, height: 12, background: '#dde5ea' }} />
|
||||
<span>Zoom {zoom}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CoordStatusBar;
|
||||
48
frontend/wing-gis-web/src/components/map/MapLegend.tsx
Normal file
48
frontend/wing-gis-web/src/components/map/MapLegend.tsx
Normal file
@ -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 (
|
||||
<div style={{ position: 'absolute', bottom: 38, right: 12, background: 'rgba(255,255,255,.93)', border: '1px solid #ccd', borderRadius: 4, padding: '7px 10px', fontSize: 10, fontFamily: 'sans-serif', boxShadow: '0 1px 5px rgba(0,0,0,.12)', zIndex: 10, minWidth: 138 }}>
|
||||
<div style={{ fontWeight: 700, color: '#1a3a4a', marginBottom: 5, fontSize: 10, borderBottom: '1px solid #dde', paddingBottom: 3 }}>물표 범례</div>
|
||||
{VESSEL_LEGEND.map((l, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, margin: '3px 0' }}>
|
||||
<span style={{ color: l.color, fontSize: l.size || 11 }}>{l.sym}</span>
|
||||
<span style={{ color: '#333' }}>{l.name}</span>
|
||||
<span style={{ marginLeft: 'auto', fontFamily: 'monospace', color: l.color }}>{l.count}</span>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ borderTop: '1px solid #dde', marginTop: 5, paddingTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '2px 0' }}>
|
||||
<svg width="20" height="8"><line x1="0" y1="4" x2="20" y2="4" stroke="#0044cc" strokeWidth="1.5" strokeDasharray="5,3" /></svg>
|
||||
<span style={{ color: '#333' }}>EEZ</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '2px 0' }}>
|
||||
<svg width="20" height="8"><line x1="0" y1="4" x2="20" y2="4" stroke="#cc0000" strokeWidth="1.2" strokeDasharray="5,3" /></svg>
|
||||
<span style={{ color: '#333' }}>NLL</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, margin: '2px 0' }}>
|
||||
<svg width="20" height="8"><circle cx="4" cy="4" r="3" fill="none" stroke="#cc4400" strokeWidth="1.2" /><line x1="4" y1="1" x2="4" y2={-2} stroke="#cc4400" strokeWidth="1.2" /></svg>
|
||||
<span style={{ color: '#333' }}>등대 · 부표</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapLegend;
|
||||
52
frontend/wing-gis-web/src/components/map/MapTools.tsx
Normal file
52
frontend/wing-gis-web/src/components/map/MapTools.tsx
Normal file
@ -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 (
|
||||
<div style={{ position: 'absolute', left: 10, top: 120, display: 'flex', flexDirection: 'column', gap: 0, background: '#fff', borderRadius: 4, border: '1px solid #dde5ea', overflow: 'hidden', boxShadow: '0 1px 4px rgba(0,0,0,.08)', zIndex: 10 }}>
|
||||
{MAP_TOOLS.map((b, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
title={b.t}
|
||||
onClick={() => handleClick(b.action)}
|
||||
style={{ width: 30, height: 28, border: 'none', background: b.action === 'pan' ? '#e6f7fa' : 'none', cursor: 'pointer', fontSize: 14, color: b.action === 'pan' ? '#0093b2' : '#4a6070', display: 'flex', alignItems: 'center', justifyContent: 'center', borderBottom: i < 6 ? '1px solid #dde5ea' : 'none' }}
|
||||
>
|
||||
{b.icon}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapTools;
|
||||
116
frontend/wing-gis-web/src/components/map/MapViewML.tsx
Normal file
116
frontend/wing-gis-web/src/components/map/MapViewML.tsx
Normal file
@ -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<MapViewMLProps> = ({ children }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<MapContext.Provider value={{ mapRef, overlayRef, mapSyncEpoch }}>
|
||||
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
||||
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
||||
<MapTools />
|
||||
<BaseMapSelector />
|
||||
<NauticalChartToggle />
|
||||
<ScaleBar text={`${scaleText} ━━━ 5 km`} />
|
||||
<MapLegend />
|
||||
{children}
|
||||
<CoordStatusBar coord={coord} zoom={zoom} scale={scaleText} />
|
||||
|
||||
{photoModal && (
|
||||
<ShipImageModal
|
||||
imo={photoModal.imo}
|
||||
vesselName={photoModal.name}
|
||||
initialImagePath={photoModal.imagePath}
|
||||
totalCount={photoModal.imageCount}
|
||||
onClose={closePhotoModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MapContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapViewML;
|
||||
140
frontend/wing-gis-web/src/components/map/MockSeaChart.tsx
Normal file
140
frontend/wing-gis-web/src/components/map/MockSeaChart.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import React from 'react';
|
||||
|
||||
const MockSeaChart: React.FC = () => (
|
||||
<>
|
||||
{/* Grid overlay */}
|
||||
<div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(rgba(255,255,255,.2) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.2) 1px,transparent 1px)', backgroundSize: '50px 50px', zIndex: 0 }} />
|
||||
{/* Depth gradient */}
|
||||
<div style={{ position: 'absolute', inset: 0, background: 'radial-gradient(ellipse 70% 50% at 50% 55%,rgba(140,200,230,.4) 0%,transparent 70%)', zIndex: 0 }} />
|
||||
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', zIndex: 1 }} viewBox="0 0 900 580" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="depthGrad" cx="50%" cy="58%" r="45%">
|
||||
<stop offset="0%" stopColor="#6aadcc" stopOpacity=".4" />
|
||||
<stop offset="50%" stopColor="#8ec4d8" stopOpacity=".2" />
|
||||
<stop offset="100%" stopColor="transparent" />
|
||||
</radialGradient>
|
||||
<radialGradient id="shallowGrad" cx="50%" cy="70%" r="30%">
|
||||
<stop offset="0%" stopColor="#a8d8ec" stopOpacity=".6" />
|
||||
<stop offset="100%" stopColor="transparent" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
<rect width="900" height="580" fill="#c8e4f0" opacity=".6" />
|
||||
<ellipse cx="450" cy="340" rx="390" ry="230" fill="url(#depthGrad)" />
|
||||
<ellipse cx="450" cy="460" rx="450" ry="130" fill="url(#shallowGrad)" />
|
||||
|
||||
{/* Depth contours */}
|
||||
<path d="M100,340 Q180,328 260,338 Q320,346 380,332 Q440,318 500,330 Q560,342 620,326 Q680,310 740,325 Q790,338 850,322" fill="none" stroke="#4488aa" strokeWidth=".8" opacity=".5" />
|
||||
<path d="M70,368 Q160,352 250,364 Q340,376 430,358 Q520,340 610,355 Q700,370 790,350 Q840,340 900,352" fill="none" stroke="#3377aa" strokeWidth="1" opacity=".55" />
|
||||
<path d="M40,400 Q130,382 240,394 Q360,408 470,386 Q580,364 690,382 Q790,398 890,376" fill="none" stroke="#2266aa" strokeWidth="1.2" opacity=".5" />
|
||||
<path d="M20,438 Q120,418 240,430 Q380,444 500,422 Q620,400 760,418 Q830,428 900,412" fill="none" stroke="#1155aa" strokeWidth="1" opacity=".4" />
|
||||
<path d="M10,470 Q120,450 250,462 Q400,475 530,452 Q660,428 800,448 Q860,456 900,440" fill="none" stroke="#004499" strokeWidth="1" strokeDasharray="3,3" opacity=".35" />
|
||||
<path d="M0,505 Q120,485 260,496 Q420,508 560,484 Q700,460 860,480" fill="none" stroke="#003388" strokeWidth="1" strokeDasharray="5,3" opacity=".3" />
|
||||
|
||||
{/* Depth contour labels */}
|
||||
<text x="92" y="337" fontSize="8.5" fill="#3377aa" fontFamily="serif" fontStyle="italic" opacity=".7">5</text>
|
||||
<text x="64" y="365" fontSize="8.5" fill="#3377aa" fontFamily="serif" fontStyle="italic" opacity=".7">10</text>
|
||||
<text x="36" y="398" fontSize="8.5" fill="#2266aa" fontFamily="serif" fontStyle="italic" opacity=".7">20</text>
|
||||
<text x="18" y="436" fontSize="8.5" fill="#1155aa" fontFamily="serif" fontStyle="italic" opacity=".65">50</text>
|
||||
<text x="8" y="469" fontSize="8.5" fill="#004499" fontFamily="serif" fontStyle="italic" opacity=".6">100</text>
|
||||
<text x="2" y="503" fontSize="8.5" fill="#003388" fontFamily="serif" fontStyle="italic" opacity=".55">200</text>
|
||||
<text x="860" y="325" fontSize="8.5" fill="#3377aa" fontFamily="serif" fontStyle="italic" opacity=".6">10</text>
|
||||
<text x="855" y="378" fontSize="8.5" fill="#2266aa" fontFamily="serif" fontStyle="italic" opacity=".6">20</text>
|
||||
<text x="862" y="414" fontSize="8.5" fill="#1155aa" fontFamily="serif" fontStyle="italic" opacity=".55">50</text>
|
||||
|
||||
{/* Coastline */}
|
||||
<path d="M0,0 L0,262 Q26,244 57,257 Q88,270 118,252 Q150,233 181,247 Q215,261 250,243 Q290,223 322,241 Q356,259 390,245 Q412,234 433,249 Q462,265 494,246 Q526,227 558,241 Q589,255 620,241 Q650,227 680,243 Q710,258 737,243 Q764,227 792,239 Q830,255 872,239 L900,233 L900,0 Z" fill="#eeecd8" stroke="#333" strokeWidth="1.4" />
|
||||
<path d="M0,0 L0,255 Q26,237 57,250 Q88,262 118,245 Q150,226 181,240 Q215,254 250,236 Q290,216 322,234 Q356,252 390,238 Q412,227 433,242 Q462,258 494,239 Q526,220 558,234 Q589,248 620,234 Q650,220 680,236 Q710,251 737,236 Q764,220 792,232 Q830,248 872,232 L900,226 L900,0 Z" fill="#e0ddc0" opacity=".35" />
|
||||
|
||||
{/* Islands */}
|
||||
<ellipse cx="295" cy="338" rx="28" ry="16" fill="#eeecd8" stroke="#333" strokeWidth="1" />
|
||||
<text x="295" y="341" textAnchor="middle" fontSize="8" fill="#333" fontFamily="sans-serif" fontWeight="500">덕적도</text>
|
||||
<ellipse cx="478" cy="296" rx="17" ry="10" fill="#eeecd8" stroke="#333" strokeWidth="1" />
|
||||
<text x="478" y="299" textAnchor="middle" fontSize="7.5" fill="#333" fontFamily="sans-serif">영흥도</text>
|
||||
<ellipse cx="619" cy="318" rx="30" ry="14" fill="#eeecd8" stroke="#333" strokeWidth="1" />
|
||||
<text x="619" y="321" textAnchor="middle" fontSize="8" fill="#333" fontFamily="sans-serif" fontWeight="500">강화도</text>
|
||||
<ellipse cx="174" cy="388" rx="13" ry="8" fill="#eeecd8" stroke="#333" strokeWidth=".8" />
|
||||
<ellipse cx="723" cy="358" rx="19" ry="9" fill="#eeecd8" stroke="#333" strokeWidth=".8" />
|
||||
<text x="723" y="361" textAnchor="middle" fontSize="7" fill="#333" fontFamily="sans-serif">교동도</text>
|
||||
<ellipse cx="530" cy="355" rx="11" ry="7" fill="#eeecd8" stroke="#333" strokeWidth=".8" />
|
||||
|
||||
{/* Rocks */}
|
||||
<text x="340" y="372" fontSize="11" fill="#cc4400" opacity=".7" textAnchor="middle">+</text>
|
||||
<text x="410" y="348" fontSize="11" fill="#cc4400" opacity=".7" textAnchor="middle">+</text>
|
||||
<text x="565" y="338" fontSize="10" fill="#cc4400" opacity=".65" textAnchor="middle">+</text>
|
||||
|
||||
{/* Shallow areas */}
|
||||
<path d="M100,330 Q160,310 220,320 Q280,330 295,323 Q295,354 280,354 Q220,354 160,344 Z" fill="#a0d0e8" opacity=".4" />
|
||||
<path d="M600,305 Q650,295 700,305 Q720,315 723,347 Q700,367 660,365 Q620,340 600,325 Z" fill="#a0d0e8" opacity=".35" />
|
||||
|
||||
{/* EEZ */}
|
||||
<path d="M30,64 Q200,44 402,68 Q602,90 872,62" fill="none" stroke="#0044cc" strokeWidth="1.8" strokeDasharray="10,6" opacity=".65" />
|
||||
<text x="120" y="58" fontSize="9" fill="#0044cc" opacity=".8" fontFamily="sans-serif" fontWeight="700">EEZ</text>
|
||||
<text x="480" y="55" fontSize="9" fill="#0044cc" opacity=".7" fontFamily="sans-serif">배타적 경제수역</text>
|
||||
|
||||
{/* NLL */}
|
||||
<path d="M56,163 Q196,148 376,158 Q559,168 744,153" fill="none" stroke="#cc0000" strokeWidth="1.4" strokeDasharray="8,5" opacity=".55" />
|
||||
<text x="120" y="158" fontSize="9" fill="#cc0000" opacity=".75" fontFamily="sans-serif" fontWeight="700">NLL</text>
|
||||
|
||||
{/* Jurisdiction */}
|
||||
<rect x="78" y="102" width="316" height="214" fill="rgba(0,147,178,.06)" stroke="#0093b2" strokeWidth="1" strokeDasharray="5,4" opacity=".5" rx="2" />
|
||||
<text x="90" y="120" fontSize="9" fill="#0093b2" opacity=".6" fontFamily="sans-serif">인천 관할해역</text>
|
||||
|
||||
{/* Patrol route */}
|
||||
<path d="M60,200 Q200,185 380,190 Q540,195 750,180" fill="none" stroke="#0066aa" strokeWidth="1" strokeDasharray="10,5" opacity=".3" />
|
||||
<text x="180" y="182" fontSize="8" fill="#0066aa" opacity=".4" fontFamily="sans-serif">순찰항로</text>
|
||||
|
||||
{/* Tracks */}
|
||||
<path d="M200,428 Q230,385 263,350 Q283,325 312,300" fill="none" stroke="#0055cc" strokeWidth="1.5" strokeDasharray="5,4" opacity=".45" />
|
||||
<path d="M418,465 Q450,423 482,392 Q500,370 530,346" fill="none" stroke="#1a8a3a" strokeWidth="1.5" strokeDasharray="5,4" opacity=".4" />
|
||||
<path d="M580,395 Q561,359 550,323 Q543,300 532,278" fill="none" stroke="#cc3300" strokeWidth="1.5" strokeDasharray="5,4" opacity=".4" />
|
||||
<path d="M720,360 Q710,330 705,305 Q700,285 695,262" fill="none" stroke="#8833cc" strokeWidth="1.2" strokeDasharray="4,4" opacity=".35" />
|
||||
|
||||
{/* Lighthouses */}
|
||||
<circle cx="295" cy="365" r="4.5" fill="none" stroke="#cc4400" strokeWidth="1.5" />
|
||||
<line x1="295" y1="360" x2="295" y2="348" stroke="#cc4400" strokeWidth="1.5" />
|
||||
<text x="304" y="363" fontSize="7.5" fill="#cc4400" opacity=".8" fontFamily="sans-serif">Fl.4s</text>
|
||||
<circle cx="620" cy="333" r="4.5" fill="none" stroke="#cc4400" strokeWidth="1.5" />
|
||||
<line x1="620" y1="328" x2="620" y2="316" stroke="#cc4400" strokeWidth="1.5" />
|
||||
<text x="629" y="331" fontSize="7.5" fill="#cc4400" opacity=".8" fontFamily="sans-serif">Fl.3s</text>
|
||||
<circle cx="380" cy="310" r="3" fill="#cc4400" opacity=".7" />
|
||||
<circle cx="455" cy="295" r="3" fill="#cc4400" opacity=".7" />
|
||||
<circle cx="540" cy="300" r="3" fill="none" stroke="#cc4400" strokeWidth="1.2" opacity=".7" />
|
||||
<circle cx="650" cy="290" r="3" fill="#cc4400" opacity=".7" />
|
||||
|
||||
{/* Depth numbers */}
|
||||
<text x="160" y="420" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".65">24</text>
|
||||
<text x="250" y="400" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".65">38</text>
|
||||
<text x="355" y="415" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".6">52</text>
|
||||
<text x="460" y="435" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".6">67</text>
|
||||
<text x="570" y="420" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".6">43</text>
|
||||
<text x="680" y="408" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".6">29</text>
|
||||
<text x="200" y="460" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".55">81</text>
|
||||
<text x="430" y="470" fontSize="9" fill="#0a4070" fontFamily="serif" fontStyle="italic" opacity=".55">114</text>
|
||||
<text x="650" y="455" fontSize="9" fill="#0a4070" fontFamily="serif" fontStyle="italic" opacity=".5">98</text>
|
||||
<text x="310" y="280" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".6">15</text>
|
||||
<text x="510" y="270" fontSize="9" fill="#1a5580" fontFamily="serif" fontStyle="italic" opacity=".55">12</text>
|
||||
|
||||
{/* Sea name */}
|
||||
<text x="450" y="200" textAnchor="middle" fontSize="18" fill="#1a6090" opacity=".3" fontStyle="italic" fontFamily="serif" letterSpacing="8">{'서 해'}</text>
|
||||
<text x="450" y="222" textAnchor="middle" fontSize="10" fill="#1a6090" opacity=".22" fontStyle="italic" fontFamily="serif" letterSpacing="4">Yellow Sea</text>
|
||||
|
||||
{/* Lat/Lon grid */}
|
||||
<line x1="0" y1="145" x2="900" y2="145" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<line x1="0" y1="290" x2="900" y2="290" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<line x1="0" y1="435" x2="900" y2="435" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<line x1="225" y1="0" x2="225" y2="580" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<line x1="450" y1="0" x2="450" y2="580" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<line x1="675" y1="0" x2="675" y2="580" stroke="rgba(100,150,200,.18)" strokeWidth=".6" />
|
||||
<text x="4" y="143" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">38°N</text>
|
||||
<text x="4" y="288" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">37°N</text>
|
||||
<text x="4" y="433" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">36°N</text>
|
||||
<text x="215" y="572" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">125°E</text>
|
||||
<text x="440" y="572" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">126°E</text>
|
||||
<text x="665" y="572" fontSize="8" fill="rgba(60,100,160,.5)" fontFamily="monospace">127°E</text>
|
||||
</svg>
|
||||
</>
|
||||
);
|
||||
|
||||
export default MockSeaChart;
|
||||
13
frontend/wing-gis-web/src/components/map/ScaleBar.tsx
Normal file
13
frontend/wing-gis-web/src/components/map/ScaleBar.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ScaleBarProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
const ScaleBar: React.FC<ScaleBarProps> = ({ text = '1 : 50,000 ━━━ 5 km' }) => (
|
||||
<div style={{ position: 'absolute', bottom: 34, left: 10, zIndex: 10, background: 'rgba(255,255,255,.88)', border: '1px solid #dde5ea', borderRadius: 3, padding: '3px 8px', fontSize: 10, fontFamily: 'var(--mono)', color: '#4a6070' }}>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ScaleBar;
|
||||
105
frontend/wing-gis-web/src/components/vessel/VesselPopup.tsx
Normal file
105
frontend/wing-gis-web/src/components/vessel/VesselPopup.tsx
Normal file
@ -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 (
|
||||
<div style={{
|
||||
position: 'absolute', top: '18%', left: '36%', width: 240, zIndex: 20,
|
||||
background: '#fff', border: '1px solid #d0dce8', borderRadius: 8,
|
||||
boxShadow: '0 4px 18px rgba(0,0,0,0.15)', overflow: 'hidden',
|
||||
fontFamily: "'Noto Sans KR', sans-serif",
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ background: '#1a3a4a', padding: '9px 12px 8px', position: 'relative' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 22, height: 15, borderRadius: 2, background: '#cc0000',
|
||||
fontSize: 8, color: '#fff', fontWeight: 700, border: '1px solid rgba(255,255,255,0.3)',
|
||||
}}>
|
||||
{vessel.flag || 'KR'}
|
||||
</span>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: '#fff' }}>{vessel.name || '—'}</div>
|
||||
<div style={{ fontSize: 10, color: 'rgba(255,255,255,0.5)', fontFamily: 'monospace', marginTop: 2 }}>
|
||||
MMSI: {vessel.mmsi || '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={() => setSelectedVessel(null)}
|
||||
style={{ position: 'absolute', top: 7, right: 9, color: 'rgba(255,255,255,0.45)', cursor: 'pointer', fontSize: 16 }}
|
||||
>
|
||||
✕
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Ship icon */}
|
||||
<div style={{ background: '#f2f6f9', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: '12px 0 8px' }}>
|
||||
<span style={{ fontSize: 38 }}>🚢</span>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div style={{ display: 'flex', gap: 5, justifyContent: 'center', padding: '0 12px 10px', background: '#f2f6f9', borderBottom: '1px solid #e0eaf0' }}>
|
||||
<span style={{ fontSize: 10, padding: '2px 9px', borderRadius: 3, fontWeight: 500, background: '#e6f0fb', color: '#1a5fa0', border: '1px solid #b5d4f4' }}>
|
||||
{vessel.shipType || vessel.source}
|
||||
</span>
|
||||
<span style={{
|
||||
fontSize: 10, padding: '2px 9px', borderRadius: 3, fontWeight: 500,
|
||||
background: isAlert ? '#fdecea' : '#eaf6ee',
|
||||
color: isAlert ? '#c0301c' : '#1a6e38',
|
||||
border: `1px solid ${isAlert ? '#f5b0a8' : '#a8d8b8'}`,
|
||||
}}>
|
||||
{vessel.status || '항해중'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Info rows */}
|
||||
<div style={{ padding: '9px 13px 6px' }}>
|
||||
<InfoRow label="속도/항로" value={`${vessel.sog ?? '—'} kn / ${vessel.cog ?? '—'}°`} highlight />
|
||||
<InfoRow label="흘수" value={vessel.draft ? `${vessel.draft}m` : '—'} />
|
||||
<InfoRow label="출항지" value={vessel.destination || '—'} />
|
||||
<InfoRow label="데이터 소스" value={vessel.source} dim />
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<div style={{ display: 'flex', gap: 1, borderTop: '1px solid #e0eaf0', background: '#f7fafc' }}>
|
||||
{['📋 상세정보', '🗺 항적조회', '🧭 항로예측'].map((label) => (
|
||||
<button
|
||||
key={label}
|
||||
style={{
|
||||
flex: 1, padding: '7px 4px', background: 'transparent', color: '#4a6070',
|
||||
fontSize: 11, border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 3,
|
||||
}}
|
||||
onMouseOver={(e) => { e.currentTarget.style.background = '#e6f2fa'; e.currentTarget.style.color = '#0077aa'; }}
|
||||
onMouseOut={(e) => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#4a6070'; }}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value, highlight, dim }: { label: string; value: string; highlight?: boolean; dim?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', padding: '5px 0', borderBottom: '1px solid #edf2f6' }}>
|
||||
<span style={{ fontSize: 11, color: '#8a9eaa', flexShrink: 0 }}>{label}</span>
|
||||
<span style={{ fontSize: 11, fontFamily: 'monospace', textAlign: 'right', color: highlight ? '#0077aa' : dim ? '#aab8c4' : '#1a2e38', fontWeight: highlight ? 600 : 400 }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
frontend/wing-gis-web/src/components/vessel/VesselSearch.tsx
Normal file
160
frontend/wing-gis-web/src/components/vessel/VesselSearch.tsx
Normal file
@ -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, '<em style="color:#0093b2;font-weight:700;font-style:normal">$1</em>');
|
||||
};
|
||||
|
||||
const hasFilter = query || typeFilter || srcFilter;
|
||||
const list = hasFilter ? results() : [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 검색 입력 */}
|
||||
<div style={{ padding: '7px 9px 6px', background: '#0093b2' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5, background: '#fff', borderRadius: 3, padding: '3px 8px', height: 27 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="위치 / MMSI / 선박명 검색"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ flex: 1, border: 'none', outline: 'none', fontSize: 12, fontFamily: 'inherit' }}
|
||||
/>
|
||||
<span style={{ cursor: 'pointer', color: '#0093b2', fontSize: 13 }}>🔍</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
{hasFilter && (
|
||||
<div style={{ padding: '5px 9px', display: 'flex', gap: 4, borderBottom: '1px solid #dde5ea' }}>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 10, padding: '2px 4px', border: '1px solid #dde', borderRadius: 3 }}
|
||||
>
|
||||
<option value="">전체 선종</option>
|
||||
<option value="Bulk">벌크</option>
|
||||
<option value="Tanker">탱커</option>
|
||||
<option value="Cargo">화물</option>
|
||||
<option value="어선">어선</option>
|
||||
<option value="항공기">항공기</option>
|
||||
</select>
|
||||
<select
|
||||
value={srcFilter}
|
||||
onChange={(e) => setSrcFilter(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 10, padding: '2px 4px', border: '1px solid #dde', borderRadius: 3 }}
|
||||
>
|
||||
<option value="">전체 소스</option>
|
||||
<option value="AIS">AIS</option>
|
||||
<option value="V-Pass">V-Pass</option>
|
||||
<option value="E-NAV">E-NAV</option>
|
||||
<option value="VTS">VTS</option>
|
||||
<option value="VTS-RADAR">VTS-레이더</option>
|
||||
<option value="AIR-AIS">항공기AIS</option>
|
||||
<option value="VHF-DSC">VHF-DSC</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { setQuery(''); setTypeFilter(''); setSrcFilter(''); }}
|
||||
style={{ fontSize: 10, color: '#0093b2', background: 'none', border: 'none', cursor: 'pointer' }}
|
||||
>
|
||||
✕ 닫기
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검색 결과 */}
|
||||
{hasFilter && (
|
||||
<div style={{ borderBottom: '1px solid #dde5ea' }}>
|
||||
<div style={{ padding: '4px 10px', fontSize: 10, color: '#8a9eaa', background: '#f0f6f9', borderBottom: '1px solid #dde5ea' }}>
|
||||
{list.length}건
|
||||
</div>
|
||||
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
{list.length === 0 ? (
|
||||
<div style={{ padding: 16, textAlign: 'center', fontSize: 12, color: '#8a9eaa' }}>검색 결과가 없습니다.</div>
|
||||
) : (
|
||||
list.map((v) => (
|
||||
<VesselResultItem key={v.id || v.mmsi} vessel={v} highlight={highlight} onClick={() => setSelectedVessel(v)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px',
|
||||
borderBottom: '1px solid #dde5ea', cursor: 'pointer', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = '#edf6f9')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = '')}
|
||||
>
|
||||
<div style={{
|
||||
width: 22, height: 22, borderRadius: 4, display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', fontSize: 12, background: `${color}15`,
|
||||
}}>
|
||||
{v.source === 'AIR-AIS' ? '✈' : '🚢'}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{ fontSize: 12, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
dangerouslySetInnerHTML={{ __html: `${highlight(v.name || '—')} <span style="font-size:9px;color:#8a9eaa">${highlight(v.mmsi || '')}</span>` }}
|
||||
/>
|
||||
<div style={{ fontSize: 10, color: '#8a9eaa', marginTop: 1 }}>
|
||||
{v.shipType || '—'} · {v.source} · {v.sog ?? '—'} kn
|
||||
</div>
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: 9, padding: '1px 6px', borderRadius: 8, fontWeight: 500,
|
||||
background: isAlert ? '#fdecea' : '#eaf6ee',
|
||||
color: isAlert ? '#c0301c' : '#1a6e38',
|
||||
border: `1px solid ${isAlert ? '#f5b0a8' : '#a8d8b8'}`,
|
||||
}}>
|
||||
{v.status || '정상'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
frontend/wing-gis-web/src/components/vessel/VesselShape.tsx
Normal file
18
frontend/wing-gis-web/src/components/vessel/VesselShape.tsx
Normal file
@ -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<VesselShapeProps> = ({ type, cls }) => {
|
||||
const color = vesselColors[cls] || '#666';
|
||||
if (type === 'tri') return <div style={{ width: 0, height: 0, borderLeft: '5px solid transparent', borderRight: '5px solid transparent', borderBottom: `13px solid ${color}` }} />;
|
||||
if (type === 'sq') return <div style={{ width: 10, height: 10, border: `2px solid ${color}`, background: 'rgba(255,255,255,.5)' }} />;
|
||||
if (type === 'dia') return <div style={{ width: 9, height: 9, transform: 'rotate(45deg)', border: `2px solid ${color}`, background: 'rgba(255,255,255,.5)' }} />;
|
||||
if (type === 'plane') return <div style={{ fontSize: 13, lineHeight: 1, color }}>{'\u2708'}</div>;
|
||||
return null;
|
||||
};
|
||||
|
||||
export default VesselShape;
|
||||
@ -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 (
|
||||
<div>
|
||||
{SIGNALS.map(({ key, label, icon }) => {
|
||||
const on = signalState[key];
|
||||
const color = VESSEL_COLORS[key];
|
||||
const cnt = count(key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => 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')}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<div style={{
|
||||
width: 13, height: 13, borderRadius: 2, flexShrink: 0,
|
||||
border: `1.5px solid ${on ? color : '#bbb'}`,
|
||||
background: on ? color : 'transparent',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
{on && <span style={{ color: '#fff', fontSize: 8, fontWeight: 700 }}>✓</span>}
|
||||
</div>
|
||||
|
||||
{/* 아이콘 */}
|
||||
<span style={{ fontSize: 11, color: on ? color : '#ccc', flexShrink: 0 }}>{icon}</span>
|
||||
|
||||
{/* 이름 */}
|
||||
<span style={{ fontSize: 12, color: on ? '#1a2e38' : '#aaa', flex: 1 }}>{label}</span>
|
||||
|
||||
{/* 카운트 배지 */}
|
||||
<span style={{
|
||||
fontSize: 9, padding: '1px 4px', borderRadius: 9,
|
||||
background: key === 'VHF-DSC' ? '#fde' : `${color}15`,
|
||||
color: key === 'VHF-DSC' ? '#d63030' : color,
|
||||
fontFamily: 'monospace',
|
||||
}}>
|
||||
{cnt}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
frontend/wing-gis-web/src/context/MapContext.tsx
Normal file
17
frontend/wing-gis-web/src/context/MapContext.tsx
Normal file
@ -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<maplibregl.Map | null>;
|
||||
overlayRef: MutableRefObject<MapboxOverlay | null>;
|
||||
mapSyncEpoch: number;
|
||||
}
|
||||
|
||||
export const MapContext = createContext<MapContextValue | null>(null);
|
||||
|
||||
export function useMapContext(): MapContextValue {
|
||||
const ctx = useContext(MapContext);
|
||||
if (!ctx) throw new Error('useMapContext must be used within MapContext.Provider');
|
||||
return ctx;
|
||||
}
|
||||
72
frontend/wing-gis-web/src/data/mockVessels.ts
Normal file
72
frontend/wing-gis-web/src/data/mockVessels.ts
Normal file
@ -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<string, MockVesselInfo> = {
|
||||
'대양호': {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<string, string> = {
|
||||
'v-ais': '#0055cc',
|
||||
'v-vpass': '#1a8a3a',
|
||||
'v-enav': '#8833cc',
|
||||
'v-vts': '#cc6600',
|
||||
'v-vtsradar': '#cc3300',
|
||||
'v-airais': '#0099aa',
|
||||
'v-vhfdsc': '#d63030',
|
||||
};
|
||||
@ -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<maplibregl.Map | null>,
|
||||
settings: NauticalChartSettings | undefined,
|
||||
opts: { mapSyncEpoch: number },
|
||||
) {
|
||||
const stateRef = useRef<NauticalLayerState | null>(null);
|
||||
const { mapSyncEpoch } = opts;
|
||||
|
||||
const [styleData, setStyleData] = useState<NauticalStyleData | null>(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
|
||||
}, []);
|
||||
}
|
||||
@ -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<string, unknown>;
|
||||
layers: unknown[];
|
||||
sprite: string;
|
||||
glyphs: string;
|
||||
}
|
||||
|
||||
let cachedFull: StyleSpecification | null = null;
|
||||
let cachedParts: NauticalStyleData | null = null;
|
||||
|
||||
export async function fetchEncStyle(signal?: AbortSignal): Promise<StyleSpecification> {
|
||||
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<string, unknown> }).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<NauticalStyleData> {
|
||||
if (cachedParts) return cachedParts;
|
||||
|
||||
const style = await fetchEncStyle(signal);
|
||||
cachedParts = {
|
||||
sources: (style.sources ?? {}) as Record<string, unknown>,
|
||||
layers: style.layers as unknown[],
|
||||
sprite: (style.sprite ?? '') as string,
|
||||
glyphs: (style.glyphs ?? '') as string,
|
||||
};
|
||||
return cachedParts;
|
||||
}
|
||||
@ -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<string, unknown>; 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<string, unknown>;
|
||||
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 */ }
|
||||
}
|
||||
}
|
||||
@ -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<S52Theme, Record<string, string>> = {
|
||||
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',
|
||||
},
|
||||
};
|
||||
@ -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 (
|
||||
<div style={{ position: 'absolute', top: 50, right: 10, zIndex: 10, background: 'rgba(255,255,255,.93)', border: '1px solid #dde5ea', borderRadius: 4, padding: '6px 8px', boxShadow: '0 1px 4px rgba(0,0,0,.08)', display: 'flex', alignItems: 'center', gap: 8, fontSize: 11 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', color: '#1a3a4a', fontWeight: 600 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enabled}
|
||||
onChange={(e) => setSettings({ ...settings, enabled: e.target.checked })}
|
||||
style={{ accentColor: '#0093b2' }}
|
||||
/>
|
||||
ENC 해도
|
||||
</label>
|
||||
{settings.enabled && (
|
||||
<div style={{ display: 'flex', gap: 2 }}>
|
||||
{S52_THEMES.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
type="button"
|
||||
onClick={() => setSettings({ ...settings, theme: t.value })}
|
||||
style={{
|
||||
padding: '2px 7px', fontSize: 10, border: '1px solid #dde5ea', borderRadius: 3, cursor: 'pointer',
|
||||
background: settings.theme === t.value ? '#0093b2' : 'transparent',
|
||||
color: settings.theme === t.value ? '#fff' : '#4a6070',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NauticalChartToggle;
|
||||
@ -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<ShipImageInfo[]> {
|
||||
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');
|
||||
};
|
||||
@ -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<ShipImageModalProps> = ({
|
||||
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<ShipImageInfo[] | null>(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 (
|
||||
<div style={S.overlay} onClick={onClose}>
|
||||
<div style={S.content} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={S.header}>
|
||||
<span style={S.title}>
|
||||
{vesselName && <strong>{vesselName}</strong>}
|
||||
{total > 1 && <span style={S.counter}>{index + 1} / {total}</span>}
|
||||
</span>
|
||||
<button style={S.closeBtn} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div style={S.body}>
|
||||
{total > 1 && <button style={{ ...S.nav, left: 8 }} onClick={goPrev}>‹</button>}
|
||||
<div style={S.imgWrap}>
|
||||
{(loading || fetching) && !error && <div style={S.spinner} />}
|
||||
{error && <div style={S.error}>이미지를 불러올 수 없습니다</div>}
|
||||
{currentUrl && (
|
||||
<img
|
||||
key={currentUrl}
|
||||
src={currentUrl}
|
||||
alt={vesselName || '선박 사진'}
|
||||
style={{ ...S.img, opacity: loading ? 0 : 1 }}
|
||||
onLoad={() => setLoading(false)}
|
||||
onError={() => { setLoading(false); setError(true); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{total > 1 && <button style={{ ...S.nav, right: 8 }} onClick={goNext}>›</button>}
|
||||
</div>
|
||||
|
||||
{thumbs.length > 1 && (
|
||||
<div style={S.carousel}>
|
||||
{thumbs.map((src, i) => (
|
||||
<img key={i} src={src} alt="" style={S.thumb(i === index)} onClick={() => { setIndex(i); setLoading(true); setError(false); }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={S.footer}>
|
||||
{meta?.copyright && <span>{meta.copyright}</span>}
|
||||
{meta?.date && <span>{meta.date}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipImageModal;
|
||||
@ -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<maplibregl.Map | null>,
|
||||
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<ShipBatchRenderer<VesselFeature> | null>(null);
|
||||
const [renderedNonAis, setRenderedNonAis] = useState<VesselFeature[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const renderer = new ShipBatchRenderer<VesselFeature>();
|
||||
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<ShipBatchRenderer<AisFeature> | null>(null);
|
||||
const [renderedAis, setRenderedAis] = useState<AisFeature[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const renderer = new ShipBatchRenderer<AisFeature>();
|
||||
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<string>(), []);
|
||||
|
||||
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]);
|
||||
}
|
||||
@ -0,0 +1,145 @@
|
||||
export interface ViewportBounds {
|
||||
minLon: number;
|
||||
maxLon: number;
|
||||
minLat: number;
|
||||
maxLat: number;
|
||||
}
|
||||
|
||||
interface HasPosition {
|
||||
lon: number;
|
||||
lat: number;
|
||||
}
|
||||
|
||||
type RenderCallback<T> = (ships: T[], trigger: number) => void;
|
||||
|
||||
const ZOOM_MIN_INTERVAL: Record<number, number> = {
|
||||
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<T extends HasPosition>(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<T extends HasPosition>(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<string, number>();
|
||||
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<T extends HasPosition = HasPosition> {
|
||||
private data: T[] = [];
|
||||
private callback: RenderCallback<T> | null = null;
|
||||
private viewport: ViewportBounds | null = null;
|
||||
private zoom = 10;
|
||||
private interval = 1000;
|
||||
private handle: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastRenderTime = 0;
|
||||
private trigger = 0;
|
||||
private rendering = false;
|
||||
|
||||
initialize(cb: RenderCallback<T>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<string, [number, number, number, number]>();
|
||||
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<number, AisTarget>): 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(),
|
||||
};
|
||||
}
|
||||
@ -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<AisFeature>({
|
||||
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<AisFeature>({
|
||||
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<AisFeature>({
|
||||
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];
|
||||
}
|
||||
@ -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<string, VesselIconSpec>();
|
||||
const SPEED_THRESHOLD_KN = 1;
|
||||
|
||||
function makeMovingShipSvg(fill: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48">
|
||||
<path d="M16 2 L8 13 L4 28 L7 45 L25 45 L28 28 L24 13 Z" fill="${fill}" stroke="rgba(0,0,0,0.6)" stroke-width="1.5" stroke-linejoin="round"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function makeStoppedShipSvg(fill: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="5.5" fill="${fill}" stroke="rgba(0,0,0,0.6)" stroke-width="1.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function makeBuoySvg(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="44" viewBox="0 0 32 44">
|
||||
<rect x="14" y="0" width="4" height="20" fill="#795548" rx="1"/>
|
||||
<ellipse cx="16" cy="28" rx="10" ry="8" fill="#D32F2F" stroke="rgba(0,0,0,0.5)" stroke-width="1"/>
|
||||
<rect x="8" y="25" width="16" height="6" fill="#FFC107" rx="1"/>
|
||||
<circle cx="16" cy="4" r="3" fill="#FFEB3B" stroke="rgba(0,0,0,0.3)" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -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
|
||||
? `<div style="margin:0 0 6px;text-align:center">
|
||||
<img src="${toThumbnailUrl(ship.shipImagePath)}" alt="" style="width:140px;height:90px;object-fit:cover;border-radius:5px;display:inline-block;border:1px solid rgba(0,147,178,.25)" />
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return {
|
||||
html: `<div style="font-family:'Noto Sans KR',system-ui,sans-serif;font-size:12px;white-space:nowrap;line-height:1.4">
|
||||
${photoHtml}
|
||||
<div style="font-weight:700;margin-bottom:3px;color:#1a3a4a">${ship.name || `MMSI ${ship.mmsi}`}</div>
|
||||
<div style="color:#4a6070">MMSI: <b style="color:#1a2e38">${ship.mmsi}</b>${kindLabel ? ` · ${kindLabel}` : ''}</div>
|
||||
<div style="color:#4a6070">SOG: <b style="color:#0093b2">${ship.sog.toFixed(1)}</b> kn · COG: <b style="color:#0093b2">${ship.cog.toFixed(0)}</b>°</div>
|
||||
${ship.shipImageCount ? `<div style="color:#4a6070;font-size:11px">사진: <b>${ship.shipImageCount}</b>장</div>` : ''}
|
||||
<div style="color:#8a9eaa;font-size:11px;margin-top:3px">${fmtTimestamp(ship.messageTimestamp)}</div>
|
||||
</div>`,
|
||||
};
|
||||
}
|
||||
@ -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<VesselSource, [number, number, number, number]> = Object.fromEntries(
|
||||
(Object.entries(VESSEL_COLORS) as [VesselSource, string][]).map(([src, hex]) => [src, hexToRgba(hex)])
|
||||
) as Record<VesselSource, [number, number, number, number]>;
|
||||
|
||||
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];
|
||||
}
|
||||
@ -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<VesselSource, boolean>;
|
||||
selectedMmsi: string | null;
|
||||
highlightedMmsis: Set<string>;
|
||||
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<VesselFeature>({
|
||||
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<VesselFeature>({
|
||||
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<VesselFeature>({
|
||||
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];
|
||||
}
|
||||
@ -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<string, VesselIconSpec>();
|
||||
|
||||
function buildMovingSvg(color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="48" viewBox="0 0 32 48">
|
||||
<path d="M16 2L26 38L16 30L6 38Z" fill="${color}" stroke="#fff" stroke-width="1.5"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildStoppedSvg(color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<circle cx="8" cy="8" r="6" fill="${color}" stroke="#fff" stroke-width="1.2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildSquareSvg(color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect x="3" y="3" width="18" height="18" fill="rgba(255,255,255,0.5)" stroke="${color}" stroke-width="2.5" rx="2"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildDiamondSvg(color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
|
||||
<rect x="4" y="4" width="16" height="16" fill="rgba(255,255,255,0.5)" stroke="${color}" stroke-width="2.5" transform="rotate(45 12 12)"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function buildPlaneSvg(color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||
<path d="M16 4L20 14L28 16L20 18L16 28L12 18L4 16L12 14Z" fill="${color}" stroke="#fff" stroke-width="1"/>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
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;
|
||||
62
frontend/wing-gis-web/src/hooks/useAisPolling.ts
Normal file
62
frontend/wing-gis-web/src/hooks/useAisPolling.ts
Normal file
@ -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<typeof setInterval> | 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]);
|
||||
}
|
||||
28
frontend/wing-gis-web/src/hooks/useDeckLayers.ts
Normal file
28
frontend/wing-gis-web/src/hooks/useDeckLayers.ts
Normal file
@ -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<MapboxOverlay | null>,
|
||||
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]);
|
||||
}
|
||||
62
frontend/wing-gis-web/src/hooks/useMap.ts
Normal file
62
frontend/wing-gis-web/src/hooks/useMap.ts
Normal file
@ -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<maplibregl.Map | null>(null);
|
||||
const overlayRef = useRef<MapboxOverlay | null>(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 };
|
||||
}
|
||||
85
frontend/wing-gis-web/src/hooks/useStore.ts
Normal file
85
frontend/wing-gis-web/src/hooks/useStore.ts
Normal file
@ -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<VesselSource, boolean>;
|
||||
toggleSignal: (source: VesselSource) => void;
|
||||
|
||||
// 사이드바 검색
|
||||
searchQuery: string;
|
||||
setSearchQuery: (q: string) => void;
|
||||
|
||||
// AIS 실시간 데이터
|
||||
aisTargets: Map<number, AisTarget>;
|
||||
setAisTargets: (targets: Map<number, AisTarget>) => 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<AppState>((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 }),
|
||||
}));
|
||||
48
frontend/wing-gis-web/src/hooks/useVesselStream.ts
Normal file
48
frontend/wing-gis-web/src/hooks/useVesselStream.ts
Normal file
@ -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<Client | null>(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;
|
||||
}
|
||||
111
frontend/wing-gis-web/src/index.css
Normal file
111
frontend/wing-gis-web/src/index.css
Normal file
@ -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);
|
||||
}
|
||||
173
frontend/wing-gis-web/src/lib/map/MaplibreDeckCustomLayer.ts
Normal file
173
frontend/wing-gis-web/src/lib/map/MaplibreDeckCustomLayer.ts
Normal file
@ -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<MatrixViewState> {
|
||||
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<DeckProps<MatrixView[]>['layers']>;
|
||||
|
||||
function readMat4(m: ArrayLike<number>): number[] {
|
||||
const out = new Array<number>(16);
|
||||
for (let i = 0; i < 16; i++) out[i] = m[i] as number;
|
||||
return out;
|
||||
}
|
||||
|
||||
function mat4Changed(a: number[] | undefined, b: ArrayLike<number>): 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<string>();
|
||||
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<MatrixView[]> | null = null;
|
||||
private _deckProps: Partial<DeckProps<MatrixView[]>> = {};
|
||||
private _viewId: string;
|
||||
private _lastMvp: number[] | undefined;
|
||||
private _finalizeOnRemove = false;
|
||||
|
||||
constructor(opts: { id: string; viewId?: string; deckProps?: Partial<DeckProps<MatrixView[]>> }) {
|
||||
this.id = opts.id;
|
||||
this._viewId = opts.viewId ?? 'wing-deck-view';
|
||||
this._deckProps = opts.deckProps ?? {};
|
||||
}
|
||||
|
||||
get deck(): Deck<MatrixView[]> | null {
|
||||
return this._deck;
|
||||
}
|
||||
|
||||
requestFinalize() {
|
||||
this._finalizeOnRemove = true;
|
||||
}
|
||||
|
||||
setProps(next: Partial<DeckProps<MatrixView[]>>) {
|
||||
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<MatrixView[]>);
|
||||
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<MatrixView[]>);
|
||||
return;
|
||||
}
|
||||
|
||||
this._deck = new Deck<MatrixView[]>({
|
||||
...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<DeckProps<MatrixView[]>>);
|
||||
} 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(); });
|
||||
}
|
||||
}
|
||||
}
|
||||
32
frontend/wing-gis-web/src/lib/map/mapConstants.ts
Normal file
32
frontend/wing-gis-web/src/lib/map/mapConstants.ts
Normal file
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
76
frontend/wing-gis-web/src/lib/map/mapCore.ts
Normal file
76
frontend/wing-gis-web/src/lib/map/mapCore.ts
Normal file
@ -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<string>();
|
||||
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;
|
||||
}
|
||||
5
frontend/wing-gis-web/src/main.tsx
Normal file
5
frontend/wing-gis-web/src/main.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />)
|
||||
47
frontend/wing-gis-web/src/services/aisApi.ts
Normal file
47
frontend/wing-gis-web/src/services/aisApi.ts
Normal file
@ -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<AisTarget[]> {
|
||||
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;
|
||||
}
|
||||
30
frontend/wing-gis-web/src/services/api.ts
Normal file
30
frontend/wing-gis-web/src/services/api.ts
Normal file
@ -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);
|
||||
}
|
||||
);
|
||||
26
frontend/wing-gis-web/src/services/vesselApi.ts
Normal file
26
frontend/wing-gis-web/src/services/vesselApi.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { api } from './api';
|
||||
import type { Vessel, VesselTrack, VesselSearchRequest, VesselCountBySource } from '../types/vessel';
|
||||
|
||||
/** 물표 목록 (소스 필터) */
|
||||
export const getVessels = (source?: string) =>
|
||||
api.get<Vessel[]>('/vessels', { params: source ? { source } : {} }).then(r => r.data);
|
||||
|
||||
/** MMSI로 상세 조회 */
|
||||
export const getVesselByMmsi = (mmsi: string) =>
|
||||
api.get<Vessel>(`/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<Vessel[]>('/vessels/bbox', { params: { minLon, minLat, maxLon, maxLat } }).then(r => r.data);
|
||||
|
||||
/** 항적 조회 */
|
||||
export const getVesselTracks = (mmsi: string, hours = 24) =>
|
||||
api.get<VesselTrack[]>(`/vessels/${mmsi}/tracks`, { params: { hours } }).then(r => r.data);
|
||||
|
||||
/** 소스별 카운트 */
|
||||
export const getVesselCounts = () =>
|
||||
api.get<VesselCountBySource>('/vessels/count').then(r => r.data);
|
||||
95
frontend/wing-gis-web/src/types/ais.ts
Normal file
95
frontend/wing-gis-web/src/types/ais.ts
Normal file
@ -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<SignalKindCode, string> = {
|
||||
'000020': '어선',
|
||||
'000021': '경비함정',
|
||||
'000022': '여객선',
|
||||
'000023': '화물선',
|
||||
'000024': '유조선',
|
||||
'000025': '관공선',
|
||||
'000027': '일반',
|
||||
'000028': '부이',
|
||||
};
|
||||
|
||||
export const SIGNAL_KIND_COLORS: Record<SignalKindCode, string> = {
|
||||
'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<string>(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;
|
||||
}
|
||||
36
frontend/wing-gis-web/src/types/layer.ts
Normal file
36
frontend/wing-gis-web/src/types/layer.ts
Normal file
@ -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;
|
||||
84
frontend/wing-gis-web/src/types/vessel.ts
Normal file
84
frontend/wing-gis-web/src/types/vessel.ts
Normal file
@ -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<VesselSource, string> = {
|
||||
'AIS': '#0055cc',
|
||||
'V-Pass': '#1a8a3a',
|
||||
'E-NAV': '#8833cc',
|
||||
'VTS': '#cc6600',
|
||||
'VTS-RADAR': '#cc3300',
|
||||
'AIR-AIS': '#0099aa',
|
||||
'VHF-DSC': '#d63030',
|
||||
};
|
||||
|
||||
/** 물표 소스별 아이콘 형태 */
|
||||
export const VESSEL_SHAPES: Record<VesselSource, 'triangle' | 'square' | 'diamond' | 'plane' | 'circle'> = {
|
||||
'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<string, number>;
|
||||
33
frontend/wing-gis-web/src/utils/coordinate.ts
Normal file
33
frontend/wing-gis-web/src/utils/coordinate.ts
Normal file
@ -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<number, string> = {
|
||||
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()}`;
|
||||
}
|
||||
31
frontend/wing-gis-web/src/utils/s52Colors.ts
Normal file
31
frontend/wing-gis-web/src/utils/s52Colors.ts
Normal file
@ -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<string, string> = {
|
||||
'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',
|
||||
};
|
||||
44
frontend/wing-gis-web/src/utils/vesselIcon.ts
Normal file
44
frontend/wing-gis-web/src/utils/vesselIcon.ts
Normal file
@ -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 `<svg width="${size}" height="${size + 4}" viewBox="0 0 14 18">
|
||||
<path d="M7 0L12 14L7 11L2 14Z" fill="${color}" stroke="#fff" stroke-width="0.8"/>
|
||||
</svg>`;
|
||||
case 'square':
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 14 14">
|
||||
<rect x="1" y="1" width="12" height="12" fill="rgba(255,255,255,0.5)" stroke="${color}" stroke-width="2" rx="1"/>
|
||||
</svg>`;
|
||||
case 'diamond':
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 14 14">
|
||||
<rect x="2" y="2" width="10" height="10" fill="rgba(255,255,255,0.5)" stroke="${color}" stroke-width="2" transform="rotate(45 7 7)"/>
|
||||
</svg>`;
|
||||
case 'plane':
|
||||
return `<svg width="${size + 2}" height="${size + 2}" viewBox="0 0 16 16">
|
||||
<text x="8" y="13" text-anchor="middle" font-size="14" fill="${color}">✈</text>
|
||||
</svg>`;
|
||||
default:
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 14 14">
|
||||
<circle cx="7" cy="7" r="5" fill="rgba(255,255,255,0.5)" stroke="${color}" stroke-width="2"/>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VHF-DSC 조난 전용 깜빡이 SVG
|
||||
*/
|
||||
export function createDistressSvg(size = 14): string {
|
||||
return `<svg width="${size}" height="${size + 4}" viewBox="0 0 14 18">
|
||||
<path d="M7 0L12 14L7 11L2 14Z" fill="#d63030" stroke="#fff" stroke-width="0.8">
|
||||
<animate attributeName="opacity" values="1;0.25;1" dur="0.7s" repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>`;
|
||||
}
|
||||
10
frontend/wing-gis-web/src/vite-env.d.ts
vendored
Normal file
10
frontend/wing-gis-web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE: string;
|
||||
readonly VITE_WS_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
28
frontend/wing-gis-web/tsconfig.app.json
Normal file
28
frontend/wing-gis-web/tsconfig.app.json
Normal file
@ -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"]
|
||||
}
|
||||
7
frontend/wing-gis-web/tsconfig.json
Normal file
7
frontend/wing-gis-web/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/wing-gis-web/tsconfig.node.json
Normal file
26
frontend/wing-gis-web/tsconfig.node.json
Normal file
@ -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"]
|
||||
}
|
||||
31
frontend/wing-gis-web/vite.config.ts
Normal file
31
frontend/wing-gis-web/vite.config.ts
Normal file
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
116
infra/docker-compose.yml
Normal file
116
infra/docker-compose.yml
Normal file
@ -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:
|
||||
284
infra/sql/001_init_schema.sql
Normal file
284
infra/sql/001_init_schema.sql
Normal file
@ -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');
|
||||
5
services/wing-gis-map/Dockerfile
Normal file
5
services/wing-gis-map/Dockerfile
Normal file
@ -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"]
|
||||
64
services/wing-gis-map/build.gradle
Normal file
64
services/wing-gis-map/build.gradle
Normal file
@ -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()
|
||||
}
|
||||
7
services/wing-gis-map/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
services/wing-gis-map/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -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
|
||||
248
services/wing-gis-map/gradlew
vendored
Executable file
248
services/wing-gis-map/gradlew
vendored
Executable file
@ -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" "$@"
|
||||
93
services/wing-gis-map/gradlew.bat
vendored
Normal file
93
services/wing-gis-map/gradlew.bat
vendored
Normal file
@ -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
|
||||
1
services/wing-gis-map/settings.gradle
Normal file
1
services/wing-gis-map/settings.gradle
Normal file
@ -0,0 +1 @@
|
||||
rootProject.name = 'wing-gis-map'
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<List<ChartDataset>> getDatasets(
|
||||
@RequestParam(defaultValue = "S-101") String product) {
|
||||
return ResponseEntity.ok(datasetRepo.findByProductOrderByCellNameAsc(product));
|
||||
}
|
||||
|
||||
@Operation(summary = "셀명으로 데이터셋 조회")
|
||||
@GetMapping("/{cellName}")
|
||||
public ResponseEntity<ChartDataset> 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<List<ChartFeature>> 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<List<ChartFeature>> getDatasetFeatures(@PathVariable String cellName) {
|
||||
var ds = datasetRepo.findByCellNameAndStatus(cellName, "CURRENT")
|
||||
.orElseThrow(() -> new IllegalArgumentException("Dataset not found"));
|
||||
return ResponseEntity.ok(featureRepo.findByDatasetId(ds.getId()));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ChartDataset, Long> {
|
||||
|
||||
Optional<ChartDataset> findByCellNameAndStatus(String cellName, String status);
|
||||
|
||||
List<ChartDataset> 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<ChartDataset> findByBbox(
|
||||
@Param("minLon") double minLon, @Param("minLat") double minLat,
|
||||
@Param("maxLon") double maxLon, @Param("maxLat") double maxLat);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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<ChartFeature, Long> {
|
||||
|
||||
List<ChartFeature> findByDatasetId(Long datasetId);
|
||||
|
||||
List<ChartFeature> 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<ChartFeature> 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user