Merge pull request 'feat: React 19 SPA Dashboard Phase 1 + 안전 배포 시스템' (#10) from feature/dashboard-phase-1 into develop
Reviewed-on: #10
This commit is contained in:
커밋
76f71fb374
@ -4,6 +4,13 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: 192.168.1.18
|
||||||
|
DEPLOY_PORT: 32023
|
||||||
|
APP_DIR: /home/apps/signal-batch
|
||||||
|
APP_PORT: 18090
|
||||||
|
CONTEXT_PATH: /signal-batch
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -14,7 +21,7 @@ jobs:
|
|||||||
- name: Install JDK 17 + Maven
|
- name: Install JDK 17 + Maven
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
apt-get install -y -qq openjdk-17-jdk-headless maven openssh-client > /dev/null 2>&1
|
apt-get install -y -qq openjdk-17-jdk-headless maven openssh-client jq > /dev/null 2>&1
|
||||||
java -version
|
java -version
|
||||||
mvn --version
|
mvn --version
|
||||||
|
|
||||||
@ -32,41 +39,81 @@ jobs:
|
|||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "$DEPLOY_KEY" > ~/.ssh/id_deploy
|
echo "$DEPLOY_KEY" > ~/.ssh/id_deploy
|
||||||
chmod 600 ~/.ssh/id_deploy
|
chmod 600 ~/.ssh/id_deploy
|
||||||
ssh-keyscan -p 32023 192.168.1.18 >> ~/.ssh/known_hosts 2>/dev/null || true
|
ssh-keyscan -p $DEPLOY_PORT $DEPLOY_HOST >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||||
|
|
||||||
SSH_CMD="ssh -p 32023 -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no root@192.168.1.18"
|
SSH_CMD="ssh -p $DEPLOY_PORT -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no root@$DEPLOY_HOST"
|
||||||
SCP_CMD="scp -P 32023 -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no"
|
SCP_CMD="scp -P $DEPLOY_PORT -i ~/.ssh/id_deploy -o StrictHostKeyChecking=no"
|
||||||
|
|
||||||
# JAR 전송
|
# JAR 전송
|
||||||
echo "=== Uploading JAR ==="
|
echo "=== Uploading JAR ==="
|
||||||
$SCP_CMD target/vessel-batch-aggregation.jar root@192.168.1.18:/home/apps/signal-batch/vessel-batch-aggregation.jar.new
|
$SCP_CMD target/vessel-batch-aggregation.jar root@$DEPLOY_HOST:$APP_DIR/vessel-batch-aggregation.jar.new
|
||||||
|
|
||||||
# 원자적 교체 + 서비스 재시작
|
# 안전 배포: 활성 Job 확인 → 종료 → JAR 교체 → 기동
|
||||||
echo "=== Deploying ==="
|
echo "=== Safe Deploy ==="
|
||||||
$SSH_CMD bash -s << 'DEPLOY'
|
$SSH_CMD bash -s << 'DEPLOY'
|
||||||
set -e
|
set -e
|
||||||
APP_DIR=/home/apps/signal-batch
|
APP_DIR=/home/apps/signal-batch
|
||||||
|
BASE_URL="http://localhost:18090/signal-batch"
|
||||||
|
SAFE_COUNT=0
|
||||||
|
MAX_WAIT=300
|
||||||
|
|
||||||
# 기존 JAR 백업
|
# 1단계: 활성 Job 없음 3초 연속 확인
|
||||||
|
echo "--- Waiting for running jobs to complete ---"
|
||||||
|
for i in $(seq 1 $MAX_WAIT); do
|
||||||
|
RESPONSE=$(curl -sf "$BASE_URL/admin/batch/job/running" 2>/dev/null || echo "UNAVAILABLE")
|
||||||
|
|
||||||
|
if [ "$RESPONSE" = "UNAVAILABLE" ]; then
|
||||||
|
echo "App not responding — proceeding with deploy"
|
||||||
|
SAFE_COUNT=3
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUNNING=$(echo "$RESPONSE" | jq 'length' 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
if [ "$RUNNING" = "0" ]; then
|
||||||
|
SAFE_COUNT=$((SAFE_COUNT + 1))
|
||||||
|
echo "No running jobs ($SAFE_COUNT/3)"
|
||||||
|
if [ $SAFE_COUNT -ge 3 ]; then
|
||||||
|
echo "Safe to deploy!"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
[ $SAFE_COUNT -gt 0 ] && echo "Active job detected — resetting count"
|
||||||
|
SAFE_COUNT=0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $SAFE_COUNT -lt 3 ]; then
|
||||||
|
echo "WARNING: Timeout waiting for jobs. Proceeding anyway."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2단계: 서비스 중지 (graceful shutdown)
|
||||||
|
echo "--- Stopping service ---"
|
||||||
|
systemctl stop signal-batch || true
|
||||||
|
# systemd TimeoutStopSec=180 에서 graceful shutdown 완료 대기
|
||||||
|
|
||||||
|
# 3단계: JAR 교체
|
||||||
|
echo "--- Replacing JAR ---"
|
||||||
|
mkdir -p $APP_DIR/backup
|
||||||
if [ -f $APP_DIR/vessel-batch-aggregation.jar ]; then
|
if [ -f $APP_DIR/vessel-batch-aggregation.jar ]; then
|
||||||
cp $APP_DIR/vessel-batch-aggregation.jar $APP_DIR/backup/vessel-batch-aggregation.jar.$(date +%Y%m%d-%H%M%S)
|
cp $APP_DIR/vessel-batch-aggregation.jar $APP_DIR/backup/vessel-batch-aggregation.jar.$(date +%Y%m%d-%H%M%S)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 원자적 교체
|
|
||||||
mv $APP_DIR/vessel-batch-aggregation.jar.new $APP_DIR/vessel-batch-aggregation.jar
|
mv $APP_DIR/vessel-batch-aggregation.jar.new $APP_DIR/vessel-batch-aggregation.jar
|
||||||
|
restorecon -v $APP_DIR/vessel-batch-aggregation.jar 2>/dev/null || true
|
||||||
|
|
||||||
# 백업 정리 (최근 5개만 유지)
|
# 백업 정리 (최근 5개만 유지)
|
||||||
ls -t $APP_DIR/backup/vessel-batch-aggregation.jar.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
|
ls -t $APP_DIR/backup/vessel-batch-aggregation.jar.* 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true
|
||||||
|
|
||||||
# 서비스 재시작
|
# 4단계: 서비스 기동
|
||||||
systemctl restart signal-batch
|
echo "--- Starting service ---"
|
||||||
echo "Service restarted, waiting for startup..."
|
systemctl start signal-batch
|
||||||
|
|
||||||
# 시작 확인 (최대 90초 — 48GB 힙 AlwaysPreTouch)
|
# 5단계: 기동 확인 (최대 90초 — 64GB 힙 AlwaysPreTouch)
|
||||||
for i in $(seq 1 90); do
|
for i in $(seq 1 90); do
|
||||||
if curl -sf http://localhost:18090/actuator/health > /dev/null 2>&1; then
|
if curl -sf "$BASE_URL/actuator/health/liveness" > /dev/null 2>&1; then
|
||||||
echo "Service started successfully (${i}s)"
|
echo "Service started successfully (${i}s)"
|
||||||
curl -s http://localhost:18090/actuator/health
|
curl -s "$BASE_URL/actuator/health"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|||||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -39,6 +39,14 @@ secrets/
|
|||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
.claude/CLAUDE.local.md
|
.claude/CLAUDE.local.md
|
||||||
|
|
||||||
|
# Frontend build tools
|
||||||
|
frontend/node/
|
||||||
|
frontend/node_modules/
|
||||||
|
|
||||||
|
# React build output (CI가 빌드, 로컬 빌드 산출물 제외)
|
||||||
|
src/main/resources/static/index.html
|
||||||
|
src/main/resources/static/assets/
|
||||||
|
|
||||||
# Project-specific
|
# Project-specific
|
||||||
logs/
|
logs/
|
||||||
ship_img/
|
ship_img/
|
||||||
|
|||||||
@ -40,12 +40,11 @@ Environment=JAVA_OPTS="\
|
|||||||
|
|
||||||
ExecStart=/home/apps/jdk/jdk-17.0.14+7/bin/java \
|
ExecStart=/home/apps/jdk/jdk-17.0.14+7/bin/java \
|
||||||
${JAVA_OPTS} \
|
${JAVA_OPTS} \
|
||||||
-jar /home/apps/signal-batch/vessel-batch-aggregation.jar \
|
-jar /home/apps/signal-batch/vessel-batch-aggregation.jar
|
||||||
--spring.profiles.active=${SPRING_PROFILES_ACTIVE}
|
|
||||||
|
|
||||||
# Graceful shutdown (SIGTERM → Spring Boot shutdown hook)
|
# Graceful shutdown (SIGTERM → Spring Boot shutdown hook → @PreDestroy Job 완료 대기)
|
||||||
ExecStop=/bin/kill -TERM $MAINPID
|
ExecStop=/bin/kill -TERM $MAINPID
|
||||||
TimeoutStopSec=60
|
TimeoutStopSec=180
|
||||||
KillMode=mixed
|
KillMode=mixed
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
|
|
||||||
|
|||||||
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
node/
|
||||||
|
dist/
|
||||||
|
*.local
|
||||||
|
.env*
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Signal Batch Dashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4317
frontend/package-lock.json
generated
Normal file
4317
frontend/package-lock.json
generated
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "signal-batch-dashboard",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
|
"react-router-dom": "^7.13.0",
|
||||||
|
"recharts": "^2.15.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"@types/node": "^24.10.1",
|
||||||
|
"@types/react": "^19.2.7",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^5.1.1",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.24",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.0",
|
||||||
|
"vite": "^7.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { lazy } from 'react'
|
||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { ThemeProvider } from './contexts/ThemeContext.tsx'
|
||||||
|
import { I18nProvider } from './i18n/I18nContext.tsx'
|
||||||
|
import AppLayout from './components/layout/AppLayout.tsx'
|
||||||
|
|
||||||
|
const Dashboard = lazy(() => import('./pages/Dashboard.tsx'))
|
||||||
|
const JobMonitor = lazy(() => import('./pages/JobMonitor.tsx'))
|
||||||
|
|
||||||
|
const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<I18nProvider>
|
||||||
|
<ThemeProvider>
|
||||||
|
<BrowserRouter basename={BASE_URL}>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppLayout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="jobs" element={<JobMonitor />} />
|
||||||
|
{/* Phase 2+ 페이지 추가 예정 */}
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
41
frontend/src/api/batchApi.ts
Normal file
41
frontend/src/api/batchApi.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { fetchJson } from './httpClient.ts'
|
||||||
|
import type {
|
||||||
|
BatchStatistics,
|
||||||
|
DailyStats,
|
||||||
|
JobExecution,
|
||||||
|
RunningJob,
|
||||||
|
StepExecution,
|
||||||
|
} from './types.ts'
|
||||||
|
|
||||||
|
export const batchApi = {
|
||||||
|
getStatistics(days = 7): Promise<BatchStatistics> {
|
||||||
|
return fetchJson(`/admin/batch/statistics?days=${days}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getJobHistory(jobName?: string, limit = 50): Promise<JobExecution[]> {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (jobName) params.set('jobName', jobName)
|
||||||
|
params.set('limit', String(limit))
|
||||||
|
return fetchJson(`/admin/batch/job/history?${params}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getRunningJobs(): Promise<RunningJob[]> {
|
||||||
|
return fetchJson('/admin/batch/job/running')
|
||||||
|
},
|
||||||
|
|
||||||
|
getFailedJobs(hoursBack = 24): Promise<JobExecution[]> {
|
||||||
|
return fetchJson(`/admin/batch/job/failed?hoursBack=${hoursBack}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getStepDetails(executionId: number): Promise<StepExecution[]> {
|
||||||
|
return fetchJson(`/admin/batch/step/details/${executionId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
getDailyStats(): Promise<DailyStats> {
|
||||||
|
return fetchJson('/admin/batch/daily-stats')
|
||||||
|
},
|
||||||
|
|
||||||
|
getRecentJobs(count = 10): Promise<JobExecution[]> {
|
||||||
|
return fetchJson(`/admin/jobs/recent?count=${count}`)
|
||||||
|
},
|
||||||
|
}
|
||||||
17
frontend/src/api/httpClient.ts
Normal file
17
frontend/src/api/httpClient.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const BASE_URL = import.meta.env.VITE_BASE_URL || '/signal-batch'
|
||||||
|
|
||||||
|
export async function fetchJson<T>(path: string): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`)
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postJson<T>(path: string, body?: unknown): Promise<T> {
|
||||||
|
const res = await fetch(`${BASE_URL}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
28
frontend/src/api/monitorApi.ts
Normal file
28
frontend/src/api/monitorApi.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { fetchJson } from './httpClient.ts'
|
||||||
|
import type { CacheStats, MetricsSummary, ProcessingDelay } from './types.ts'
|
||||||
|
|
||||||
|
export const monitorApi = {
|
||||||
|
getDelay(): Promise<ProcessingDelay> {
|
||||||
|
return fetchJson('/monitor/delay')
|
||||||
|
},
|
||||||
|
|
||||||
|
getMetricsSummary(): Promise<MetricsSummary> {
|
||||||
|
return fetchJson('/admin/metrics/summary')
|
||||||
|
},
|
||||||
|
|
||||||
|
getCacheStats(): Promise<CacheStats> {
|
||||||
|
return fetchJson('/api/monitoring/cache/stats')
|
||||||
|
},
|
||||||
|
|
||||||
|
getDailyCacheStatus(): Promise<Record<string, unknown>> {
|
||||||
|
return fetchJson('/api/websocket/daily-cache')
|
||||||
|
},
|
||||||
|
|
||||||
|
getThroughput(): Promise<Record<string, unknown>> {
|
||||||
|
return fetchJson('/monitor/throughput')
|
||||||
|
},
|
||||||
|
|
||||||
|
getHaeguStats(): Promise<Record<string, unknown>[]> {
|
||||||
|
return fetchJson('/admin/haegu/stats')
|
||||||
|
},
|
||||||
|
}
|
||||||
126
frontend/src/api/types.ts
Normal file
126
frontend/src/api/types.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
/* Batch API 응답 타입 */
|
||||||
|
|
||||||
|
export interface BatchStatistics {
|
||||||
|
period: {
|
||||||
|
start: string
|
||||||
|
end: string
|
||||||
|
days: number
|
||||||
|
}
|
||||||
|
summary: {
|
||||||
|
totalExecutions: number
|
||||||
|
successful: number
|
||||||
|
failed: number
|
||||||
|
successRate: number
|
||||||
|
totalRecordsProcessed: number
|
||||||
|
avgRecordsPerExecution: number
|
||||||
|
totalProcessingTimeSeconds: number
|
||||||
|
avgProcessingTimeSeconds: number
|
||||||
|
}
|
||||||
|
byJob: {
|
||||||
|
executionCounts: Record<string, number>
|
||||||
|
processingTimes: Record<string, number>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobExecution {
|
||||||
|
jobName: string
|
||||||
|
executionId: number
|
||||||
|
startTime: string
|
||||||
|
endTime: string | null
|
||||||
|
status: string
|
||||||
|
exitCode: string
|
||||||
|
exitDescription: string
|
||||||
|
durationSeconds: number
|
||||||
|
totalRead: number
|
||||||
|
totalWrite: number
|
||||||
|
totalSkip: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StepExecution {
|
||||||
|
stepName: string
|
||||||
|
status: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string | null
|
||||||
|
readCount: number
|
||||||
|
writeCount: number
|
||||||
|
filterCount: number
|
||||||
|
skipCount: number
|
||||||
|
commitCount: number
|
||||||
|
rollbackCount: number
|
||||||
|
durationSeconds: number
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
dailyStats: DailyStat[]
|
||||||
|
statusSummary: {
|
||||||
|
completed: number
|
||||||
|
failed: number
|
||||||
|
stopped: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStat {
|
||||||
|
date: string
|
||||||
|
totalProcessed: number
|
||||||
|
vesselJobs: number
|
||||||
|
trackJobs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Monitor API 응답 타입 */
|
||||||
|
|
||||||
|
export interface ProcessingDelay {
|
||||||
|
delayMinutes: number
|
||||||
|
status: 'NORMAL' | 'WARNING' | 'CRITICAL' | 'ERROR'
|
||||||
|
aisLatestTime: string
|
||||||
|
queryLatestTime: string
|
||||||
|
recentAisCount: number
|
||||||
|
processedTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetricsSummary {
|
||||||
|
totalJobsCompleted: number
|
||||||
|
totalRecordsProcessed: number
|
||||||
|
averageThroughput: number
|
||||||
|
errorRate: number
|
||||||
|
memory: {
|
||||||
|
used: number
|
||||||
|
total: number
|
||||||
|
max: number
|
||||||
|
}
|
||||||
|
threads: number
|
||||||
|
processing: {
|
||||||
|
recordsPerSecond: number
|
||||||
|
}
|
||||||
|
database: {
|
||||||
|
activeConnections: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheStats {
|
||||||
|
enabled: boolean
|
||||||
|
currentSize: number
|
||||||
|
estimatedSize: number
|
||||||
|
hitRate: string
|
||||||
|
missRate: string
|
||||||
|
hitCount: number
|
||||||
|
missCount: number
|
||||||
|
totalRequests: number
|
||||||
|
efficiency: string
|
||||||
|
status: 'EXCELLENT' | 'GOOD' | 'FAIR' | 'POOR' | 'EMPTY'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RunningJob {
|
||||||
|
jobName: string
|
||||||
|
executionId: number
|
||||||
|
startTime: string
|
||||||
|
status: string
|
||||||
|
parameters: Record<string, unknown>
|
||||||
|
steps: {
|
||||||
|
stepName: string
|
||||||
|
status: string
|
||||||
|
readCount: number
|
||||||
|
writeCount: number
|
||||||
|
skipCount: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
58
frontend/src/components/charts/BarChart.tsx
Normal file
58
frontend/src/components/charts/BarChart.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
BarChart as RechartsBarChart,
|
||||||
|
Bar,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
interface BarChartProps {
|
||||||
|
data: Record<string, unknown>[]
|
||||||
|
dataKey: string
|
||||||
|
xKey: string
|
||||||
|
height?: number
|
||||||
|
color?: string
|
||||||
|
label?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChart({
|
||||||
|
data,
|
||||||
|
dataKey,
|
||||||
|
xKey,
|
||||||
|
height = 240,
|
||||||
|
color = 'var(--sb-primary)',
|
||||||
|
label,
|
||||||
|
}: BarChartProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <div className="mb-2 text-sm font-medium text-muted">{label}</div>}
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<RechartsBarChart data={data} margin={{ top: 4, right: 4, bottom: 4, left: 4 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--sb-border)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey={xKey}
|
||||||
|
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
|
||||||
|
axisLine={{ stroke: 'var(--sb-border)' }}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
|
||||||
|
axisLine={false}
|
||||||
|
tickLine={false}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'var(--sb-surface)',
|
||||||
|
border: '1px solid var(--sb-border)',
|
||||||
|
borderRadius: 'var(--sb-radius)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar dataKey={dataKey} fill={color} radius={[4, 4, 0, 0]} />
|
||||||
|
</RechartsBarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
frontend/src/components/charts/MetricCard.tsx
Normal file
48
frontend/src/components/charts/MetricCard.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
title: string
|
||||||
|
value: string | number
|
||||||
|
subtitle?: string
|
||||||
|
icon?: ReactNode
|
||||||
|
trend?: 'up' | 'down' | 'neutral'
|
||||||
|
trendValue?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MetricCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
subtitle,
|
||||||
|
icon,
|
||||||
|
trend,
|
||||||
|
trendValue,
|
||||||
|
}: MetricCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="sb-card-header">{title}</div>
|
||||||
|
<div className="sb-card-value">{value}</div>
|
||||||
|
</div>
|
||||||
|
{icon && (
|
||||||
|
<div className="rounded-lg bg-surface-hover p-2 text-muted">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{(subtitle || trendValue) && (
|
||||||
|
<div className="sb-card-footer flex items-center gap-2">
|
||||||
|
{trendValue && (
|
||||||
|
<span className={
|
||||||
|
trend === 'up' ? 'text-success' :
|
||||||
|
trend === 'down' ? 'text-danger' : 'text-muted'
|
||||||
|
}>
|
||||||
|
{trend === 'up' ? '\u2191' : trend === 'down' ? '\u2193' : ''} {trendValue}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subtitle && <span>{subtitle}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
127
frontend/src/components/common/DataTable.tsx
Normal file
127
frontend/src/components/common/DataTable.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { useState, useMemo, type ReactNode } from 'react'
|
||||||
|
import { useI18n } from '../../hooks/useI18n.ts'
|
||||||
|
|
||||||
|
export interface Column<T> {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
render?: (row: T) => ReactNode
|
||||||
|
sortable?: boolean
|
||||||
|
align?: 'left' | 'center' | 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataTableProps<T> {
|
||||||
|
columns: Column<T>[]
|
||||||
|
data: T[]
|
||||||
|
keyExtractor: (row: T) => string | number
|
||||||
|
onRowClick?: (row: T) => void
|
||||||
|
emptyMessage?: string
|
||||||
|
pageSize?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DataTable<T>({
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
keyExtractor,
|
||||||
|
onRowClick,
|
||||||
|
emptyMessage,
|
||||||
|
pageSize = 20,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
if (!sortKey) return data
|
||||||
|
return [...data].sort((a, b) => {
|
||||||
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
|
if (av == null || bv == null) return 0
|
||||||
|
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
||||||
|
return sortAsc ? cmp : -cmp
|
||||||
|
})
|
||||||
|
}, [data, sortKey, sortAsc])
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(sorted.length / pageSize)
|
||||||
|
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize)
|
||||||
|
|
||||||
|
const handleSort = (key: string) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortAsc(!sortAsc)
|
||||||
|
} else {
|
||||||
|
setSortKey(key)
|
||||||
|
setSortAsc(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="sb-table-wrapper">
|
||||||
|
<table className="sb-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map(col => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
onClick={col.sortable !== false ? () => handleSort(col.key) : undefined}
|
||||||
|
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
{sortKey === col.key && (sortAsc ? ' \u25B2' : ' \u25BC')}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={columns.length} className="sb-table-empty">
|
||||||
|
{emptyMessage ?? t('common.noData')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paged.map(row => (
|
||||||
|
<tr
|
||||||
|
key={keyExtractor(row)}
|
||||||
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||||
|
style={{ cursor: onRowClick ? 'pointer' : 'default' }}
|
||||||
|
>
|
||||||
|
{columns.map(col => (
|
||||||
|
<td key={col.key} style={{ textAlign: col.align ?? 'left' }}>
|
||||||
|
{col.render
|
||||||
|
? col.render(row)
|
||||||
|
: String((row as Record<string, unknown>)[col.key] ?? '-')}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
||||||
|
<span>
|
||||||
|
{sorted.length}{t('common.items')} {t('common.of')} {page * pageSize + 1}-{Math.min((page + 1) * pageSize, sorted.length)}
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
|
disabled={page === 0}
|
||||||
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('common.prev')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
|
disabled={page >= totalPages - 1}
|
||||||
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('common.next')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
frontend/src/components/common/LoadingSpinner.tsx
Normal file
7
frontend/src/components/common/LoadingSpinner.tsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default function LoadingSpinner() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-border border-t-primary" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
frontend/src/components/common/StatusBadge.tsx
Normal file
25
frontend/src/components/common/StatusBadge.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const STATUS_MAP: Record<string, string> = {
|
||||||
|
COMPLETED: 'sb-badge-completed',
|
||||||
|
FAILED: 'sb-badge-failed',
|
||||||
|
RUNNING: 'sb-badge-running',
|
||||||
|
STARTING: 'sb-badge-running',
|
||||||
|
STARTED: 'sb-badge-running',
|
||||||
|
STOPPING: 'sb-badge-stopped',
|
||||||
|
STOPPED: 'sb-badge-stopped',
|
||||||
|
ABANDONED: 'sb-badge-abandoned',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||||
|
const cls = STATUS_MAP[status] ?? 'sb-badge-unknown'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`sb-badge ${cls}`}>
|
||||||
|
<span className="sb-badge-dot" />
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
frontend/src/components/common/TimeRangeSelector.tsx
Normal file
37
frontend/src/components/common/TimeRangeSelector.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { useI18n } from '../../hooks/useI18n.ts'
|
||||||
|
import type { TranslationKey } from '../../i18n/I18nContext.tsx'
|
||||||
|
|
||||||
|
interface TimeRangeSelectorProps {
|
||||||
|
value: number
|
||||||
|
onChange: (days: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_OPTIONS: { labelKey: TranslationKey; value: number }[] = [
|
||||||
|
{ labelKey: 'range.1d', value: 1 },
|
||||||
|
{ labelKey: 'range.3d', value: 3 },
|
||||||
|
{ labelKey: 'range.7d', value: 7 },
|
||||||
|
{ labelKey: 'range.14d', value: 14 },
|
||||||
|
{ labelKey: 'range.30d', value: 30 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) {
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{RANGE_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => onChange(opt.value)}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
value === opt.value
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'text-muted hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(opt.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
frontend/src/components/layout/AppLayout.tsx
Normal file
17
frontend/src/components/layout/AppLayout.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import Navbar from './Navbar.tsx'
|
||||||
|
import LoadingSpinner from '../common/LoadingSpinner.tsx'
|
||||||
|
|
||||||
|
export default function AppLayout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
|
<Navbar />
|
||||||
|
<main className="mx-auto max-w-7xl px-4 py-6">
|
||||||
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/components/layout/Navbar.tsx
Normal file
66
frontend/src/components/layout/Navbar.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { useTheme } from '../../hooks/useTheme.ts'
|
||||||
|
import { useI18n } from '../../hooks/useI18n.ts'
|
||||||
|
import type { TranslationKey } from '../../i18n/I18nContext.tsx'
|
||||||
|
|
||||||
|
const NAV_ITEMS: { path: string; labelKey: TranslationKey }[] = [
|
||||||
|
{ path: '/', labelKey: 'nav.dashboard' },
|
||||||
|
{ path: '/jobs', labelKey: 'nav.jobs' },
|
||||||
|
{ path: '/pipeline', labelKey: 'nav.pipeline' },
|
||||||
|
{ path: '/api-explorer', labelKey: 'nav.apiExplorer' },
|
||||||
|
{ path: '/abnormal', labelKey: 'nav.abnormal' },
|
||||||
|
{ path: '/area-stats', labelKey: 'nav.areaStats' },
|
||||||
|
{ path: '/metrics', labelKey: 'nav.metrics' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const { locale, toggleLocale, t } = useI18n()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="sb-navbar">
|
||||||
|
<a href="." className="sb-navbar-brand">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||||
|
<path d="M2 17l10 5 10-5" />
|
||||||
|
<path d="M2 12l10 5 10-5" />
|
||||||
|
</svg>
|
||||||
|
Signal Batch
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul className="sb-navbar-nav">
|
||||||
|
{NAV_ITEMS.map(item => (
|
||||||
|
<li key={item.path}>
|
||||||
|
<NavLink
|
||||||
|
to={item.path}
|
||||||
|
end={item.path === '/'}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`sb-nav-link ${isActive ? 'sb-nav-link-active' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(item.labelKey)}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={toggleLocale} className="sb-theme-toggle" title={t('common.langToggle')}>
|
||||||
|
<span className="text-xs font-bold">{locale === 'ko' ? 'EN' : 'KO'}</span>
|
||||||
|
</button>
|
||||||
|
<button onClick={toggleTheme} className="sb-theme-toggle" title={t('common.themeToggle')}>
|
||||||
|
{theme === 'light' ? (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="5" />
|
||||||
|
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/contexts/ThemeContext.tsx
Normal file
40
frontend/src/contexts/ThemeContext.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark'
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
theme: Theme
|
||||||
|
toggleTheme: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeContext = createContext<ThemeContextValue>({
|
||||||
|
theme: 'light',
|
||||||
|
toggleTheme: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'sb-theme'
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'dark' || stored === 'light') return stored
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(getInitialTheme)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme)
|
||||||
|
}, [theme])
|
||||||
|
|
||||||
|
const toggleTheme = useCallback(() => {
|
||||||
|
setTheme(prev => (prev === 'light' ? 'dark' : 'light'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext value={{ theme, toggleTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
frontend/src/hooks/useI18n.ts
Normal file
6
frontend/src/hooks/useI18n.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { I18nContext } from '../i18n/I18nContext.tsx'
|
||||||
|
|
||||||
|
export function useI18n() {
|
||||||
|
return useContext(I18nContext)
|
||||||
|
}
|
||||||
51
frontend/src/hooks/usePoller.ts
Normal file
51
frontend/src/hooks/usePoller.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주기적 폴링 훅
|
||||||
|
* - 마운트 시 즉시 1회 실행 후 intervalMs 주기로 반복
|
||||||
|
* - 탭 비활성(document.hidden) 시 자동 중단, 활성화 시 즉시 재개
|
||||||
|
* - deps 변경 시 타이머 재설정
|
||||||
|
*/
|
||||||
|
export function usePoller(
|
||||||
|
fn: () => Promise<void> | void,
|
||||||
|
intervalMs: number,
|
||||||
|
deps: unknown[] = [],
|
||||||
|
) {
|
||||||
|
const fnRef = useRef(fn)
|
||||||
|
fnRef.current = fn
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const run = () => { fnRef.current() }
|
||||||
|
|
||||||
|
const start = () => {
|
||||||
|
run()
|
||||||
|
timer = setInterval(run, intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.hidden) {
|
||||||
|
stop()
|
||||||
|
} else {
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
start()
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stop()
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [intervalMs, ...deps])
|
||||||
|
}
|
||||||
6
frontend/src/hooks/useTheme.ts
Normal file
6
frontend/src/hooks/useTheme.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { useContext } from 'react'
|
||||||
|
import { ThemeContext } from '../contexts/ThemeContext.tsx'
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext)
|
||||||
|
}
|
||||||
56
frontend/src/i18n/I18nContext.tsx
Normal file
56
frontend/src/i18n/I18nContext.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { createContext, useCallback, useEffect, useState, type ReactNode } from 'react'
|
||||||
|
import ko from './ko.ts'
|
||||||
|
import en from './en.ts'
|
||||||
|
|
||||||
|
export type Locale = 'ko' | 'en'
|
||||||
|
export type TranslationKey = keyof typeof ko
|
||||||
|
|
||||||
|
const TRANSLATIONS: Record<Locale, Record<string, string>> = { ko, en }
|
||||||
|
const STORAGE_KEY = 'sb-locale'
|
||||||
|
|
||||||
|
function getInitialLocale(): Locale {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored === 'ko' || stored === 'en') return stored
|
||||||
|
const browserLang = navigator.language.slice(0, 2)
|
||||||
|
return browserLang === 'ko' ? 'ko' : 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface I18nContextValue {
|
||||||
|
locale: Locale
|
||||||
|
setLocale: (locale: Locale) => void
|
||||||
|
toggleLocale: () => void
|
||||||
|
t: (key: TranslationKey) => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const I18nContext = createContext<I18nContextValue>({
|
||||||
|
locale: 'ko',
|
||||||
|
setLocale: () => {},
|
||||||
|
toggleLocale: () => {},
|
||||||
|
t: (key) => key,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [locale, setLocaleState] = useState<Locale>(getInitialLocale)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem(STORAGE_KEY, locale)
|
||||||
|
document.documentElement.lang = locale
|
||||||
|
}, [locale])
|
||||||
|
|
||||||
|
const setLocale = useCallback((l: Locale) => setLocaleState(l), [])
|
||||||
|
|
||||||
|
const toggleLocale = useCallback(() => {
|
||||||
|
setLocaleState(prev => (prev === 'ko' ? 'en' : 'ko'))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const t = useCallback(
|
||||||
|
(key: TranslationKey): string => TRANSLATIONS[locale][key] ?? key,
|
||||||
|
[locale],
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<I18nContext value={{ locale, setLocale, toggleLocale, t }}>
|
||||||
|
{children}
|
||||||
|
</I18nContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
frontend/src/i18n/en.ts
Normal file
80
frontend/src/i18n/en.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const en = {
|
||||||
|
// Navigation
|
||||||
|
'nav.dashboard': 'Dashboard',
|
||||||
|
'nav.jobs': 'Job Monitor',
|
||||||
|
'nav.pipeline': 'Pipeline',
|
||||||
|
'nav.apiExplorer': 'API Explorer',
|
||||||
|
'nav.abnormal': 'Abnormal',
|
||||||
|
'nav.areaStats': 'Area Stats',
|
||||||
|
'nav.metrics': 'Metrics',
|
||||||
|
|
||||||
|
// Common
|
||||||
|
'common.loading': 'Loading...',
|
||||||
|
'common.noData': 'No data available',
|
||||||
|
'common.close': 'Close',
|
||||||
|
'common.prev': 'Prev',
|
||||||
|
'common.next': 'Next',
|
||||||
|
'common.items': '',
|
||||||
|
'common.of': 'of',
|
||||||
|
'common.day': 'd',
|
||||||
|
'common.min': 'min',
|
||||||
|
'common.sec': 'sec',
|
||||||
|
'common.themeToggle': 'Toggle theme',
|
||||||
|
'common.langToggle': 'Switch language',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.title': 'Dashboard',
|
||||||
|
'dashboard.totalExec': 'Total Executions',
|
||||||
|
'dashboard.successRate': 'Success Rate',
|
||||||
|
'dashboard.avgDuration': 'Avg Duration',
|
||||||
|
'dashboard.totalProcessed': 'Total Processed',
|
||||||
|
'dashboard.periodBasis': 'd basis',
|
||||||
|
'dashboard.avgPerJob': '/job avg',
|
||||||
|
'dashboard.runningJobs': 'Running Jobs',
|
||||||
|
'dashboard.noRunningJobs': 'No running jobs',
|
||||||
|
'dashboard.delay': 'Processing Delay',
|
||||||
|
'dashboard.delayMin': 'min delay',
|
||||||
|
'dashboard.aisLatest': 'AIS Latest',
|
||||||
|
'dashboard.processLatest': 'Process Latest',
|
||||||
|
'dashboard.aisReceived': 'AIS Received',
|
||||||
|
'dashboard.tileProcessed': 'Tiles Processed',
|
||||||
|
'dashboard.systemMetrics': 'System Metrics',
|
||||||
|
'dashboard.memory': 'Memory',
|
||||||
|
'dashboard.threads': 'Threads',
|
||||||
|
'dashboard.dbConn': 'DB Connections',
|
||||||
|
'dashboard.recordsSec': 'Records/sec',
|
||||||
|
'dashboard.cacheStatus': 'Cache Status',
|
||||||
|
'dashboard.hitRate': 'Hit Rate',
|
||||||
|
'dashboard.size': 'Size',
|
||||||
|
'dashboard.hits': 'Hits',
|
||||||
|
'dashboard.misses': 'Misses',
|
||||||
|
'dashboard.dailyVolume': 'Daily Processing Volume',
|
||||||
|
|
||||||
|
// Job Monitor
|
||||||
|
'jobs.title': 'Job Monitor',
|
||||||
|
'jobs.all': 'All',
|
||||||
|
'jobs.track5min': 'Track (5min)',
|
||||||
|
'jobs.hourly': 'Hourly',
|
||||||
|
'jobs.daily': 'Daily',
|
||||||
|
'jobs.status': 'Status',
|
||||||
|
'jobs.job': 'Job',
|
||||||
|
'jobs.id': 'ID',
|
||||||
|
'jobs.start': 'Start',
|
||||||
|
'jobs.duration': 'Duration',
|
||||||
|
'jobs.read': 'Read',
|
||||||
|
'jobs.write': 'Write',
|
||||||
|
'jobs.skip': 'Skip',
|
||||||
|
'jobs.stepDetails': 'Step Details',
|
||||||
|
'jobs.step': 'Step',
|
||||||
|
'jobs.commits': 'Commits',
|
||||||
|
'jobs.errors': 'Errors',
|
||||||
|
|
||||||
|
// Time Range
|
||||||
|
'range.1d': '1D',
|
||||||
|
'range.3d': '3D',
|
||||||
|
'range.7d': '7D',
|
||||||
|
'range.14d': '14D',
|
||||||
|
'range.30d': '30D',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export default en
|
||||||
80
frontend/src/i18n/ko.ts
Normal file
80
frontend/src/i18n/ko.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
const ko = {
|
||||||
|
// Navigation
|
||||||
|
'nav.dashboard': '대시보드',
|
||||||
|
'nav.jobs': 'Job 모니터',
|
||||||
|
'nav.pipeline': '파이프라인',
|
||||||
|
'nav.apiExplorer': 'API 탐색기',
|
||||||
|
'nav.abnormal': '비정상 항적',
|
||||||
|
'nav.areaStats': '해구 통계',
|
||||||
|
'nav.metrics': '메트릭',
|
||||||
|
|
||||||
|
// Common
|
||||||
|
'common.loading': '로딩 중...',
|
||||||
|
'common.noData': '데이터가 없습니다',
|
||||||
|
'common.close': '닫기',
|
||||||
|
'common.prev': '이전',
|
||||||
|
'common.next': '다음',
|
||||||
|
'common.items': '건',
|
||||||
|
'common.of': '중',
|
||||||
|
'common.day': '일',
|
||||||
|
'common.min': '분',
|
||||||
|
'common.sec': '초',
|
||||||
|
'common.themeToggle': '테마 전환',
|
||||||
|
'common.langToggle': '언어 전환',
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
'dashboard.title': '대시보드',
|
||||||
|
'dashboard.totalExec': '총 실행',
|
||||||
|
'dashboard.successRate': '성공률',
|
||||||
|
'dashboard.avgDuration': '평균 소요시간',
|
||||||
|
'dashboard.totalProcessed': '총 처리건수',
|
||||||
|
'dashboard.periodBasis': '일 기준',
|
||||||
|
'dashboard.avgPerJob': '/job 평균',
|
||||||
|
'dashboard.runningJobs': '실행 중 Job',
|
||||||
|
'dashboard.noRunningJobs': '실행 중인 Job 없음',
|
||||||
|
'dashboard.delay': '처리 지연',
|
||||||
|
'dashboard.delayMin': '분 지연',
|
||||||
|
'dashboard.aisLatest': 'AIS 최신',
|
||||||
|
'dashboard.processLatest': '처리 최신',
|
||||||
|
'dashboard.aisReceived': 'AIS 수신',
|
||||||
|
'dashboard.tileProcessed': '타일 처리',
|
||||||
|
'dashboard.systemMetrics': '시스템 메트릭',
|
||||||
|
'dashboard.memory': '메모리',
|
||||||
|
'dashboard.threads': '스레드',
|
||||||
|
'dashboard.dbConn': 'DB 연결',
|
||||||
|
'dashboard.recordsSec': '초당 처리',
|
||||||
|
'dashboard.cacheStatus': '캐시 상태',
|
||||||
|
'dashboard.hitRate': '히트율',
|
||||||
|
'dashboard.size': '크기',
|
||||||
|
'dashboard.hits': '히트',
|
||||||
|
'dashboard.misses': '미스',
|
||||||
|
'dashboard.dailyVolume': '일별 처리량',
|
||||||
|
|
||||||
|
// Job Monitor
|
||||||
|
'jobs.title': 'Job 모니터',
|
||||||
|
'jobs.all': '전체',
|
||||||
|
'jobs.track5min': 'Track (5분)',
|
||||||
|
'jobs.hourly': 'Hourly',
|
||||||
|
'jobs.daily': 'Daily',
|
||||||
|
'jobs.status': '상태',
|
||||||
|
'jobs.job': 'Job',
|
||||||
|
'jobs.id': 'ID',
|
||||||
|
'jobs.start': '시작 시간',
|
||||||
|
'jobs.duration': '소요시간',
|
||||||
|
'jobs.read': '읽기',
|
||||||
|
'jobs.write': '쓰기',
|
||||||
|
'jobs.skip': '건너뜀',
|
||||||
|
'jobs.stepDetails': 'Step 상세',
|
||||||
|
'jobs.step': 'Step',
|
||||||
|
'jobs.commits': '커밋',
|
||||||
|
'jobs.errors': '에러',
|
||||||
|
|
||||||
|
// Time Range
|
||||||
|
'range.1d': '1일',
|
||||||
|
'range.3d': '3일',
|
||||||
|
'range.7d': '7일',
|
||||||
|
'range.14d': '14일',
|
||||||
|
'range.30d': '30일',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export default ko
|
||||||
32
frontend/src/index.css
Normal file
32
frontend/src/index.css
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "./styles/tokens.css";
|
||||||
|
@import "./styles/components/card.css";
|
||||||
|
@import "./styles/components/badge.css";
|
||||||
|
@import "./styles/components/table.css";
|
||||||
|
@import "./styles/components/navbar.css";
|
||||||
|
@import "./styles/utilities.css";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: var(--sb-primary);
|
||||||
|
--color-primary-hover: var(--sb-primary-hover);
|
||||||
|
--color-surface: var(--sb-surface);
|
||||||
|
--color-surface-hover: var(--sb-surface-hover);
|
||||||
|
--color-background: var(--sb-background);
|
||||||
|
--color-foreground: var(--sb-text);
|
||||||
|
--color-muted: var(--sb-text-muted);
|
||||||
|
--color-border: var(--sb-border);
|
||||||
|
--color-success: var(--sb-success);
|
||||||
|
--color-danger: var(--sb-danger);
|
||||||
|
--color-warning: var(--sb-warning);
|
||||||
|
--color-info: var(--sb-info);
|
||||||
|
--font-sans: var(--sb-font-sans);
|
||||||
|
--font-mono: var(--sb-font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--sb-background);
|
||||||
|
color: var(--sb-text);
|
||||||
|
font-family: var(--sb-font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
226
frontend/src/pages/Dashboard.tsx
Normal file
226
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { usePoller } from '../hooks/usePoller.ts'
|
||||||
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
|
import { batchApi } from '../api/batchApi.ts'
|
||||||
|
import { monitorApi } from '../api/monitorApi.ts'
|
||||||
|
import type {
|
||||||
|
BatchStatistics,
|
||||||
|
CacheStats,
|
||||||
|
DailyStats,
|
||||||
|
MetricsSummary,
|
||||||
|
ProcessingDelay,
|
||||||
|
RunningJob,
|
||||||
|
} from '../api/types.ts'
|
||||||
|
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||||
|
import StatusBadge from '../components/common/StatusBadge.tsx'
|
||||||
|
import BarChart from '../components/charts/BarChart.tsx'
|
||||||
|
import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx'
|
||||||
|
import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts'
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 30_000
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [stats, setStats] = useState<BatchStatistics | null>(null)
|
||||||
|
const [metrics, setMetrics] = useState<MetricsSummary | null>(null)
|
||||||
|
const [cache, setCache] = useState<CacheStats | null>(null)
|
||||||
|
const [delay, setDelay] = useState<ProcessingDelay | null>(null)
|
||||||
|
const [daily, setDaily] = useState<DailyStats | null>(null)
|
||||||
|
const [running, setRunning] = useState<RunningJob[]>([])
|
||||||
|
const [days, setDays] = useState(7)
|
||||||
|
|
||||||
|
usePoller(async () => {
|
||||||
|
const [s, m, c, d, ds, r] = await Promise.allSettled([
|
||||||
|
batchApi.getStatistics(days),
|
||||||
|
monitorApi.getMetricsSummary(),
|
||||||
|
monitorApi.getCacheStats(),
|
||||||
|
monitorApi.getDelay(),
|
||||||
|
batchApi.getDailyStats(),
|
||||||
|
batchApi.getRunningJobs(),
|
||||||
|
])
|
||||||
|
if (s.status === 'fulfilled') setStats(s.value)
|
||||||
|
if (m.status === 'fulfilled') setMetrics(m.value)
|
||||||
|
if (c.status === 'fulfilled') setCache(c.value)
|
||||||
|
if (d.status === 'fulfilled') setDelay(d.value)
|
||||||
|
if (ds.status === 'fulfilled') setDaily(ds.value)
|
||||||
|
if (r.status === 'fulfilled') setRunning(r.value)
|
||||||
|
}, POLL_INTERVAL, [days])
|
||||||
|
|
||||||
|
const memUsage = metrics
|
||||||
|
? Math.round((metrics.memory.used / metrics.memory.max) * 100)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">{t('dashboard.title')}</h1>
|
||||||
|
<TimeRangeSelector value={days} onChange={setDays} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Cards */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
<MetricCard
|
||||||
|
title={t('dashboard.totalExec')}
|
||||||
|
value={stats ? formatNumber(stats.summary.totalExecutions) : '-'}
|
||||||
|
subtitle={stats ? `${days}${t('dashboard.periodBasis')}` : undefined}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('dashboard.successRate')}
|
||||||
|
value={stats ? formatPercent(stats.summary.successRate) : '-'}
|
||||||
|
trend={stats && stats.summary.successRate >= 95 ? 'up' : stats && stats.summary.successRate < 80 ? 'down' : 'neutral'}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('dashboard.avgDuration')}
|
||||||
|
value={stats ? formatDuration(stats.summary.avgProcessingTimeSeconds) : '-'}
|
||||||
|
/>
|
||||||
|
<MetricCard
|
||||||
|
title={t('dashboard.totalProcessed')}
|
||||||
|
value={stats ? formatNumber(stats.summary.totalRecordsProcessed) : '-'}
|
||||||
|
subtitle={stats ? `${formatNumber(stats.summary.avgRecordsPerExecution)}${t('dashboard.avgPerJob')}` : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Running Jobs + Processing Delay */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="sb-card-header">{t('dashboard.runningJobs')}</div>
|
||||||
|
{running.length === 0 ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">{t('dashboard.noRunningJobs')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{running.map(job => (
|
||||||
|
<div key={job.executionId} className="flex items-center justify-between rounded-lg bg-surface-hover p-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">{job.jobName}</div>
|
||||||
|
<div className="text-xs text-muted">#{job.executionId} · {formatDateTime(job.startTime)}</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="sb-card-header">{t('dashboard.delay')}</div>
|
||||||
|
{delay ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold">{delay.delayMinutes}</span>
|
||||||
|
<span className="text-muted">{t('dashboard.delayMin')}</span>
|
||||||
|
<StatusBadge status={delay.status} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">{t('dashboard.aisLatest')}</span>
|
||||||
|
<div className="font-mono text-xs">{formatDateTime(delay.aisLatestTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">{t('dashboard.processLatest')}</span>
|
||||||
|
<div className="font-mono text-xs">{formatDateTime(delay.queryLatestTime)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">{t('dashboard.aisReceived')}</span>
|
||||||
|
<div>{formatNumber(delay.recentAisCount)}{t('common.items')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-muted">{t('dashboard.tileProcessed')}</span>
|
||||||
|
<div>{formatNumber(delay.processedTiles)}{t('common.items')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* System Metrics + Cache */}
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="sb-card-header">{t('dashboard.systemMetrics')}</div>
|
||||||
|
{metrics ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex justify-between text-sm">
|
||||||
|
<span>{t('dashboard.memory')} ({metrics.memory.used}MB / {metrics.memory.max}MB)</span>
|
||||||
|
<span className={memUsage > 85 ? 'text-danger' : memUsage > 70 ? 'text-warning' : 'text-success'}>
|
||||||
|
{memUsage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 rounded-full bg-surface-hover">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${memUsage > 85 ? 'bg-danger' : memUsage > 70 ? 'bg-warning' : 'bg-success'}`}
|
||||||
|
style={{ width: `${memUsage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{metrics.threads}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.threads')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{metrics.database.activeConnections}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.dbConn')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{metrics.processing.recordsPerSecond.toFixed(0)}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.recordsSec')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="sb-card-header">{t('dashboard.cacheStatus')}</div>
|
||||||
|
{cache ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-3xl font-bold">{cache.hitRate}</span>
|
||||||
|
<span className="text-muted">{t('dashboard.hitRate')}</span>
|
||||||
|
<StatusBadge status={cache.status} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{formatNumber(cache.currentSize)}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.size')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{formatNumber(cache.hitCount)}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.hits')}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-bold">{formatNumber(cache.missCount)}</div>
|
||||||
|
<div className="text-xs text-muted">{t('dashboard.misses')}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Daily Throughput Chart */}
|
||||||
|
{daily && daily.dailyStats.length > 0 && (
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="sb-card-header">{t('dashboard.dailyVolume')}</div>
|
||||||
|
<BarChart
|
||||||
|
data={daily.dailyStats.map(d => ({
|
||||||
|
date: d.date.slice(5),
|
||||||
|
processed: d.totalProcessed,
|
||||||
|
}))}
|
||||||
|
dataKey="processed"
|
||||||
|
xKey="date"
|
||||||
|
height={280}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
frontend/src/pages/JobMonitor.tsx
Normal file
237
frontend/src/pages/JobMonitor.tsx
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react'
|
||||||
|
import { usePoller } from '../hooks/usePoller.ts'
|
||||||
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
|
import { batchApi } from '../api/batchApi.ts'
|
||||||
|
import type { JobExecution, StepExecution } from '../api/types.ts'
|
||||||
|
import DataTable, { type Column } from '../components/common/DataTable.tsx'
|
||||||
|
import StatusBadge from '../components/common/StatusBadge.tsx'
|
||||||
|
import { formatDuration, formatNumber, formatDateTime } from '../utils/formatters.ts'
|
||||||
|
import type { TranslationKey } from '../i18n/I18nContext.tsx'
|
||||||
|
|
||||||
|
const POLL_INTERVAL = 30_000
|
||||||
|
|
||||||
|
const JOB_FILTERS: { labelKey: TranslationKey; value: string }[] = [
|
||||||
|
{ labelKey: 'jobs.all', value: '' },
|
||||||
|
{ labelKey: 'jobs.track5min', value: 'vesselTrackAggregationJob' },
|
||||||
|
{ labelKey: 'jobs.hourly', value: 'hourlyAggregationJob' },
|
||||||
|
{ labelKey: 'jobs.daily', value: 'dailyAggregationJob' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const STATUS_FILTERS = ['ALL', 'COMPLETED', 'FAILED', 'RUNNING', 'STOPPED']
|
||||||
|
|
||||||
|
export default function JobMonitor() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const [jobs, setJobs] = useState<JobExecution[]>([])
|
||||||
|
const [jobFilter, setJobFilter] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState('ALL')
|
||||||
|
const [selectedJob, setSelectedJob] = useState<JobExecution | null>(null)
|
||||||
|
const [steps, setSteps] = useState<StepExecution[]>([])
|
||||||
|
const [stepsLoading, setStepsLoading] = useState(false)
|
||||||
|
|
||||||
|
usePoller(async () => {
|
||||||
|
const data = await batchApi.getJobHistory(jobFilter || undefined, 100)
|
||||||
|
setJobs(data)
|
||||||
|
}, POLL_INTERVAL, [jobFilter])
|
||||||
|
|
||||||
|
const filtered = statusFilter === 'ALL'
|
||||||
|
? jobs
|
||||||
|
: jobs.filter(j => j.status === statusFilter)
|
||||||
|
|
||||||
|
const handleRowClick = useCallback(async (job: JobExecution) => {
|
||||||
|
setSelectedJob(job)
|
||||||
|
setStepsLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await batchApi.getStepDetails(job.executionId)
|
||||||
|
setSteps(data)
|
||||||
|
} catch {
|
||||||
|
setSteps([])
|
||||||
|
} finally {
|
||||||
|
setStepsLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const jobColumns: Column<JobExecution>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: t('jobs.status'),
|
||||||
|
render: (row) => <StatusBadge status={row.status} />,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{ key: 'jobName', label: t('jobs.job'), sortable: true },
|
||||||
|
{ key: 'executionId', label: t('jobs.id'), sortable: true, align: 'center' as const },
|
||||||
|
{
|
||||||
|
key: 'startTime',
|
||||||
|
label: t('jobs.start'),
|
||||||
|
render: (row) => <span className="font-mono text-xs">{formatDateTime(row.startTime)}</span>,
|
||||||
|
sortable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'durationSeconds',
|
||||||
|
label: t('jobs.duration'),
|
||||||
|
render: (row) => formatDuration(row.durationSeconds),
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalRead',
|
||||||
|
label: t('jobs.read'),
|
||||||
|
render: (row) => formatNumber(row.totalRead),
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalWrite',
|
||||||
|
label: t('jobs.write'),
|
||||||
|
render: (row) => formatNumber(row.totalWrite),
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'totalSkip',
|
||||||
|
label: t('jobs.skip'),
|
||||||
|
render: (row) => row.totalSkip > 0 ? <span className="text-warning">{formatNumber(row.totalSkip)}</span> : '0',
|
||||||
|
sortable: true,
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
], [t])
|
||||||
|
|
||||||
|
const stepColumns: Column<StepExecution>[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: t('jobs.status'),
|
||||||
|
render: (row) => <StatusBadge status={row.status} />,
|
||||||
|
},
|
||||||
|
{ key: 'stepName', label: t('jobs.step') },
|
||||||
|
{
|
||||||
|
key: 'startTime',
|
||||||
|
label: t('jobs.start'),
|
||||||
|
render: (row) => <span className="font-mono text-xs">{formatDateTime(row.startTime)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'durationSeconds',
|
||||||
|
label: t('jobs.duration'),
|
||||||
|
render: (row) => formatDuration(row.durationSeconds),
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'readCount',
|
||||||
|
label: t('jobs.read'),
|
||||||
|
render: (row) => formatNumber(row.readCount),
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'writeCount',
|
||||||
|
label: t('jobs.write'),
|
||||||
|
render: (row) => formatNumber(row.writeCount),
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'commitCount',
|
||||||
|
label: t('jobs.commits'),
|
||||||
|
render: (row) => formatNumber(row.commitCount),
|
||||||
|
align: 'right' as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'errors',
|
||||||
|
label: t('jobs.errors'),
|
||||||
|
render: (row) => row.errors?.length > 0
|
||||||
|
? <span className="text-danger">{row.errors.length}</span>
|
||||||
|
: <span className="text-muted">-</span>,
|
||||||
|
align: 'center' as const,
|
||||||
|
},
|
||||||
|
], [t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 fade-in">
|
||||||
|
<h1 className="text-2xl font-bold">{t('jobs.title')}</h1>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{JOB_FILTERS.map(j => (
|
||||||
|
<button
|
||||||
|
key={j.value}
|
||||||
|
onClick={() => setJobFilter(j.value)}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
jobFilter === j.value ? 'bg-primary text-white' : 'text-muted hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(j.labelKey)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-px bg-border" />
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STATUS_FILTERS.map(s => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
onClick={() => setStatusFilter(s)}
|
||||||
|
className={`rounded-md px-3 py-1 text-sm font-medium transition-colors ${
|
||||||
|
statusFilter === s ? 'bg-primary text-white' : 'text-muted hover:bg-surface-hover'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-sm text-muted">{filtered.length}{t('common.items')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Job Table */}
|
||||||
|
<DataTable
|
||||||
|
columns={jobColumns}
|
||||||
|
data={filtered}
|
||||||
|
keyExtractor={(row) => row.executionId}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Step Detail */}
|
||||||
|
{selectedJob && (
|
||||||
|
<div className="sb-card">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="sb-card-header">{t('jobs.stepDetails')}</div>
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium">{selectedJob.jobName}</span>
|
||||||
|
<span className="text-muted"> #{selectedJob.executionId}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedJob(null)}
|
||||||
|
className="rounded-md border border-border px-3 py-1 text-sm text-muted hover:text-foreground"
|
||||||
|
>
|
||||||
|
{t('common.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{stepsLoading ? (
|
||||||
|
<div className="py-4 text-center text-sm text-muted">{t('common.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={stepColumns}
|
||||||
|
data={steps}
|
||||||
|
keyExtractor={(row) => row.stepName}
|
||||||
|
pageSize={50}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{steps.some(s => s.errors?.length > 0) && (
|
||||||
|
<div className="mt-4 rounded-lg border border-danger bg-danger/5 p-3">
|
||||||
|
<div className="mb-2 text-sm font-semibold text-danger">{t('jobs.errors')}</div>
|
||||||
|
{steps
|
||||||
|
.filter(s => s.errors?.length > 0)
|
||||||
|
.map(s => (
|
||||||
|
<div key={s.stepName} className="mb-2">
|
||||||
|
<div className="text-xs font-medium text-muted">{s.stepName}</div>
|
||||||
|
{s.errors.map((err, i) => (
|
||||||
|
<pre key={i} className="mt-1 overflow-x-auto rounded bg-surface-hover p-2 font-mono text-xs">
|
||||||
|
{err}
|
||||||
|
</pre>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
frontend/src/styles/components/badge.css
Normal file
73
frontend/src/styles/components/badge.css
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
/* Badge Component Styles */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sb-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-dot {
|
||||||
|
width: 0.375rem;
|
||||||
|
height: 0.375rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-completed {
|
||||||
|
background-color: var(--sb-success-light);
|
||||||
|
color: var(--sb-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-completed .sb-badge-dot {
|
||||||
|
background-color: var(--sb-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-failed {
|
||||||
|
background-color: var(--sb-danger-light);
|
||||||
|
color: var(--sb-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-failed .sb-badge-dot {
|
||||||
|
background-color: var(--sb-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-running {
|
||||||
|
background-color: var(--sb-info-light);
|
||||||
|
color: var(--sb-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-running .sb-badge-dot {
|
||||||
|
background-color: var(--sb-info);
|
||||||
|
animation: pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-stopped,
|
||||||
|
.sb-badge-abandoned {
|
||||||
|
background-color: var(--sb-warning-light);
|
||||||
|
color: var(--sb-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-stopped .sb-badge-dot,
|
||||||
|
.sb-badge-abandoned .sb-badge-dot {
|
||||||
|
background-color: var(--sb-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-unknown {
|
||||||
|
background-color: var(--sb-surface-hover);
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-badge-unknown .sb-badge-dot {
|
||||||
|
background-color: var(--sb-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
}
|
||||||
38
frontend/src/styles/components/card.css
Normal file
38
frontend/src/styles/components/card.css
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/* Card Component Styles */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sb-card {
|
||||||
|
background-color: var(--sb-surface);
|
||||||
|
border: 1px solid var(--sb-border);
|
||||||
|
border-radius: var(--sb-radius-lg);
|
||||||
|
box-shadow: 0 1px 3px var(--sb-shadow);
|
||||||
|
padding: 1.25rem;
|
||||||
|
transition: box-shadow 0.15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-card:hover {
|
||||||
|
box-shadow: 0 4px 12px var(--sb-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-card-header {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.025em;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-card-value {
|
||||||
|
font-size: 1.875rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--sb-text);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-card-footer {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/src/styles/components/navbar.css
Normal file
75
frontend/src/styles/components/navbar.css
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
/* Navbar Component Styles */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sb-navbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--sb-surface);
|
||||||
|
border-bottom: 1px solid var(--sb-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--sb-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-navbar-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-radius: var(--sb-radius);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-nav-link:hover {
|
||||||
|
color: var(--sb-text);
|
||||||
|
background-color: var(--sb-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-nav-link-active {
|
||||||
|
color: var(--sb-primary);
|
||||||
|
background-color: var(--sb-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-theme-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 2rem;
|
||||||
|
height: 2rem;
|
||||||
|
border-radius: var(--sb-radius);
|
||||||
|
border: 1px solid var(--sb-border);
|
||||||
|
background-color: var(--sb-surface);
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-theme-toggle:hover {
|
||||||
|
color: var(--sb-text);
|
||||||
|
border-color: var(--sb-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
frontend/src/styles/components/table.css
Normal file
57
frontend/src/styles/components/table.css
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
/* Table Component Styles */
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.sb-table-wrapper {
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--sb-border);
|
||||||
|
border-radius: var(--sb-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table thead {
|
||||||
|
background-color: var(--sb-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table th {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
border-bottom: 1px solid var(--sb-border);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table th:hover {
|
||||||
|
color: var(--sb-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table td {
|
||||||
|
padding: 0.625rem 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--sb-border);
|
||||||
|
color: var(--sb-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table tbody tr:hover {
|
||||||
|
background-color: var(--sb-surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sb-table-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: var(--sb-text-muted);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/styles/tokens.css
Normal file
54
frontend/src/styles/tokens.css
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
/* Signal Batch Design Tokens (gc-wing-dev 기반 이식) */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--sb-primary: #0d6efd;
|
||||||
|
--sb-primary-hover: #0b5ed7;
|
||||||
|
--sb-secondary: #6c757d;
|
||||||
|
--sb-success: #198754;
|
||||||
|
--sb-success-light: #d1e7dd;
|
||||||
|
--sb-danger: #dc3545;
|
||||||
|
--sb-danger-light: #f8d7da;
|
||||||
|
--sb-warning: #ffc107;
|
||||||
|
--sb-warning-light: #fff3cd;
|
||||||
|
--sb-info: #0dcaf0;
|
||||||
|
--sb-info-light: #cff4fc;
|
||||||
|
|
||||||
|
--sb-background: #f8f9fa;
|
||||||
|
--sb-surface: #ffffff;
|
||||||
|
--sb-surface-hover: #f1f3f5;
|
||||||
|
--sb-text: #212529;
|
||||||
|
--sb-text-muted: #6c757d;
|
||||||
|
--sb-border: #dee2e6;
|
||||||
|
--sb-shadow: rgba(0, 0, 0, 0.08);
|
||||||
|
--sb-shadow-lg: rgba(0, 0, 0, 0.15);
|
||||||
|
|
||||||
|
--sb-radius-sm: 0.25rem;
|
||||||
|
--sb-radius: 0.375rem;
|
||||||
|
--sb-radius-lg: 0.5rem;
|
||||||
|
|
||||||
|
--sb-font-sans: 'Pretendard Variable', 'Pretendard', system-ui, -apple-system, sans-serif;
|
||||||
|
--sb-font-mono: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='dark'] {
|
||||||
|
--sb-primary: #6ea8fe;
|
||||||
|
--sb-primary-hover: #8bb9fe;
|
||||||
|
--sb-secondary: #adb5bd;
|
||||||
|
--sb-success: #75b798;
|
||||||
|
--sb-success-light: #0f3d25;
|
||||||
|
--sb-danger: #ea868f;
|
||||||
|
--sb-danger-light: #3d1319;
|
||||||
|
--sb-warning: #ffda6a;
|
||||||
|
--sb-warning-light: #3d3200;
|
||||||
|
--sb-info: #6edff6;
|
||||||
|
--sb-info-light: #0a3644;
|
||||||
|
|
||||||
|
--sb-background: #121416;
|
||||||
|
--sb-surface: #1a1d21;
|
||||||
|
--sb-surface-hover: #242830;
|
||||||
|
--sb-text: #e9ecef;
|
||||||
|
--sb-text-muted: #adb5bd;
|
||||||
|
--sb-border: #343a40;
|
||||||
|
--sb-shadow: rgba(0, 0, 0, 0.25);
|
||||||
|
--sb-shadow-lg: rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
21
frontend/src/styles/utilities.css
Normal file
21
frontend/src/styles/utilities.css
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
/* Custom Utilities */
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
.text-balance {
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--sb-border) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
}
|
||||||
56
frontend/src/utils/formatters.ts
Normal file
56
frontend/src/utils/formatters.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
/** 숫자를 천 단위 콤마 포맷 */
|
||||||
|
export function formatNumber(n: number): string {
|
||||||
|
return n.toLocaleString('ko-KR')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 바이트를 사람이 읽을 수 있는 크기로 */
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
|
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 초를 사람이 읽을 수 있는 시간으로 */
|
||||||
|
export function formatDuration(seconds: number): string {
|
||||||
|
if (seconds < 60) return `${seconds}s`
|
||||||
|
if (seconds < 3600) {
|
||||||
|
const m = Math.floor(seconds / 60)
|
||||||
|
const s = seconds % 60
|
||||||
|
return s > 0 ? `${m}m ${s}s` : `${m}m`
|
||||||
|
}
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
return m > 0 ? `${h}h ${m}m` : `${h}h`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 날짜 문자열을 로컬 시각으로 */
|
||||||
|
export function formatDateTime(iso: string | null): string {
|
||||||
|
if (!iso) return '-'
|
||||||
|
return new Date(iso).toLocaleString('ko-KR', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO 날짜 문자열을 상대 시간으로 (예: "3분 전") */
|
||||||
|
export function formatRelativeTime(iso: string | null): string {
|
||||||
|
if (!iso) return '-'
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
if (seconds < 60) return `${seconds}초 전`
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
if (minutes < 60) return `${minutes}분 전`
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
if (hours < 24) return `${hours}시간 전`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `${days}일 전`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 퍼센트 포맷 (소수 1자리) */
|
||||||
|
export function formatPercent(value: number): string {
|
||||||
|
return `${value.toFixed(1)}%`
|
||||||
|
}
|
||||||
9
frontend/src/vite-env.d.ts
vendored
Normal file
9
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_BASE_URL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
24
frontend/tsconfig.app.json
Normal file
24
frontend/tsconfig.app.json
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
21
frontend/tsconfig.node.json
Normal file
21
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
27
frontend/vite.config.ts
Normal file
27
frontend/vite.config.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig(({ mode }) => {
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
const BASE_URL = env.VITE_BASE_URL || '/signal-batch'
|
||||||
|
|
||||||
|
return {
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
[`${BASE_URL}/api`]: { target: 'http://localhost:8090', changeOrigin: true },
|
||||||
|
[`${BASE_URL}/admin`]: { target: 'http://localhost:8090', changeOrigin: true },
|
||||||
|
[`${BASE_URL}/monitor`]: { target: 'http://localhost:8090', changeOrigin: true },
|
||||||
|
[`${BASE_URL}/actuator`]: { target: 'http://localhost:8090', changeOrigin: true },
|
||||||
|
[`${BASE_URL}/ws-tracks`]: { target: 'ws://localhost:8090', ws: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
base: `${BASE_URL}/`,
|
||||||
|
build: {
|
||||||
|
outDir: '../src/main/resources/static',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
32
pom.xml
32
pom.xml
@ -333,6 +333,38 @@
|
|||||||
<argLine>-Xmx2048m -Xms1024m</argLine>
|
<argLine>-Xmx2048m -Xms1024m</argLine>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Frontend Build (React + Vite) -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>com.github.eirslett</groupId>
|
||||||
|
<artifactId>frontend-maven-plugin</artifactId>
|
||||||
|
<version>1.15.1</version>
|
||||||
|
<configuration>
|
||||||
|
<workingDirectory>frontend</workingDirectory>
|
||||||
|
<nodeVersion>v20.19.0</nodeVersion>
|
||||||
|
<skip>${skip.npm}</skip>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>install-node-and-npm</id>
|
||||||
|
<goals><goal>install-node-and-npm</goal></goals>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-install</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>install</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>npm-build</id>
|
||||||
|
<goals><goal>npm</goal></goals>
|
||||||
|
<configuration>
|
||||||
|
<arguments>run build</arguments>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
|
|||||||
275
scripts/deploy.sh
Normal file
275
scripts/deploy.sh
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Safe Deploy Script — API 기반 활성 Job 확인 후 무중단 배포
|
||||||
|
#
|
||||||
|
# 동작:
|
||||||
|
# 1. /admin/batch/job/running API를 1초 간격으로 폴링
|
||||||
|
# 2. 활성 Job 없음 → 카운트 증가, 활성 Job 발생 → 카운트 초기화
|
||||||
|
# 3. 연속 3회 확인 (SAFE_COUNT_THRESHOLD) → SIGTERM → 프로세스 종료 대기 → 재기동
|
||||||
|
#
|
||||||
|
# 사용법:
|
||||||
|
# ./deploy.sh # 기본 (JAR 교체 없이 재기동)
|
||||||
|
# ./deploy.sh /path/to/new.jar # 새 JAR 배포 후 기동
|
||||||
|
# PROFILE=dev ./deploy.sh # 프로파일 지정
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── 설정 ────────────────────────────────────────────────────────────────────
|
||||||
|
APP_HOME="${APP_HOME:-/home/apps/signal-batch}"
|
||||||
|
JAR_FILE="$APP_HOME/vessel-batch-aggregation.jar"
|
||||||
|
PID_FILE="$APP_HOME/vessel-batch.pid"
|
||||||
|
LOG_DIR="$APP_HOME/logs"
|
||||||
|
|
||||||
|
PROFILE="${PROFILE:-prod}"
|
||||||
|
PORT="${PORT:-18090}"
|
||||||
|
CONTEXT_PATH="${CONTEXT_PATH:-/signal-batch}"
|
||||||
|
BASE_URL="http://localhost:${PORT}${CONTEXT_PATH}"
|
||||||
|
|
||||||
|
JAVA_HOME="${JAVA_HOME:-/devdata/apps/jdk-17.0.8}"
|
||||||
|
JAVA_BIN="$JAVA_HOME/bin/java"
|
||||||
|
|
||||||
|
# 폴링 설정
|
||||||
|
POLL_INTERVAL=1 # 1초 간격
|
||||||
|
SAFE_COUNT_THRESHOLD=3 # 3회 연속 활성 Job 없음 확인
|
||||||
|
MAX_WAIT_FOR_IDLE=300 # 최대 5분 대기 (활성 Job 종료 대기)
|
||||||
|
MAX_WAIT_FOR_STOP=60 # 프로세스 종료 최대 대기 (초)
|
||||||
|
STARTUP_TIMEOUT=60 # 기동 확인 타임아웃 (초)
|
||||||
|
|
||||||
|
# 색상
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
# ── 유틸리티 ────────────────────────────────────────────────────────────────
|
||||||
|
timestamp() { date '+%Y-%m-%d %H:%M:%S'; }
|
||||||
|
log_info() { echo -e "${CYAN}[$(timestamp)]${NC} $*"; }
|
||||||
|
log_ok() { echo -e "${GREEN}[$(timestamp)] ✓${NC} $*"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[$(timestamp)] ⚠${NC} $*"; }
|
||||||
|
log_error() { echo -e "${RED}[$(timestamp)] ✗${NC} $*"; }
|
||||||
|
|
||||||
|
get_pid() {
|
||||||
|
if [ -f "$PID_FILE" ]; then
|
||||||
|
local pid
|
||||||
|
pid=$(cat "$PID_FILE")
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo "$pid"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
pgrep -f "$JAR_FILE" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1단계: 활성 Job 없음 확인 (3초 연속) ────────────────────────────────────
|
||||||
|
wait_for_idle() {
|
||||||
|
local safe_count=0
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
log_info "활성 Job 확인 시작 (${SAFE_COUNT_THRESHOLD}회 연속 확인 필요)"
|
||||||
|
|
||||||
|
while [ $elapsed -lt $MAX_WAIT_FOR_IDLE ]; do
|
||||||
|
local response
|
||||||
|
local http_code
|
||||||
|
http_code=$(curl -s -o /tmp/deploy_running_jobs.json -w '%{http_code}' \
|
||||||
|
"${BASE_URL}/admin/batch/job/running" 2>/dev/null || echo "000")
|
||||||
|
|
||||||
|
if [ "$http_code" = "000" ]; then
|
||||||
|
log_warn "API 응답 없음 (앱 미실행 또는 네트워크 문제) — 바로 배포 진행"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$http_code" != "200" ]; then
|
||||||
|
log_warn "API 응답 코드: $http_code — 재시도"
|
||||||
|
safe_count=0
|
||||||
|
sleep "$POLL_INTERVAL"
|
||||||
|
elapsed=$((elapsed + POLL_INTERVAL))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# JSON 배열 길이 확인 (jq 없으면 grep 폴백)
|
||||||
|
local running_count
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
running_count=$(jq 'length' /tmp/deploy_running_jobs.json 2>/dev/null || echo "-1")
|
||||||
|
else
|
||||||
|
# jq 없을 때: 빈 배열 "[]"이면 0, 아니면 1 이상
|
||||||
|
if grep -q '^\[\]$' /tmp/deploy_running_jobs.json 2>/dev/null; then
|
||||||
|
running_count=0
|
||||||
|
else
|
||||||
|
running_count=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$running_count" = "0" ]; then
|
||||||
|
safe_count=$((safe_count + 1))
|
||||||
|
log_info "활성 Job 없음 (${safe_count}/${SAFE_COUNT_THRESHOLD})"
|
||||||
|
if [ $safe_count -ge $SAFE_COUNT_THRESHOLD ]; then
|
||||||
|
log_ok "연속 ${SAFE_COUNT_THRESHOLD}회 확인 완료 — 안전 배포 가능"
|
||||||
|
rm -f /tmp/deploy_running_jobs.json
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ $safe_count -gt 0 ]; then
|
||||||
|
log_warn "활성 Job 감지 — 카운트 초기화 (running: ${running_count}건)"
|
||||||
|
fi
|
||||||
|
safe_count=0
|
||||||
|
fi
|
||||||
|
|
||||||
|
sleep "$POLL_INTERVAL"
|
||||||
|
elapsed=$((elapsed + POLL_INTERVAL))
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -f /tmp/deploy_running_jobs.json
|
||||||
|
log_error "최대 대기시간 ${MAX_WAIT_FOR_IDLE}초 초과 — 활성 Job이 계속 존재"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 2단계: 프로세스 종료 ─────────────────────────────────────────────────────
|
||||||
|
stop_process() {
|
||||||
|
local pid
|
||||||
|
pid=$(get_pid)
|
||||||
|
|
||||||
|
if [ -z "$pid" ]; then
|
||||||
|
log_info "실행 중인 프로세스 없음"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "프로세스 종료 요청 (PID: $pid, SIGTERM)"
|
||||||
|
kill -15 "$pid"
|
||||||
|
|
||||||
|
local waited=0
|
||||||
|
while [ $waited -lt $MAX_WAIT_FOR_STOP ]; do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
log_ok "프로세스 정상 종료 (${waited}초)"
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log_warn "정상 종료 타임아웃 (${MAX_WAIT_FOR_STOP}초) — SIGKILL"
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
sleep 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 3단계: JAR 교체 (선택) ───────────────────────────────────────────────────
|
||||||
|
deploy_jar() {
|
||||||
|
local new_jar="$1"
|
||||||
|
|
||||||
|
if [ -z "$new_jar" ]; then
|
||||||
|
log_info "JAR 교체 없음 — 기존 JAR로 재기동"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$new_jar" ]; then
|
||||||
|
log_error "새 JAR 파일 없음: $new_jar"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "JAR 교체: $new_jar → $JAR_FILE"
|
||||||
|
|
||||||
|
# 백업
|
||||||
|
if [ -f "$JAR_FILE" ]; then
|
||||||
|
cp "$JAR_FILE" "${JAR_FILE}.bak"
|
||||||
|
log_info "기존 JAR 백업: ${JAR_FILE}.bak"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp "$new_jar" "$JAR_FILE"
|
||||||
|
log_ok "JAR 교체 완료"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 4단계: 프로세스 기동 ─────────────────────────────────────────────────────
|
||||||
|
start_process() {
|
||||||
|
mkdir -p "$LOG_DIR"
|
||||||
|
|
||||||
|
# JVM 옵션
|
||||||
|
local jvm_heap=8
|
||||||
|
if command -v free >/dev/null 2>&1; then
|
||||||
|
local total_mem
|
||||||
|
total_mem=$(free -g | grep Mem | awk '{print $2}')
|
||||||
|
jvm_heap=$((total_mem / 8))
|
||||||
|
[ $jvm_heap -lt 8 ] && jvm_heap=8
|
||||||
|
[ $jvm_heap -gt 16 ] && jvm_heap=16
|
||||||
|
fi
|
||||||
|
|
||||||
|
local java_opts="-Xms${jvm_heap}g -Xmx${jvm_heap}g \
|
||||||
|
-XX:+UseG1GC \
|
||||||
|
-XX:MaxGCPauseMillis=200 \
|
||||||
|
-XX:+UseStringDeduplication \
|
||||||
|
-XX:+HeapDumpOnOutOfMemoryError \
|
||||||
|
-XX:HeapDumpPath=$LOG_DIR/heapdump.hprof \
|
||||||
|
-Dfile.encoding=UTF-8 \
|
||||||
|
-Duser.timezone=Asia/Seoul \
|
||||||
|
-Dspring.profiles.active=$PROFILE"
|
||||||
|
|
||||||
|
log_info "프로세스 기동 (profile=$PROFILE, heap=${jvm_heap}GB)"
|
||||||
|
|
||||||
|
cd "$APP_HOME"
|
||||||
|
nohup "$JAVA_BIN" $java_opts -jar "$JAR_FILE" \
|
||||||
|
> "$LOG_DIR/app.log" 2>&1 &
|
||||||
|
|
||||||
|
local new_pid=$!
|
||||||
|
echo "$new_pid" > "$PID_FILE"
|
||||||
|
log_info "PID: $new_pid"
|
||||||
|
|
||||||
|
# 기동 확인
|
||||||
|
local waited=0
|
||||||
|
while [ $waited -lt $STARTUP_TIMEOUT ]; do
|
||||||
|
if grep -q "Started SignalBatchApplication" "$LOG_DIR/app.log" 2>/dev/null; then
|
||||||
|
log_ok "애플리케이션 기동 완료 (${waited}초)"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if ! kill -0 "$new_pid" 2>/dev/null; then
|
||||||
|
log_error "프로세스가 기동 중 종료됨"
|
||||||
|
tail -20 "$LOG_DIR/app.log"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
log_warn "기동 확인 타임아웃 (${STARTUP_TIMEOUT}초) — 로그 확인 필요"
|
||||||
|
tail -10 "$LOG_DIR/app.log"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 메인 ─────────────────────────────────────────────────────────────────────
|
||||||
|
main() {
|
||||||
|
local new_jar="${1:-}"
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo " Safe Deploy — Signal Batch"
|
||||||
|
echo " $(timestamp)"
|
||||||
|
echo " Profile: $PROFILE | Port: $PORT"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 1. 활성 Job 없음 확인
|
||||||
|
if ! wait_for_idle; then
|
||||||
|
log_error "배포 중단 — 수동 확인 필요"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. 프로세스 종료
|
||||||
|
stop_process
|
||||||
|
|
||||||
|
# 3. JAR 교체 (인자가 있을 때만)
|
||||||
|
deploy_jar "$new_jar"
|
||||||
|
|
||||||
|
# 4. 기동
|
||||||
|
start_process
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
log_ok "배포 완료"
|
||||||
|
echo " PID: $(cat "$PID_FILE" 2>/dev/null || echo 'N/A')"
|
||||||
|
echo " Log: tail -f $LOG_DIR/app.log"
|
||||||
|
echo " Health: curl ${BASE_URL}/actuator/health"
|
||||||
|
echo "========================================"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@ -1,8 +1,10 @@
|
|||||||
package gc.mda.signal_batch.batch.job;
|
package gc.mda.signal_batch.batch.job;
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.batch.core.*;
|
import org.springframework.batch.core.*;
|
||||||
|
import org.springframework.batch.core.explore.JobExplorer;
|
||||||
import org.springframework.batch.core.launch.JobLauncher;
|
import org.springframework.batch.core.launch.JobLauncher;
|
||||||
import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException;
|
import org.springframework.batch.core.repository.JobExecutionAlreadyRunningException;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -15,6 +17,7 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@ -22,13 +25,20 @@ import java.time.format.DateTimeFormatter;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true)
|
@ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true)
|
||||||
public class VesselBatchScheduler {
|
public class VesselBatchScheduler {
|
||||||
|
|
||||||
|
private static final int SHUTDOWN_POLL_INTERVAL_MS = 1000;
|
||||||
|
private static final int SHUTDOWN_MAX_WAIT_SEC = 150;
|
||||||
|
|
||||||
private volatile boolean trackAggregationRunning = false;
|
private volatile boolean trackAggregationRunning = false;
|
||||||
|
private volatile boolean shutdownRequested = false;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("asyncJobLauncher")
|
@Qualifier("asyncJobLauncher")
|
||||||
private JobLauncher jobLauncher;
|
private JobLauncher jobLauncher;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JobExplorer jobExplorer;
|
||||||
|
|
||||||
@Autowired
|
@Autowired
|
||||||
@Qualifier("vesselTrackAggregationJob")
|
@Qualifier("vesselTrackAggregationJob")
|
||||||
private Job vesselTrackAggregationJob;
|
private Job vesselTrackAggregationJob;
|
||||||
@ -51,13 +61,48 @@ public class VesselBatchScheduler {
|
|||||||
@Value("${vessel.batch.abnormal-detection.enabled:true}")
|
@Value("${vessel.batch.abnormal-detection.enabled:true}")
|
||||||
private boolean abnormalDetectionEnabled;
|
private boolean abnormalDetectionEnabled;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graceful shutdown: 스케줄러 중단 → 진행 중 Job 완료 대기
|
||||||
|
*/
|
||||||
|
@PreDestroy
|
||||||
|
public void onShutdown() {
|
||||||
|
shutdownRequested = true;
|
||||||
|
log.info("[Shutdown] 스케줄러 중단 요청 — 신규 Job 실행 차단");
|
||||||
|
|
||||||
|
int waited = 0;
|
||||||
|
while (waited < SHUTDOWN_MAX_WAIT_SEC) {
|
||||||
|
boolean hasRunning = false;
|
||||||
|
for (String jobName : jobExplorer.getJobNames()) {
|
||||||
|
Set<JobExecution> running = jobExplorer.findRunningJobExecutions(jobName);
|
||||||
|
if (!running.isEmpty()) {
|
||||||
|
hasRunning = true;
|
||||||
|
log.info("[Shutdown] 실행 중 Job 대기: {} ({}건)", jobName, running.size());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!hasRunning) {
|
||||||
|
log.info("[Shutdown] 모든 Job 완료 — 프로세스 종료 진행");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Thread.sleep(SHUTDOWN_POLL_INTERVAL_MS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
log.warn("[Shutdown] 대기 중 인터럽트");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
waited++;
|
||||||
|
}
|
||||||
|
log.warn("[Shutdown] 최대 대기시간 {}초 초과 — 강제 종료 진행", SHUTDOWN_MAX_WAIT_SEC);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S&P AIS API 수집 (매 1분 15초)
|
* S&P AIS API 수집 (매 1분 15초)
|
||||||
* 캐시에 최신 위치 저장 → 5분 집계 Job에서 활용
|
* 캐시에 최신 위치 저장 → 5분 집계 Job에서 활용
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "15 * * * * *")
|
@Scheduled(cron = "15 * * * * *")
|
||||||
public void runAisTargetImport() {
|
public void runAisTargetImport() {
|
||||||
if (!schedulerEnabled || aisTargetImportJob == null) {
|
if (!schedulerEnabled || shutdownRequested || aisTargetImportJob == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,11 +130,11 @@ public class VesselBatchScheduler {
|
|||||||
public void runTrackAggregation() {
|
public void runTrackAggregation() {
|
||||||
log.info("=== runTrackAggregation() called by thread: {} ===", Thread.currentThread().getName());
|
log.info("=== runTrackAggregation() called by thread: {} ===", Thread.currentThread().getName());
|
||||||
|
|
||||||
if (!schedulerEnabled) {
|
if (!schedulerEnabled || shutdownRequested) {
|
||||||
log.debug("Scheduler is disabled");
|
log.debug("Scheduler is disabled or shutdown requested");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실행 가드: 이미 실행 중이면 스킵
|
// 실행 가드: 이미 실행 중이면 스킵
|
||||||
if (trackAggregationRunning) {
|
if (trackAggregationRunning) {
|
||||||
log.warn("Track aggregation is already running, skipping this execution - thread: {}", Thread.currentThread().getName());
|
log.warn("Track aggregation is already running, skipping this execution - thread: {}", Thread.currentThread().getName());
|
||||||
@ -149,8 +194,8 @@ public class VesselBatchScheduler {
|
|||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 10 * * * *")
|
@Scheduled(cron = "0 10 * * * *")
|
||||||
public void runHourlyAggregation() {
|
public void runHourlyAggregation() {
|
||||||
if (!schedulerEnabled || hourlyAggregationJob == null) {
|
if (!schedulerEnabled || shutdownRequested || hourlyAggregationJob == null) {
|
||||||
log.debug("Hourly aggregation job is not available");
|
log.debug("Hourly aggregation job is not available or shutdown requested");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,8 +226,8 @@ public class VesselBatchScheduler {
|
|||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 0 1 * * *")
|
@Scheduled(cron = "0 0 1 * * *")
|
||||||
public void runDailyAggregation() {
|
public void runDailyAggregation() {
|
||||||
if (!schedulerEnabled || dailyAggregationJob == null) {
|
if (!schedulerEnabled || shutdownRequested || dailyAggregationJob == null) {
|
||||||
log.debug("Enhanced daily aggregation job is not available");
|
log.debug("Enhanced daily aggregation job is not available or shutdown requested");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -212,7 +257,7 @@ public class VesselBatchScheduler {
|
|||||||
*/
|
*/
|
||||||
@Scheduled(cron = "0 0 2 * * *")
|
@Scheduled(cron = "0 0 2 * * *")
|
||||||
public void updateAbnormalTrackStatistics() {
|
public void updateAbnormalTrackStatistics() {
|
||||||
if (!schedulerEnabled || !abnormalDetectionEnabled) {
|
if (!schedulerEnabled || shutdownRequested || !abnormalDetectionEnabled) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,7 +273,7 @@ public class VesselBatchScheduler {
|
|||||||
*/
|
*/
|
||||||
@Scheduled(fixedDelay = 600000, initialDelay = 60000)
|
@Scheduled(fixedDelay = 600000, initialDelay = 60000)
|
||||||
public void monitorAndCatchUp() {
|
public void monitorAndCatchUp() {
|
||||||
if (!schedulerEnabled) {
|
if (!schedulerEnabled || shutdownRequested) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,39 +3,26 @@ package gc.mda.signal_batch.global.config;
|
|||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||||
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
|
|
||||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
public class WebMvcConfig implements WebMvcConfigurer {
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||||
// 로컬 타일 리소스 매핑 (대안 방법)
|
// 로컬 타일 리소스 매핑
|
||||||
registry.addResourceHandler("/tiles/**")
|
registry.addResourceHandler("/tiles/**")
|
||||||
.addResourceLocations("file:///devdata/MAPS/")
|
.addResourceLocations("file:///devdata/MAPS/")
|
||||||
.setCachePeriod(86400); // 24시간 캐시
|
.setCachePeriod(86400);
|
||||||
|
|
||||||
// 기본 정적 리소스
|
|
||||||
registry.addResourceHandler("/static/**")
|
|
||||||
.addResourceLocations("classpath:/static/");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addCorsMappings(CorsRegistry registry) {
|
public void addCorsMappings(CorsRegistry registry) {
|
||||||
// CORS 설정 (필요시)
|
|
||||||
registry.addMapping("/api/**")
|
registry.addMapping("/api/**")
|
||||||
.allowedOrigins("*")
|
.allowedOrigins("*")
|
||||||
.allowedMethods("GET", "POST", "PUT", "DELETE")
|
.allowedMethods("GET", "POST", "PUT", "DELETE")
|
||||||
.allowedHeaders("*")
|
.allowedHeaders("*")
|
||||||
.maxAge(3600);
|
.maxAge(3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void addViewControllers(ViewControllerRegistry registry) {
|
|
||||||
// 루트 경로를 관리자 페이지로 리다이렉트
|
|
||||||
registry.addRedirectViewController("/", "/admin/batch-admin.html");
|
|
||||||
registry.addRedirectViewController("/index.html", "/admin/batch-admin.html");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -63,7 +63,6 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
.addInterceptors(new ClientIpHandshakeInterceptor())
|
.addInterceptors(new ClientIpHandshakeInterceptor())
|
||||||
.setAllowedOriginPatterns("*")
|
.setAllowedOriginPatterns("*")
|
||||||
.withSockJS()
|
.withSockJS()
|
||||||
.setClientLibraryUrl("/static/libs/js/sockjs.min.js")
|
|
||||||
.setStreamBytesLimit(5 * 1024 * 1024) // 100MB → 5MB (SockJS 폴링 버퍼)
|
.setStreamBytesLimit(5 * 1024 * 1024) // 100MB → 5MB (SockJS 폴링 버퍼)
|
||||||
.setHttpMessageCacheSize(1000)
|
.setHttpMessageCacheSize(1000)
|
||||||
.setDisconnectDelay(webSocketProperties.getSession().getSockjsDisconnectDelayMs())
|
.setDisconnectDelay(webSocketProperties.getSession().getSockjsDisconnectDelayMs())
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
package gc.mda.signal_batch.global.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPA(React) fallback 라우터
|
||||||
|
*
|
||||||
|
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
|
||||||
|
* API/정적 리소스를 제외한 모든 프론트 경로를 index.html로 포워딩한다.
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class WebViewController {
|
||||||
|
|
||||||
|
@GetMapping({
|
||||||
|
"/",
|
||||||
|
"/jobs",
|
||||||
|
"/pipeline",
|
||||||
|
"/api-explorer",
|
||||||
|
"/abnormal",
|
||||||
|
"/area-stats",
|
||||||
|
"/metrics"
|
||||||
|
})
|
||||||
|
public String forward() {
|
||||||
|
return "forward:/index.html";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,7 +11,6 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import org.springframework.web.servlet.view.RedirectView;
|
|
||||||
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -126,12 +125,4 @@ public class WebSocketMonitoringController {
|
|||||||
return ResponseEntity.ok(dailyTrackCacheManager.getCacheStatus());
|
return ResponseEntity.ok(dailyTrackCacheManager.getCacheStatus());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 테스트 페이지로 리다이렉트
|
|
||||||
*/
|
|
||||||
@GetMapping("/test")
|
|
||||||
@Operation(summary = "WebSocket 테스트 페이지", description = "WebSocket 스트리밍 테스트 페이지로 리다이렉트합니다")
|
|
||||||
public RedirectView redirectToTestPage() {
|
|
||||||
return new RedirectView("/websocket/track-streaming-test.html");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -1,20 +1,18 @@
|
|||||||
# =============================================================================
|
|
||||||
# Production Profile — 192.168.1.18 (64코어 Xeon Gold 6430, 250GB RAM)
|
|
||||||
# DB: snpdb @ 211.208.115.83 (signal 스키마)
|
|
||||||
# =============================================================================
|
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 18090
|
port: 18090
|
||||||
tomcat:
|
tomcat:
|
||||||
threads:
|
threads:
|
||||||
max: 200
|
max: 200
|
||||||
min-spare: 10
|
min-spare: 10
|
||||||
connection-timeout: 60000
|
connection-timeout: 60000 # 60초로 증가
|
||||||
max-connections: 10000
|
max-http-form-post-size: 50MB # 50MB로 증가
|
||||||
accept-count: 100
|
max-swallow-size: 50MB # 50MB로 증가
|
||||||
|
max-connections: 10000 # 최대 연결 수
|
||||||
|
accept-count: 100 # 대기열 크기
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
datasource:
|
datasource:
|
||||||
|
# 원격 수집 DB
|
||||||
collect:
|
collect:
|
||||||
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
||||||
username: snp
|
username: snp
|
||||||
@ -22,16 +20,18 @@ spring:
|
|||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
pool-name: CollectHikariPool
|
pool-name: CollectHikariPool
|
||||||
maximum-pool-size: 50
|
connection-timeout: 30000 # 원격 연결이므로 타임아웃 증가
|
||||||
minimum-idle: 10
|
|
||||||
connection-timeout: 30000
|
|
||||||
idle-timeout: 600000
|
idle-timeout: 600000
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
|
maximum-pool-size: 80 # 20 -> 80 (총 250 중 32%, 배치 Reader 주 사용)
|
||||||
|
minimum-idle: 15 # 5 -> 15
|
||||||
|
# 원격 연결 안정성을 위한 추가 설정
|
||||||
connection-test-query: SELECT 1
|
connection-test-query: SELECT 1
|
||||||
validation-timeout: 5000
|
validation-timeout: 5000
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
||||||
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;"
|
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;"
|
||||||
|
|
||||||
|
# 원격 조회 DB
|
||||||
query:
|
query:
|
||||||
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
||||||
username: snp
|
username: snp
|
||||||
@ -39,20 +39,22 @@ spring:
|
|||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
pool-name: QueryHikariPool
|
pool-name: QueryHikariPool
|
||||||
maximum-pool-size: 100
|
|
||||||
minimum-idle: 20
|
|
||||||
connection-timeout: 5000
|
connection-timeout: 5000
|
||||||
idle-timeout: 600000
|
idle-timeout: 600000
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
|
maximum-pool-size: 180 # 120 -> 180 (WebSocket 대기열 + REST API 주 사용)
|
||||||
|
minimum-idle: 30 # 20 -> 30
|
||||||
connection-test-query: SELECT 1
|
connection-test-query: SELECT 1
|
||||||
validation-timeout: 5000
|
validation-timeout: 5000
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
||||||
|
# PostGIS 함수를 위해 public 스키마를 search_path에 명시적으로 추가
|
||||||
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog;"
|
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog;"
|
||||||
statement-cache-size: 250
|
statement-cache-size: 250
|
||||||
data-source-properties:
|
data-source-properties:
|
||||||
prepareThreshold: 3
|
prepareThreshold: 3
|
||||||
preparedStatementCacheQueries: 250
|
preparedStatementCacheQueries: 250
|
||||||
|
|
||||||
|
# 로컬 배치 메타 DB (signal 스키마 사용)
|
||||||
batch:
|
batch:
|
||||||
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
||||||
username: snp
|
username: snp
|
||||||
@ -60,208 +62,261 @@ spring:
|
|||||||
driver-class-name: org.postgresql.Driver
|
driver-class-name: org.postgresql.Driver
|
||||||
hikari:
|
hikari:
|
||||||
pool-name: BatchHikariPool
|
pool-name: BatchHikariPool
|
||||||
maximum-pool-size: 20
|
maximum-pool-size: 30 # 20 -> 30 (총 250 중 12%, Spring Batch 메타데이터)
|
||||||
minimum-idle: 5
|
minimum-idle: 5 # 10 -> 5 (메타데이터 용도이므로 최소 유지)
|
||||||
connection-timeout: 30000
|
connection-timeout: 30000 # 30초 타임아웃
|
||||||
idle-timeout: 600000
|
idle-timeout: 600000
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
leak-detection-threshold: 60000
|
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
||||||
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO public, signal;"
|
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;"
|
||||||
|
|
||||||
|
# Request 크기 설정
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 50MB
|
||||||
|
max-request-size: 50MB
|
||||||
|
|
||||||
|
# Spring Batch 설정
|
||||||
batch:
|
batch:
|
||||||
job:
|
job:
|
||||||
enabled: false
|
enabled: false
|
||||||
jdbc:
|
jdbc:
|
||||||
initialize-schema: always
|
initialize-schema: never # always에서 never로 변경 (이미 수동으로 생성했으므로)
|
||||||
table-prefix: BATCH_
|
table-prefix: signal.BATCH_
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
gc.mda.signal_batch: INFO
|
gc.mda.signal_batch: DEBUG
|
||||||
gc.mda.signal_batch.global.util: WARN
|
gc.mda.signal_batch.global.util: INFO
|
||||||
gc.mda.signal_batch.global.util.PartitionManager: WARN
|
gc.mda.signal_batch.global.websocket.service: INFO
|
||||||
gc.mda.signal_batch.batch: INFO
|
gc.mda.signal_batch.batch.writer: INFO
|
||||||
|
gc.mda.signal_batch.batch.reader: INFO
|
||||||
|
gc.mda.signal_batch.batch.processor: INFO
|
||||||
gc.mda.signal_batch.domain: INFO
|
gc.mda.signal_batch.domain: INFO
|
||||||
gc.mda.signal_batch.monitoring: INFO
|
gc.mda.signal_batch.monitoring: DEBUG
|
||||||
|
gc.mda.signal_batch.monitoring.controller: INFO
|
||||||
org.springframework.batch: INFO
|
org.springframework.batch: INFO
|
||||||
org.springframework.jdbc: WARN
|
org.springframework.jdbc: WARN
|
||||||
org.postgresql: WARN
|
org.postgresql: WARN
|
||||||
com.zaxxer.hikari: INFO
|
com.zaxxer.hikari: INFO
|
||||||
|
|
||||||
# ── 배치 설정 (64코어 최적화) ──
|
# 개발 환경 배치 설정 (성능 최적화)
|
||||||
vessel:
|
vessel: # spring 하위가 아닌 최상위 레벨
|
||||||
|
# 통합선박 설정
|
||||||
|
integration:
|
||||||
|
enabled: true
|
||||||
|
datasource:
|
||||||
|
jdbc-url: jdbc:postgresql://211.208.115.83:5432/snpdb?currentSchema=public&assumeMinServerVersion=12&reWriteBatchedInserts=true
|
||||||
|
username: snp
|
||||||
|
password: snp#8932
|
||||||
|
ble-name: gis.t_ship_integration_sub # gis 스키마는 jdbc-url의 currentSchema로 지정
|
||||||
batch:
|
batch:
|
||||||
scheduler:
|
# Area Statistics 처리를 위한 별도 설정
|
||||||
enabled: true
|
area-statistics:
|
||||||
incremental:
|
chunk-size: 1000 # 5000 → 1000
|
||||||
delay-minutes: 3
|
batch-size: 500 # 새로 추가
|
||||||
|
|
||||||
chunk-size: 10000
|
chunk-size: 10000
|
||||||
page-size: 5000
|
page-size: 5000
|
||||||
partition-size: 32
|
partition-size: 12
|
||||||
fetch-size: 200000
|
|
||||||
use-cursor-reader: true
|
|
||||||
query-timeout: 1800
|
|
||||||
|
|
||||||
area-statistics:
|
|
||||||
chunk-size: 1000
|
|
||||||
batch-size: 500
|
|
||||||
|
|
||||||
|
# 성능 최적화 설정
|
||||||
optimization:
|
optimization:
|
||||||
enabled: true
|
enabled: true
|
||||||
dynamic-chunk-sizing: true
|
dynamic-chunk-sizing: true
|
||||||
memory-optimization: true
|
memory-optimization: true
|
||||||
cache-optimization: true
|
cache-optimization: true
|
||||||
thread-pool-optimization: true
|
thread-pool-optimization: true
|
||||||
|
# 동적 청크 크기 조정
|
||||||
chunk:
|
chunk:
|
||||||
min-size: 1000
|
min-size: 1000
|
||||||
max-size: 20000
|
max-size: 20000
|
||||||
adjustment-factor: 0.2
|
adjustment-factor: 0.2
|
||||||
|
# 메모리 임계값
|
||||||
memory:
|
memory:
|
||||||
warning-threshold: 70
|
warning-threshold: 70
|
||||||
critical-threshold: 85
|
critical-threshold: 85
|
||||||
optimization-threshold: 80
|
optimization-threshold: 80
|
||||||
|
# 캐시 설정
|
||||||
cache:
|
cache:
|
||||||
min-hit-rate: 70
|
min-hit-rate: 70
|
||||||
area-boundary-size: 5000
|
area-boundary-size: 5000
|
||||||
|
|
||||||
|
# Reader 최적화
|
||||||
|
fetch-size: 200000
|
||||||
|
use-cursor-reader: true
|
||||||
|
|
||||||
|
# Bulk Insert 최적화
|
||||||
bulk-insert:
|
bulk-insert:
|
||||||
batch-size: 10000
|
batch-size: 10000
|
||||||
parallel-threads: 16
|
parallel-threads: 8
|
||||||
use-binary-copy: false
|
use-binary-copy: false
|
||||||
|
|
||||||
|
# Writer 설정
|
||||||
writer:
|
writer:
|
||||||
use-advisory-lock: false
|
use-advisory-lock: false
|
||||||
parallel-threads: 8
|
parallel-threads: 4
|
||||||
|
|
||||||
|
# 재시도 설정
|
||||||
retry:
|
retry:
|
||||||
max-attempts: 3
|
max-attempts: 3
|
||||||
initial-interval: 1000
|
initial-interval: 1000
|
||||||
max-interval: 10000
|
max-interval: 10000
|
||||||
multiplier: 2
|
multiplier: 2
|
||||||
|
|
||||||
|
# 스케줄러 설정
|
||||||
|
scheduler:
|
||||||
|
enabled: true
|
||||||
|
incremental:
|
||||||
|
delay-minutes: 3 # 데이터 수집 지연 고려
|
||||||
|
|
||||||
|
# Batch 메타데이터 정리 설정
|
||||||
metadata:
|
metadata:
|
||||||
cleanup:
|
cleanup:
|
||||||
enabled: true
|
enabled: true # 자동 정리 활성화
|
||||||
retention-days: 21
|
retention-days: 21 # 보존 기간 (30일)
|
||||||
dry-run: false
|
dry-run: false # false: 실제 삭제, true: 테스트만
|
||||||
|
|
||||||
|
# 궤적 비정상 검출 설정
|
||||||
track:
|
track:
|
||||||
abnormal-detection:
|
abnormal-detection:
|
||||||
large-gap-threshold-hours: 4
|
large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함
|
||||||
extreme-speed-threshold: 1000
|
extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots)
|
||||||
enable-merger-filtering: false
|
enable-merger-filtering: false # VesselTrackMerger 필터링 활성화 (기본: false)
|
||||||
|
|
||||||
|
# 타임아웃 설정
|
||||||
|
query-timeout: 1800 # 30분
|
||||||
lock:
|
lock:
|
||||||
timeout: 30
|
timeout: 30
|
||||||
max-retry: 5
|
max-retry: 5
|
||||||
|
|
||||||
|
# Health Check 설정
|
||||||
health:
|
health:
|
||||||
job-timeout-hours: 2
|
job-timeout-hours: 2
|
||||||
min-partition-count: 1
|
min-partition-count: 1
|
||||||
|
|
||||||
|
# 그리드 설정
|
||||||
grid:
|
grid:
|
||||||
mode: haegu
|
mode: haegu
|
||||||
haegu:
|
haegu:
|
||||||
cache-enabled: true
|
cache-enabled: true
|
||||||
cache-size: 2000
|
cache-size: 2000
|
||||||
|
|
||||||
|
# 선박 최신 위치 캐시 설정 (운영 환경 활성화)
|
||||||
cache:
|
cache:
|
||||||
latest-position:
|
latest-position:
|
||||||
enabled: true
|
enabled: true # 운영 환경: 활성화
|
||||||
ttl-minutes: 60
|
ttl-minutes: 60 # 60분 TTL
|
||||||
max-size: 60000
|
max-size: 60000 # 최대 60,000척
|
||||||
refresh-interval-minutes: 2
|
refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려)
|
||||||
|
|
||||||
|
|
||||||
|
# 비정상 궤적 검출 설정 (개선됨)
|
||||||
abnormal-detection:
|
abnormal-detection:
|
||||||
enabled: true
|
enabled: true
|
||||||
5min-speed-threshold: 500
|
5min-speed-threshold: 500 # 5분 집계 비정상 속도 임계값 (200 knots로 완화)
|
||||||
|
# 비정상 판정 기준 (명백한 비정상만 검출하도록 완화)
|
||||||
thresholds:
|
thresholds:
|
||||||
min-movement-nm: 0.05
|
# 정박/저속 판단 기준
|
||||||
stationary-speed-knots: 0.5
|
min-movement-nm: 0.05 # 최소 이동거리 (정박 판단)
|
||||||
vessel-physical-limit-knots: 100.0
|
stationary-speed-knots: 0.5 # 정박 속도 기준
|
||||||
vessel-abnormal-speed-knots: 200.0
|
|
||||||
aircraft-physical-limit-knots: 600.0
|
# 선박 관련 임계값
|
||||||
aircraft-abnormal-speed-knots: 800.0
|
vessel-physical-limit-knots: 100.0 # 선박 물리적 한계
|
||||||
base-distance-5min-nm: 20.0
|
vessel-abnormal-speed-knots: 200.0 # 선박 명백한 비정상 속도
|
||||||
extreme-distance-5min-nm: 100.0
|
|
||||||
hourly-daily-speed-limit: 500.0
|
# 항공기 관련 임계값
|
||||||
distance-tolerance: 3.0
|
aircraft-physical-limit-knots: 600.0 # 항공기 물리적 한계
|
||||||
time-scaling-method: "sqrt"
|
aircraft-abnormal-speed-knots: 800.0 # 항공기 명백한 비정상 속도
|
||||||
|
|
||||||
|
# 거리 관련 임계값
|
||||||
|
base-distance-5min-nm: 20.0 # 5분 기준 거리 (20nm로 완화)
|
||||||
|
extreme-distance-5min-nm: 100.0 # 5분간 극단적 이동거리
|
||||||
|
|
||||||
|
# Hourly/Daily 전용 임계값
|
||||||
|
hourly-daily-speed-limit: 500.0 # 시간/일 집계시 극단적 속도만 검출
|
||||||
|
|
||||||
|
# 기타 설정
|
||||||
|
distance-tolerance: 3.0 # 거리 허용 배수 (3.0으로 완화)
|
||||||
|
time-scaling-method: "sqrt" # 시간 스케일링 방법 (sqrt)
|
||||||
|
# 캐시 설정
|
||||||
cache:
|
cache:
|
||||||
previous-track-size: 10000
|
previous-track-size: 10000 # 이전 궤적 캐시 크기
|
||||||
ttl-hours: 24
|
ttl-hours: 24 # 캐시 TTL
|
||||||
|
# 처리 옵션
|
||||||
processing:
|
processing:
|
||||||
remove-abnormal-segments: true
|
remove-abnormal-segments: true # 비정상 구간 제거 여부
|
||||||
save-corrected-tracks: true
|
save-corrected-tracks: true # 보정된 궤적 저장 여부
|
||||||
exclude-stationary-vessels: false
|
exclude-stationary-vessels: false # 정박 선박 제외 여부
|
||||||
lenient-mode: true
|
lenient-mode: true # 관대한 모드 활성화
|
||||||
|
|
||||||
|
# 파티션 관리 설정 (운영 환경 - application.yml 설정 오버라이드)
|
||||||
partition:
|
partition:
|
||||||
|
# 운영 환경에서는 더 긴 보관 기간 설정 가능
|
||||||
default-retention:
|
default-retention:
|
||||||
daily-partitions-retention-days: 7
|
daily-partitions-retention-days: 7 # 일별 파티션 7일 보관
|
||||||
monthly-partitions-retention-months: 3
|
monthly-partitions-retention-months: 3 # 월별 파티션 3개월 보관
|
||||||
|
|
||||||
tables:
|
tables:
|
||||||
|
# 중요 데이터는 더 오래 보관
|
||||||
t_area_vessel_tracks:
|
t_area_vessel_tracks:
|
||||||
retention-days: 60
|
retention-days: 60 # 구역별 선박 항적: 60일
|
||||||
t_grid_vessel_tracks:
|
t_grid_vessel_tracks:
|
||||||
retention-days: 30
|
retention-days: 30 # 해구별 선박 항적: 30일
|
||||||
t_abnormal_tracks:
|
t_abnormal_tracks:
|
||||||
retention-months: 0
|
retention-months: 0 # 비정상 항적: 무한 보관
|
||||||
|
|
||||||
# ── AIS API ──
|
# S&P AIS API 캐시 TTL (운영: 120분)
|
||||||
app:
|
app:
|
||||||
ais-api:
|
|
||||||
username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade
|
|
||||||
password: 2LLzSJNqtxWVD8zC
|
|
||||||
|
|
||||||
cache:
|
cache:
|
||||||
ais-target:
|
ais-target:
|
||||||
ttl-minutes: 120
|
ttl-minutes: 120
|
||||||
|
|
||||||
five-min-track:
|
five-min-track:
|
||||||
ttl-minutes: 75
|
ttl-minutes: 75
|
||||||
max-size: 500000
|
max-size: 500000
|
||||||
|
|
||||||
hourly-track:
|
hourly-track:
|
||||||
ttl-hours: 26
|
ttl-hours: 26
|
||||||
max-size: 780000
|
max-size: 780000
|
||||||
|
|
||||||
# ── 일일 항적 인메모리 캐시 (250GB 서버, 힙 64GB) ──
|
# 일일 항적 데이터 인메모리 캐시
|
||||||
cache:
|
cache:
|
||||||
daily-track:
|
daily-track:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention-days: 7
|
retention-days: 7 # D-1 ~ D-7 (오늘 제외)
|
||||||
max-memory-gb: 16
|
max-memory-gb: 6 # 최대 6GB (일 평균 ~720MB × 7일 = ~5GB)
|
||||||
warmup-async: true
|
warmup-async: true # 비동기 워밍업 (서버 시작 차단 없음)
|
||||||
|
|
||||||
# ── WebSocket 부하 제어 ──
|
# WebSocket 부하 제어 설정
|
||||||
websocket:
|
websocket:
|
||||||
query:
|
query:
|
||||||
max-concurrent-global: 30
|
max-concurrent-global: 30 # 서버 전체 동시 실행 쿼리 상한 (메모리 보호: 60→30)
|
||||||
max-per-session: 10
|
max-per-session: 10 # 세션당 동시 쿼리 상한 (20→10)
|
||||||
queue-timeout-seconds: 60
|
queue-timeout-seconds: 60 # 글로벌 대기 큐 타임아웃 (슬롯 감소 보완: 30→60)
|
||||||
transport:
|
transport:
|
||||||
message-size-limit-mb: 2
|
message-size-limit-mb: 2 # 단일 STOMP 메시지 상한 (50→2MB, 청크 1024KB 기준)
|
||||||
send-buffer-size-limit-mb: 50
|
send-buffer-size-limit-mb: 50 # 세션당 송신 버퍼 상한 (사전 할당 아님, 최악 30×50MB=1.5GB)
|
||||||
outbound-queue-capacity: 200
|
outbound-queue-capacity: 200 # 아웃바운드 메시지 큐 (5000→200)
|
||||||
send-time-limit-seconds: 30
|
send-time-limit-seconds: 30 # 메시지 전송 시간 제한
|
||||||
memory:
|
memory:
|
||||||
heap-reject-threshold: 0.85
|
heap-reject-threshold: 0.85 # 힙 사용률 85% 초과 시 새 쿼리 대기열 전환 (기본 20~24GB → 정상시 50% 미만)
|
||||||
session:
|
session:
|
||||||
idle-timeout-ms: 15000
|
idle-timeout-ms: 15000 # 세션 유휴 타임아웃 15초 (60s → 15s)
|
||||||
server-heartbeat-ms: 5000
|
server-heartbeat-ms: 5000 # 서버 하트비트 5초 (10s → 5s)
|
||||||
client-heartbeat-ms: 5000
|
client-heartbeat-ms: 5000 # 클라이언트 하트비트 5초 (10s → 5s)
|
||||||
sockjs-disconnect-delay-ms: 5000
|
sockjs-disconnect-delay-ms: 5000 # SockJS 해제 지연 5초 (30s → 5s)
|
||||||
send-time-limit-seconds: 30
|
send-time-limit-seconds: 30 # 메시지 전송 시간 제한 30초 (120s → 30s)
|
||||||
|
|
||||||
# ── REST V2 부하 제어 ──
|
# REST V2 부하 제어 설정
|
||||||
rest:
|
rest:
|
||||||
v2:
|
v2:
|
||||||
query:
|
query:
|
||||||
timeout-seconds: 30
|
timeout-seconds: 30 # 슬롯 대기 타임아웃 (초)
|
||||||
max-total-points: 500000
|
max-total-points: 500000 # 응답 총 포인트 상한
|
||||||
|
|
||||||
# ── 액추에이터 ──
|
# 액추에이터 설정
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@ -1,7 +1,15 @@
|
|||||||
|
server:
|
||||||
|
shutdown: graceful
|
||||||
|
servlet:
|
||||||
|
context-path: /signal-batch
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: vessel-batch-aggregation
|
name: vessel-batch-aggregation
|
||||||
|
|
||||||
|
lifecycle:
|
||||||
|
timeout-per-shutdown-phase: 3m # graceful shutdown 대기 (진행 중 Job 완료)
|
||||||
|
|
||||||
main:
|
main:
|
||||||
allow-bean-definition-overriding: true # Bean 중복 정의 허용
|
allow-bean-definition-overriding: true # Bean 중복 정의 허용
|
||||||
|
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,576 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>GIS 모니터링 - 선박 항적</title>
|
|
||||||
<!-- Local CSS -->
|
|
||||||
<link href="/libs/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/libs/css/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<link href="/libs/css/maplibre-gl.css" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mapContainer {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 400px;
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: white;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
max-width: 300px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1002;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
margin-right: 8px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-gradient {
|
|
||||||
width: 200px;
|
|
||||||
height: 20px;
|
|
||||||
margin: 10px 0;
|
|
||||||
background: linear-gradient(to right, #e0e0e0 0%, #90EE90 25%, #FFD700 50%, #FFA500 75%, #FF4500 100%);
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-labels {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hexagon Legend Styles */
|
|
||||||
.hexagon-legend {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hexagon-legend .legend-gradient {
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
rgba(1, 152, 189, 0.8) 0%,
|
|
||||||
rgba(73, 227, 206, 0.8) 25%,
|
|
||||||
rgba(216, 254, 181, 0.8) 50%,
|
|
||||||
rgba(254, 237, 177, 0.8) 65%,
|
|
||||||
rgba(254, 173, 84, 0.8) 80%,
|
|
||||||
rgba(209, 55, 78, 0.8) 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Layer Toggle Button Group */
|
|
||||||
.layer-toggle-group {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 1000;
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-toggle-group .btn {
|
|
||||||
min-width: 100px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layer-toggle-group .btn.active {
|
|
||||||
box-shadow: inset 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-info-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 420px;
|
|
||||||
width: 350px;
|
|
||||||
max-height: 400px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: none;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
z-index: 1001;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item {
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid #ddd;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item.selected {
|
|
||||||
background: #e3f2fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-checkbox {
|
|
||||||
margin-right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-controls {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.selected-count {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sequential Passage Panel Styles */
|
|
||||||
.sequential-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
width: 380px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 15px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
z-index: 1001;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-selector {
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 10px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-tag {
|
|
||||||
display: inline-block;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 8px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 2px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-tag .remove-zone {
|
|
||||||
margin-left: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
color: white;
|
|
||||||
font-size: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
||||||
z-index: 10000;
|
|
||||||
display: none;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item {
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item.disabled {
|
|
||||||
color: #999;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item.disabled:hover {
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="mapContainer">
|
|
||||||
<!-- Layer Toggle Buttons -->
|
|
||||||
<div class="layer-toggle-group btn-group" role="group" aria-label="Layer toggle">
|
|
||||||
<button type="button" class="btn btn-primary active" id="gridLayerBtn" onclick="GISApp.setVisualizationMode('grid')">
|
|
||||||
<i class="bi bi-grid-3x3"></i> Grid
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-outline-primary" id="hexagonLayerBtn" onclick="GISApp.setVisualizationMode('hexagon')">
|
|
||||||
<i class="bi bi-hexagon"></i> Hexagon
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="control-panel">
|
|
||||||
<h5 class="mb-3">GIS 모니터링</h5>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">조회 기간</label>
|
|
||||||
<select class="form-select form-select-sm" id="gisTimeRange">
|
|
||||||
<option value="60" selected>최근 1시간</option>
|
|
||||||
<option value="180">최근 3시간</option>
|
|
||||||
<option value="360">최근 6시간</option>
|
|
||||||
<option value="720">최근 12시간</option>
|
|
||||||
<option value="1440">최근 24시간</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">레이어 유형</label>
|
|
||||||
<select class="form-select form-select-sm" id="gisLayerType">
|
|
||||||
<option value="haegu">대해구</option>
|
|
||||||
<option value="area">사용자 정의 구역</option>
|
|
||||||
<option value="both" selected>전체</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label">항적 조회 기간</label>
|
|
||||||
<select class="form-select form-select-sm" id="trackPeriod">
|
|
||||||
<option value="60">최근 1시간</option>
|
|
||||||
<option value="180">최근 3시간</option>
|
|
||||||
<option value="360">최근 6시간</option>
|
|
||||||
<option value="720">최근 12시간</option>
|
|
||||||
<option value="1440">최근 24시간</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm w-100 mb-2" onclick="GISApp.refreshData()">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> 새로고침
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success btn-sm w-100" onclick="GISApp.toggleSequentialPanel()">
|
|
||||||
<i class="bi bi-diagram-3"></i> 순차 통과 분석
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Vessel List Section -->
|
|
||||||
<div id="vesselListSection" style="display: none;">
|
|
||||||
<hr>
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<h6 id="vesselListTitle" class="mb-0">구역 내 선박</h6>
|
|
||||||
<span class="selected-count" id="selectedCount" style="display: none;">0</span>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="GISApp.hideVesselList()">
|
|
||||||
<i class="bi bi-chevron-up"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sort Controls -->
|
|
||||||
<div class="sort-controls">
|
|
||||||
<label class="form-label mb-0 me-2">정렬:</label>
|
|
||||||
<select class="form-select form-select-sm" id="sortBy" style="width: auto;">
|
|
||||||
<option value="id">ID</option>
|
|
||||||
<option value="speed">속력</option>
|
|
||||||
<option value="distance">거리</option>
|
|
||||||
</select>
|
|
||||||
<select class="form-select form-select-sm" id="sortOrder" style="width: auto;">
|
|
||||||
<option value="asc">오름차순</option>
|
|
||||||
<option value="desc">내림차순</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<button class="btn btn-sm btn-primary" onclick="GISApp.selectAllVessels()">
|
|
||||||
<i class="bi bi-check-all"></i> 전체 선택
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary" onclick="GISApp.deselectAllVessels()">
|
|
||||||
<i class="bi bi-x-square"></i> 선택 해제
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-success" onclick="GISApp.showSelectedTracks()">
|
|
||||||
<i class="bi bi-geo-alt"></i> 항적 표시
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="vesselListContent" style="max-height: 400px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="map-controls">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="showTracks">
|
|
||||||
<label class="form-check-label" for="showTracks">
|
|
||||||
선박 항적 표시
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="showAllTracks">
|
|
||||||
<label class="form-check-label" for="showAllTracks">
|
|
||||||
전체 항적 표시
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="showHeatmap">
|
|
||||||
<label class="form-check-label" for="showHeatmap">
|
|
||||||
밀도 히트맵 표시
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Track Filters -->
|
|
||||||
<div class="filter-controls">
|
|
||||||
<h6>필터 설정</h6>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small mb-1">속력 범위 (kts)</label>
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minSpeedFilter"
|
|
||||||
min="0" max="50" value="0" step="0.5" style="width: 70px;">
|
|
||||||
<span>-</span>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="maxSpeedFilter"
|
|
||||||
min="0" max="50" value="50" step="0.5" style="width: 70px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<label class="form-label small mb-1">거리 범위 (nm)</label>
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minDistFilter"
|
|
||||||
min="0" max="200" value="0" step="1" style="width: 70px;">
|
|
||||||
<span>-</span>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="maxDistFilter"
|
|
||||||
min="0" max="200" value="200" step="1" style="width: 70px;">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-sm btn-primary w-100" onclick="GISApp.applyFilters()">
|
|
||||||
<i class="bi bi-funnel"></i> 필터 적용
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-secondary w-100 mt-1" onclick="GISApp.resetFilters()">
|
|
||||||
<i class="bi bi-arrow-counterclockwise"></i> 필터 초기화
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="track-info-panel" id="trackInfoPanel">
|
|
||||||
<h5 id="trackPanelTitle">구역 정보</h5>
|
|
||||||
<div id="trackPanelContent"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sequential Passage Panel -->
|
|
||||||
<div class="sequential-panel" id="sequentialPanel">
|
|
||||||
<h5 class="mb-3">순차 통과 분석</h5>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">통과 모드</label>
|
|
||||||
<select class="form-select form-select-sm" id="passageMode">
|
|
||||||
<option value="sequential">순차 통과</option>
|
|
||||||
<option value="all">전체 구역</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">구역 유형</label>
|
|
||||||
<select class="form-select form-select-sm" id="passageZoneType">
|
|
||||||
<option value="GRID">대해구</option>
|
|
||||||
<option value="AREA">사용자 정의 구역</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">조회 기간</label>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="datetime-local" class="form-control form-control-sm" id="passageStartTime">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="datetime-local" class="form-control form-control-sm" id="passageEndTime">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="zone-selector">
|
|
||||||
<label class="form-label">선택 구역 (최대 3개)</label>
|
|
||||||
<div id="selectedZones" class="mb-2"></div>
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<input type="text" class="form-control" id="zoneInput" placeholder="구역 ID 입력">
|
|
||||||
<button class="btn btn-outline-secondary" onclick="GISApp.addZone()">
|
|
||||||
<i class="bi bi-plus"></i> 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button class="btn btn-primary btn-sm w-100 mb-2" onclick="GISApp.searchSequentialPassage()">
|
|
||||||
<i class="bi bi-search"></i> 선박 검색
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="btn btn-secondary btn-sm w-100" onclick="GISApp.toggleSequentialPanel()">
|
|
||||||
<i class="bi bi-x"></i> 닫기
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div id="sequentialResults" class="mt-3" style="max-height: 300px; overflow-y: auto;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="legend" id="gridLegend">
|
|
||||||
<h6>범례</h6>
|
|
||||||
<div class="mb-2"><strong>선박 수</strong></div>
|
|
||||||
<div class="legend-gradient"></div>
|
|
||||||
<div class="legend-labels">
|
|
||||||
<span>0</span>
|
|
||||||
<span>10</span>
|
|
||||||
<span>50</span>
|
|
||||||
<span>100</span>
|
|
||||||
<span>200+</span>
|
|
||||||
</div>
|
|
||||||
<hr class="my-2">
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: rgba(0, 100, 200, 0.5); border: 2px solid #0064C8;"></div>
|
|
||||||
<span>대해구</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: rgba(200, 100, 0, 0.5); border: 2px solid #C86400;"></div>
|
|
||||||
<span>사용자 정의 구역</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #00ff00;"></div>
|
|
||||||
<span>선박 항적</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #0080ff;"></div>
|
|
||||||
<span>선택된 항적</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hexagon Layer Legend -->
|
|
||||||
<div class="legend hexagon-legend" id="hexagonLegend">
|
|
||||||
<h6>Hexagon 범례</h6>
|
|
||||||
<div class="mb-2"><strong>선박 밀도</strong></div>
|
|
||||||
<div class="legend-gradient"></div>
|
|
||||||
<div class="legend-labels" id="hexagonLegendLabels">
|
|
||||||
<span>0</span>
|
|
||||||
<span>5</span>
|
|
||||||
<span>10</span>
|
|
||||||
<span>20</span>
|
|
||||||
<span>50+</span>
|
|
||||||
</div>
|
|
||||||
<hr class="my-2">
|
|
||||||
<div id="hexagonStats">
|
|
||||||
<div><strong>총 선박 수:</strong> <span id="hexTotalVessels">0</span></div>
|
|
||||||
<div><strong>셀 당 최대:</strong> <span id="hexMaxCount">0</span></div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<small class="text-muted"><i class="bi bi-info-circle"></i> Hexagon 클릭 시 선박 목록 표시</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="loading-overlay" id="loadingOverlay">
|
|
||||||
<div class="loading-spinner">
|
|
||||||
<i class="bi bi-hourglass-split"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="vessel-tooltip" id="vesselTooltip" style="display: none;"></div>
|
|
||||||
|
|
||||||
<!-- Context Menu -->
|
|
||||||
<div class="context-menu" id="contextMenu">
|
|
||||||
<div class="context-menu-item" id="addToSequential">
|
|
||||||
<i class="bi bi-plus-circle"></i> 순차 통과에 추가
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" id="viewAreaDetails">
|
|
||||||
<i class="bi bi-info-circle"></i> 상세 정보 보기
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Local JS -->
|
|
||||||
<script src="/libs/js/jquery-3.6.0.min.js"></script>
|
|
||||||
<script src="/libs/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/libs/js/maplibre-gl.js"></script>
|
|
||||||
<script src="/libs/js/deck.gl.min.js"></script>
|
|
||||||
<script src="/js/gis-monitoring.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,904 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Vessel Batch 통합 모니터링</title>
|
|
||||||
<link href="/libs/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/libs/css/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
<script src="/libs/js/chart.min.js"></script>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
.nav-tabs .nav-link {
|
|
||||||
color: #495057;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
.nav-tabs .nav-link.active {
|
|
||||||
color: #007bff;
|
|
||||||
background-color: white;
|
|
||||||
border-color: #dee2e6 #dee2e6 white;
|
|
||||||
}
|
|
||||||
.tab-content {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border: 1px solid #dee2e6;
|
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
|
||||||
.metric-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.metric-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
.status-normal {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
.status-warning {
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
.status-critical {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 400px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 40px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
.error-message {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
.refresh-button {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
.auto-refresh-indicator {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
background: white;
|
|
||||||
padding: 10px 20px;
|
|
||||||
border-radius: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
.table-container {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
.sticky-header th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
.density-bar {
|
|
||||||
height: 20px;
|
|
||||||
background: #e9ecef;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.density-fill {
|
|
||||||
height: 100%;
|
|
||||||
transition: width 0.5s ease;
|
|
||||||
}
|
|
||||||
.quality-badge {
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 20px;
|
|
||||||
font-weight: 500;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
.quality-good {
|
|
||||||
background: #d4edda;
|
|
||||||
color: #155724;
|
|
||||||
}
|
|
||||||
.quality-attention {
|
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
|
||||||
}
|
|
||||||
.quality-error {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<nav class="navbar navbar-dark bg-primary">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<span class="navbar-brand mb-0 h1">
|
|
||||||
<i class="bi bi-speedometer2"></i> Vessel Batch 통합 모니터링
|
|
||||||
</span>
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<div class="form-check form-switch me-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="autoRefreshToggle" checked>
|
|
||||||
<label class="form-check-label text-white" for="autoRefreshToggle">
|
|
||||||
자동 새로고침
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<span class="navbar-text text-white" id="currentTime"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div class="auto-refresh-indicator">
|
|
||||||
<span id="lastUpdate">데이터 로딩 중...</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="container-fluid mt-4">
|
|
||||||
<!-- 탭 네비게이션 -->
|
|
||||||
<ul class="nav nav-tabs" id="monitoringTabs" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button">
|
|
||||||
<i class="bi bi-grid-3x3-gap"></i> 개요
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="haegu-tab" data-bs-toggle="tab" data-bs-target="#haegu" type="button">
|
|
||||||
<i class="bi bi-map"></i> 대해구 현황
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="delay-tab" data-bs-toggle="tab" data-bs-target="#delay" type="button">
|
|
||||||
<i class="bi bi-clock-history"></i> 처리 지연
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="throughput-tab" data-bs-toggle="tab" data-bs-target="#throughput" type="button">
|
|
||||||
<i class="bi bi-graph-up"></i> 처리량
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="quality-tab" data-bs-toggle="tab" data-bs-target="#quality" type="button">
|
|
||||||
<i class="bi bi-shield-check"></i> 데이터 품질
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link" id="system-tab" data-bs-toggle="tab" data-bs-target="#system" type="button">
|
|
||||||
<i class="bi bi-cpu"></i> 시스템
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- 탭 컨텐츠 -->
|
|
||||||
<div class="tab-content" id="monitoringTabContent">
|
|
||||||
<!-- 개요 탭 -->
|
|
||||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
|
||||||
<h4 class="mb-4">시스템 개요</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">처리 지연</div>
|
|
||||||
<div class="metric-value" id="overview-delay">-</div>
|
|
||||||
<small class="text-muted">분</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">시간당 처리량</div>
|
|
||||||
<div class="metric-value" id="overview-throughput">-</div>
|
|
||||||
<small class="text-muted">선박/시간</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">활성 대해구</div>
|
|
||||||
<div class="metric-value" id="overview-haegus">-</div>
|
|
||||||
<small class="text-muted">개</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">데이터 품질</div>
|
|
||||||
<div class="metric-value" id="overview-quality">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<h5>최근 알림</h5>
|
|
||||||
<div id="overview-alerts" class="list-group">
|
|
||||||
<div class="list-group-item">알림이 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 대해구 현황 탭 -->
|
|
||||||
<div class="tab-pane fade" id="haegu" role="tabpanel">
|
|
||||||
<h4 class="mb-4">대해구별 선박 현황</h4>
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="haeguChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="densityChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="table-container">
|
|
||||||
<table class="table table-hover sticky-header">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>대해구</th>
|
|
||||||
<th>대해구명</th>
|
|
||||||
<th>활성 타일</th>
|
|
||||||
<th>선박 수</th>
|
|
||||||
<th>평균 밀도</th>
|
|
||||||
<th>최대 타일 선박</th>
|
|
||||||
<th>밀도 분포</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="haeguTableBody">
|
|
||||||
<tr><td colspan="7" class="loading">데이터 로딩 중...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 처리 지연 탭 -->
|
|
||||||
<div class="tab-pane fade" id="delay" role="tabpanel">
|
|
||||||
<h4 class="mb-4">데이터 처리 지연 현황</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">현재 지연 시간</div>
|
|
||||||
<div class="metric-value" id="delay-minutes">-</div>
|
|
||||||
<small class="text-muted">분</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">상태</div>
|
|
||||||
<div class="metric-value" id="delay-status">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">최근 수집 건수</div>
|
|
||||||
<div class="metric-value" id="delay-collect-count">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>수집 DB 상태</h5>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<strong>최신 데이터 시간:</strong> <span id="collect-latest-time">-</span><br>
|
|
||||||
<strong>10분 내 수집 건수:</strong> <span id="collect-recent-count">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<h5>조회 DB 상태</h5>
|
|
||||||
<div class="alert alert-info">
|
|
||||||
<strong>최신 처리 시간:</strong> <span id="query-latest-time">-</span><br>
|
|
||||||
<strong>처리된 타일 수:</strong> <span id="query-processed-tiles">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 처리량 탭 -->
|
|
||||||
<div class="tab-pane fade" id="throughput" role="tabpanel">
|
|
||||||
<h4 class="mb-4">시스템 처리량</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">분당 평균 처리량</div>
|
|
||||||
<div class="metric-value" id="throughput-per-minute">-</div>
|
|
||||||
<small class="text-muted">선박/분</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">시간당 평균 처리량</div>
|
|
||||||
<div class="metric-value" id="throughput-per-hour">-</div>
|
|
||||||
<small class="text-muted">선박/시간</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">활성 파티션</div>
|
|
||||||
<div class="metric-value" id="active-partitions">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="throughputChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<h5>파티션 크기</h5>
|
|
||||||
<table class="table table-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>파티션명</th>
|
|
||||||
<th>크기</th>
|
|
||||||
<th>비율</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="partitionTableBody">
|
|
||||||
<tr><td colspan="3" class="loading">데이터 로딩 중...</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 데이터 품질 탭 -->
|
|
||||||
<div class="tab-pane fade" id="quality" role="tabpanel">
|
|
||||||
<h4 class="mb-4">데이터 품질 검증</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">중복 레코드</div>
|
|
||||||
<div class="metric-value" id="quality-duplicates">-</div>
|
|
||||||
<small class="text-muted">건</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">누락된 타일</div>
|
|
||||||
<div class="metric-value" id="quality-missing">-</div>
|
|
||||||
<small class="text-muted">개</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">품질 점수</div>
|
|
||||||
<div class="metric-value">
|
|
||||||
<span id="quality-score" class="quality-badge">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<h5>품질 체크 이력</h5>
|
|
||||||
<div id="quality-history" class="list-group">
|
|
||||||
<div class="list-group-item">체크 이력이 없습니다.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 시스템 탭 -->
|
|
||||||
<div class="tab-pane fade" id="system" role="tabpanel">
|
|
||||||
<h4 class="mb-4">시스템 리소스</h4>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">메모리 사용량</div>
|
|
||||||
<div class="metric-value" id="system-memory">-</div>
|
|
||||||
<div class="progress mt-2">
|
|
||||||
<div class="progress-bar" id="memory-progress" style="width: 0%"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">활성 스레드</div>
|
|
||||||
<div class="metric-value" id="system-threads">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">DB 연결</div>
|
|
||||||
<div class="metric-value" id="system-db-connections">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-3">
|
|
||||||
<div class="metric-card">
|
|
||||||
<div class="metric-label">CPU 코어</div>
|
|
||||||
<div class="metric-value" id="system-cpu-cores">-</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row mt-4">
|
|
||||||
<div class="col-md-12">
|
|
||||||
<div class="chart-container">
|
|
||||||
<canvas id="systemChart"></canvas>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 새로고침 버튼 -->
|
|
||||||
<button class="btn btn-primary btn-lg refresh-button" onclick="refreshCurrentTab()">
|
|
||||||
<i class="bi bi-arrow-clockwise"></i> 새로고침
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<script src="/libs/js/jquery-3.6.0.min.js"></script>
|
|
||||||
<script src="/libs/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/libs/js/chartjs-adapter-date-fns.bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
// 전역 변수
|
|
||||||
let charts = {};
|
|
||||||
let refreshInterval;
|
|
||||||
let currentTab = 'overview';
|
|
||||||
let qualityHistory = [];
|
|
||||||
|
|
||||||
// 초기화
|
|
||||||
$(document).ready(function() {
|
|
||||||
initCharts();
|
|
||||||
loadAllData();
|
|
||||||
|
|
||||||
// 탭 변경 이벤트
|
|
||||||
$('button[data-bs-toggle="tab"]').on('shown.bs.tab', function (e) {
|
|
||||||
currentTab = $(e.target).attr('id').replace('-tab', '');
|
|
||||||
loadTabData(currentTab);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 자동 새로고침 토글
|
|
||||||
$('#autoRefreshToggle').change(function() {
|
|
||||||
if ($(this).is(':checked')) {
|
|
||||||
startAutoRefresh();
|
|
||||||
} else {
|
|
||||||
stopAutoRefresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 시계 업데이트
|
|
||||||
setInterval(updateClock, 1000);
|
|
||||||
|
|
||||||
// 자동 새로고침 시작
|
|
||||||
startAutoRefresh();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 차트 초기화
|
|
||||||
function initCharts() {
|
|
||||||
// 대해구 차트
|
|
||||||
const haeguCtx = document.getElementById('haeguChart').getContext('2d');
|
|
||||||
charts.haegu = new Chart(haeguCtx, {
|
|
||||||
type: 'bar',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: '선박 수',
|
|
||||||
data: [],
|
|
||||||
backgroundColor: 'rgba(0, 123, 255, 0.6)',
|
|
||||||
borderColor: 'rgba(0, 123, 255, 1)',
|
|
||||||
borderWidth: 1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: '대해구별 선박 수 TOP 10'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 밀도 차트
|
|
||||||
const densityCtx = document.getElementById('densityChart').getContext('2d');
|
|
||||||
charts.density = new Chart(densityCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: '평균 밀도',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(255, 99, 132)',
|
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
|
||||||
tension: 0.1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: '대해구별 선박 밀도'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 처리량 차트
|
|
||||||
const throughputCtx = document.getElementById('throughputChart').getContext('2d');
|
|
||||||
charts.throughput = new Chart(throughputCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: '분당 처리량',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(75, 192, 192)',
|
|
||||||
backgroundColor: 'rgba(75, 192, 192, 0.1)',
|
|
||||||
tension: 0.1
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: '시간별 처리량 추이'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
type: 'time',
|
|
||||||
time: {
|
|
||||||
unit: 'minute',
|
|
||||||
displayFormats: {
|
|
||||||
minute: 'HH:mm'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 시스템 차트
|
|
||||||
const systemCtx = document.getElementById('systemChart').getContext('2d');
|
|
||||||
charts.system = new Chart(systemCtx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [{
|
|
||||||
label: '메모리 사용률 (%)',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(255, 206, 86)',
|
|
||||||
backgroundColor: 'rgba(255, 206, 86, 0.1)',
|
|
||||||
tension: 0.1,
|
|
||||||
yAxisID: 'y'
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
plugins: {
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: '시스템 리소스 사용 추이'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
y: {
|
|
||||||
type: 'linear',
|
|
||||||
display: true,
|
|
||||||
position: 'left',
|
|
||||||
min: 0,
|
|
||||||
max: 100
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 데이터 로드
|
|
||||||
async function loadAllData() {
|
|
||||||
try {
|
|
||||||
await Promise.all([
|
|
||||||
loadOverviewData(),
|
|
||||||
loadHaeguData(),
|
|
||||||
loadDelayData(),
|
|
||||||
loadThroughputData(),
|
|
||||||
loadQualityData(),
|
|
||||||
loadSystemData()
|
|
||||||
]);
|
|
||||||
updateLastUpdateTime();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 탭별 데이터 로드
|
|
||||||
async function loadTabData(tab) {
|
|
||||||
try {
|
|
||||||
switch(tab) {
|
|
||||||
case 'overview':
|
|
||||||
await loadOverviewData();
|
|
||||||
break;
|
|
||||||
case 'haegu':
|
|
||||||
await loadHaeguData();
|
|
||||||
break;
|
|
||||||
case 'delay':
|
|
||||||
await loadDelayData();
|
|
||||||
break;
|
|
||||||
case 'throughput':
|
|
||||||
await loadThroughputData();
|
|
||||||
break;
|
|
||||||
case 'quality':
|
|
||||||
await loadQualityData();
|
|
||||||
break;
|
|
||||||
case 'system':
|
|
||||||
await loadSystemData();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
updateLastUpdateTime();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load ${tab} data:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 개요 데이터 로드
|
|
||||||
async function loadOverviewData() {
|
|
||||||
try {
|
|
||||||
// 대해구 통계 (기존 API 사용)
|
|
||||||
const haeguStats = await $.get('/admin/haegu/stats');
|
|
||||||
$('#overview-haegus').text(haeguStats.length || 0);
|
|
||||||
|
|
||||||
// 시스템 메트릭
|
|
||||||
const metrics = await $.get('/admin/metrics/summary');
|
|
||||||
|
|
||||||
// 기본값 설정
|
|
||||||
$('#overview-delay').text('-');
|
|
||||||
$('#overview-throughput').text('-');
|
|
||||||
$('#overview-quality').text('확인 중');
|
|
||||||
|
|
||||||
// 알림 업데이트
|
|
||||||
updateAlerts({}, {qualityScore: 'GOOD'});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load overview data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대해구 데이터 로드
|
|
||||||
async function loadHaeguData() {
|
|
||||||
try {
|
|
||||||
const data = await $.get('/admin/haegu/stats');
|
|
||||||
|
|
||||||
if (data && data.length > 0) {
|
|
||||||
updateHaeguCharts(data);
|
|
||||||
updateHaeguTable(data);
|
|
||||||
} else {
|
|
||||||
$('#haeguTableBody').html('<tr><td colspan="7" class="text-center">데이터가 없습니다.</td></tr>');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load haegu data:', error);
|
|
||||||
$('#haeguTableBody').html('<tr><td colspan="7" class="text-center text-danger">데이터 로드 실패</td></tr>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시스템 데이터 로드
|
|
||||||
async function loadSystemData() {
|
|
||||||
try {
|
|
||||||
const data = await $.get('/admin/metrics/summary');
|
|
||||||
|
|
||||||
if (data.memory) {
|
|
||||||
const memoryUsage = Math.round((data.memory.used / data.memory.total) * 100);
|
|
||||||
$('#system-memory').text(memoryUsage + '%');
|
|
||||||
$('#memory-progress').css('width', memoryUsage + '%');
|
|
||||||
|
|
||||||
// 시스템 차트 업데이트
|
|
||||||
updateSystemChart(memoryUsage);
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#system-threads').text(data.threads || '-');
|
|
||||||
$('#system-db-connections').text(data.database?.activeConnections || '-');
|
|
||||||
|
|
||||||
// CPU 코어 수는 서버에서 제공하지 않으므로 임시값
|
|
||||||
$('#system-cpu-cores').text('8');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load system data:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 나머지 탭들은 MonitoringController가 구현되면 활성화
|
|
||||||
async function loadDelayData() {
|
|
||||||
// MonitoringController 구현 후 활성화
|
|
||||||
$('#delay-minutes').text('6');
|
|
||||||
$('#delay-status').text('NORMAL').addClass('status-normal');
|
|
||||||
$('#delay-collect-count').text('125,432');
|
|
||||||
$('#collect-latest-time').text(new Date().toLocaleString());
|
|
||||||
$('#query-latest-time').text(new Date(Date.now() - 6*60*1000).toLocaleString());
|
|
||||||
$('#collect-recent-count').text('125,432');
|
|
||||||
$('#query-processed-tiles').text('11,979');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadThroughputData() {
|
|
||||||
// MonitoringController 구현 후 활성화
|
|
||||||
$('#throughput-per-minute').text('2,090');
|
|
||||||
$('#throughput-per-hour').text('125,400');
|
|
||||||
$('#active-partitions').text('7');
|
|
||||||
|
|
||||||
// 샘플 파티션 데이터
|
|
||||||
const partitions = [
|
|
||||||
{tablename: 't_tile_summary_250718', size: '1109 MB', size_bytes: 1163591680},
|
|
||||||
{tablename: 't_tile_summary_250717', size: '0 bytes', size_bytes: 0}
|
|
||||||
];
|
|
||||||
updatePartitionTable(partitions);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadQualityData() {
|
|
||||||
// MonitoringController 구현 후 활성화
|
|
||||||
$('#quality-duplicates').text('0');
|
|
||||||
$('#quality-missing').text('0');
|
|
||||||
$('#quality-score').text('GOOD').removeClass().addClass('quality-badge quality-good');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 차트 업데이트 함수들
|
|
||||||
function updateHaeguCharts(data) {
|
|
||||||
const top10 = data.slice(0, 10);
|
|
||||||
|
|
||||||
charts.haegu.data.labels = top10.map(item => 'H' + item.haegu_no);
|
|
||||||
charts.haegu.data.datasets[0].data = top10.map(item => item.total_vessels || 0);
|
|
||||||
charts.haegu.update();
|
|
||||||
|
|
||||||
charts.density.data.labels = top10.map(item => 'H' + item.haegu_no);
|
|
||||||
charts.density.data.datasets[0].data = top10.map(item => item.avg_density || 0);
|
|
||||||
charts.density.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateHaeguTable(data) {
|
|
||||||
const tbody = $('#haeguTableBody');
|
|
||||||
tbody.empty();
|
|
||||||
|
|
||||||
const maxDensity = Math.max(...data.map(item => item.avg_density || 0));
|
|
||||||
|
|
||||||
data.forEach(item => {
|
|
||||||
const densityPercent = maxDensity > 0 ? (item.avg_density / maxDensity * 100) : 0;
|
|
||||||
const densityColor = getDensityColor(densityPercent);
|
|
||||||
|
|
||||||
const row = `
|
|
||||||
<tr>
|
|
||||||
<td><strong>H${item.haegu_no}</strong></td>
|
|
||||||
<td>-</td>
|
|
||||||
<td>${item.tile_count || 0}</td>
|
|
||||||
<td>${(item.total_vessels || 0).toLocaleString()}</td>
|
|
||||||
<td>${(item.avg_density || 0).toFixed(2)}</td>
|
|
||||||
<td>-</td>
|
|
||||||
<td>
|
|
||||||
<div class="density-bar">
|
|
||||||
<div class="density-fill" style="width: ${densityPercent}%; background: ${densityColor}"></div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
tbody.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSystemChart(memoryUsage) {
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
if (charts.system.data.labels.length > 30) {
|
|
||||||
charts.system.data.labels.shift();
|
|
||||||
charts.system.data.datasets[0].data.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
charts.system.data.labels.push(now);
|
|
||||||
charts.system.data.datasets[0].data.push(memoryUsage);
|
|
||||||
charts.system.update('none');
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePartitionTable(partitions) {
|
|
||||||
const tbody = $('#partitionTableBody');
|
|
||||||
tbody.empty();
|
|
||||||
|
|
||||||
const totalSize = partitions.reduce((sum, p) => sum + (p.size_bytes || 0), 0);
|
|
||||||
|
|
||||||
partitions.forEach(partition => {
|
|
||||||
const sizePercent = totalSize > 0 ? (partition.size_bytes / totalSize * 100) : 0;
|
|
||||||
const row = `
|
|
||||||
<tr>
|
|
||||||
<td>${partition.tablename}</td>
|
|
||||||
<td>${partition.size}</td>
|
|
||||||
<td>
|
|
||||||
<div class="progress" style="height: 20px;">
|
|
||||||
<div class="progress-bar" style="width: ${sizePercent}%">${sizePercent.toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
`;
|
|
||||||
tbody.append(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유틸리티 함수들
|
|
||||||
function getDensityColor(percent) {
|
|
||||||
if (percent < 33) return '#28a745';
|
|
||||||
if (percent < 66) return '#ffc107';
|
|
||||||
return '#dc3545';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(dateTime) {
|
|
||||||
if (!dateTime) return '-';
|
|
||||||
return new Date(dateTime).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateClock() {
|
|
||||||
$('#currentTime').text(new Date().toLocaleString());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLastUpdateTime() {
|
|
||||||
$('#lastUpdate').text('마지막 업데이트: ' + new Date().toLocaleString());
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAlerts(delayData, qualityData) {
|
|
||||||
const alerts = $('#overview-alerts');
|
|
||||||
alerts.empty();
|
|
||||||
|
|
||||||
if (delayData.delayMinutes > 30) {
|
|
||||||
alerts.append(`
|
|
||||||
<div class="list-group-item list-group-item-danger">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
|
||||||
데이터 처리가 ${delayData.delayMinutes}분 지연되고 있습니다.
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (qualityData.qualityScore === 'NEEDS_ATTENTION') {
|
|
||||||
alerts.append(`
|
|
||||||
<div class="list-group-item list-group-item-warning">
|
|
||||||
<i class="bi bi-exclamation-circle"></i>
|
|
||||||
데이터 품질 확인이 필요합니다.
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alerts.children().length === 0) {
|
|
||||||
alerts.append('<div class="list-group-item">모든 시스템이 정상입니다.</div>');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 자동 새로고침
|
|
||||||
function startAutoRefresh() {
|
|
||||||
if (refreshInterval) clearInterval(refreshInterval);
|
|
||||||
refreshInterval = setInterval(() => {
|
|
||||||
loadTabData(currentTab);
|
|
||||||
}, 30000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAutoRefresh() {
|
|
||||||
if (refreshInterval) {
|
|
||||||
clearInterval(refreshInterval);
|
|
||||||
refreshInterval = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshCurrentTab() {
|
|
||||||
loadTabData(currentTab);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,520 +0,0 @@
|
|||||||
// Animation Controller for vessel tracking
|
|
||||||
// Optimized for 20,000-30,000 vessels with dynamic FPS adjustment
|
|
||||||
|
|
||||||
class VesselAnimationController {
|
|
||||||
constructor(map, deckOverlay, trackStore) {
|
|
||||||
this.map = map;
|
|
||||||
this.deckOverlay = deckOverlay;
|
|
||||||
this.trackStore = trackStore;
|
|
||||||
|
|
||||||
this.animationFrame = null;
|
|
||||||
this.lastFrameTime = 0;
|
|
||||||
this.frameCount = 0;
|
|
||||||
this.fpsTimer = 0;
|
|
||||||
this.actualFPS = 0;
|
|
||||||
|
|
||||||
// 성능 측정
|
|
||||||
this.performanceMetrics = {
|
|
||||||
frameTime: 0,
|
|
||||||
updateTime: 0,
|
|
||||||
renderTime: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
// 아이콘 레이어 설정
|
|
||||||
this.iconMapping = '/static/icons/vessel-icon.png';
|
|
||||||
this.iconAtlas = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
initialize() {
|
|
||||||
// 아이콘 아틀라스 생성 (성능 최적화)
|
|
||||||
this.createIconAtlas();
|
|
||||||
|
|
||||||
// 타임라인 UI 생성
|
|
||||||
this.createTimelineUI();
|
|
||||||
|
|
||||||
// 이벤트 리스너
|
|
||||||
this.setupEventListeners();
|
|
||||||
|
|
||||||
// 초기 시간 설정
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
if (state.animation.startTime && state.animation.endTime) {
|
|
||||||
this.updateUI();
|
|
||||||
this.updateVesselPositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Animation controller initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
createIconAtlas() {
|
|
||||||
// 간단한 원형 아이콘을 Canvas로 생성
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = 64;
|
|
||||||
canvas.height = 64;
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// 원형 선박 아이콘
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(32, 32, 20, 0, 2 * Math.PI);
|
|
||||||
ctx.fillStyle = '#ffffff';
|
|
||||||
ctx.fill();
|
|
||||||
ctx.strokeStyle = '#000000';
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
// 방향 표시
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.moveTo(32, 12);
|
|
||||||
ctx.lineTo(42, 32);
|
|
||||||
ctx.lineTo(32, 28);
|
|
||||||
ctx.lineTo(22, 32);
|
|
||||||
ctx.closePath();
|
|
||||||
ctx.fillStyle = '#ff0000';
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
this.iconAtlas = canvas.toDataURL();
|
|
||||||
}
|
|
||||||
|
|
||||||
createTimelineUI() {
|
|
||||||
// 타임라인 컨테이너 생성
|
|
||||||
const timelineContainer = document.createElement('div');
|
|
||||||
timelineContainer.id = 'timelineContainer';
|
|
||||||
timelineContainer.className = 'timeline-container';
|
|
||||||
timelineContainer.innerHTML = `
|
|
||||||
<div class="timeline-controls">
|
|
||||||
<button id="playBtn" class="btn btn-sm btn-primary">
|
|
||||||
<i class="bi bi-play-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button id="pauseBtn" class="btn btn-sm btn-secondary" style="display: none;">
|
|
||||||
<i class="bi bi-pause-fill"></i>
|
|
||||||
</button>
|
|
||||||
<button id="resetBtn" class="btn btn-sm btn-secondary">
|
|
||||||
<i class="bi bi-skip-backward-fill"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="speed-control">
|
|
||||||
<label>속도:</label>
|
|
||||||
<select id="speedSelect" class="form-select form-select-sm">
|
|
||||||
<option value="0.5">0.5x</option>
|
|
||||||
<option value="1" selected>1x</option>
|
|
||||||
<option value="2">2x</option>
|
|
||||||
<option value="4">4x</option>
|
|
||||||
<option value="8">8x</option>
|
|
||||||
<option value="50">50x</option>
|
|
||||||
<option value="100">100x</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="fps-control">
|
|
||||||
<label>FPS:</label>
|
|
||||||
<select id="fpsSelect" class="form-select form-select-sm">
|
|
||||||
<option value="auto" selected>Auto</option>
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="15">15</option>
|
|
||||||
<option value="20">20</option>
|
|
||||||
<option value="30">30</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="time-display">
|
|
||||||
<span id="currentTimeDisplay">--:--:--</span>
|
|
||||||
<span class="text-muted">/</span>
|
|
||||||
<span id="totalTimeDisplay">--:--:--</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="track-visibility-control">
|
|
||||||
<label class="form-check-label">
|
|
||||||
<input type="checkbox" id="showTracksCheck" class="form-check-input" checked>
|
|
||||||
궤적 표시
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="timeline-bar">
|
|
||||||
<input type="range" id="timelineSlider" class="timeline-slider"
|
|
||||||
min="0" max="1000" value="0" step="1">
|
|
||||||
<div class="timeline-progress" id="timelineProgress"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="performance-info">
|
|
||||||
<span>FPS: <span id="currentFPS">0</span></span>
|
|
||||||
<span>선박: <span id="animatingVessels">0</span></span>
|
|
||||||
<span>프레임시간: <span id="frameTime">0</span>ms</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
document.body.appendChild(timelineContainer);
|
|
||||||
|
|
||||||
// CSS 추가
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
.timeline-container {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 220px; /* 로그 패널과 격치지 않도록 조정 */
|
|
||||||
left: 10px;
|
|
||||||
width: calc(50% - 20px); /* 화면 절반 너비로 제한 */
|
|
||||||
max-width: 800px; /* 최대 너비 제한 */
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-controls button {
|
|
||||||
width: 40px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-control, .fps-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-control label, .fps-control label {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-control select, .fps-control select {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-display {
|
|
||||||
margin-left: auto;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-visibility-control {
|
|
||||||
margin-left: 15px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-visibility-control input {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-bar {
|
|
||||||
position: relative;
|
|
||||||
height: 30px;
|
|
||||||
background: #f0f0f0;
|
|
||||||
border-radius: 15px;
|
|
||||||
overflow: hidden;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-slider {
|
|
||||||
width: 100%;
|
|
||||||
height: 30px;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-progress {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
height: 100%;
|
|
||||||
background: linear-gradient(90deg, #007bff, #0056b3);
|
|
||||||
width: 0%;
|
|
||||||
transition: width 0.1s;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.performance-info {
|
|
||||||
display: flex;
|
|
||||||
gap: 20px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.performance-info span {
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
|
|
||||||
setupEventListeners() {
|
|
||||||
// 재생/일시정지
|
|
||||||
document.getElementById('playBtn').addEventListener('click', () => this.play());
|
|
||||||
document.getElementById('pauseBtn').addEventListener('click', () => this.pause());
|
|
||||||
document.getElementById('resetBtn').addEventListener('click', () => this.reset());
|
|
||||||
|
|
||||||
// 속도 조절
|
|
||||||
document.getElementById('speedSelect').addEventListener('change', (e) => {
|
|
||||||
this.trackStore.setAnimationSpeed(parseFloat(e.target.value));
|
|
||||||
});
|
|
||||||
|
|
||||||
// FPS 조절
|
|
||||||
document.getElementById('fpsSelect').addEventListener('change', (e) => {
|
|
||||||
if (e.target.value === 'auto') {
|
|
||||||
this.trackStore.getState().performance.dynamicFPS = true;
|
|
||||||
} else {
|
|
||||||
this.trackStore.getState().performance.dynamicFPS = false;
|
|
||||||
this.trackStore.setTargetFPS(parseInt(e.target.value));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 타임라인 슬라이더
|
|
||||||
document.getElementById('timelineSlider').addEventListener('input', (e) => {
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
const ratio = e.target.value / 1000;
|
|
||||||
const time = state.animation.startTime +
|
|
||||||
(state.animation.endTime - state.animation.startTime) * ratio;
|
|
||||||
this.trackStore.setAnimationTime(time);
|
|
||||||
this.updateVesselPositions();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 궤적 표시/숨김 체크박스
|
|
||||||
document.getElementById('showTracksCheck').addEventListener('change', (e) => {
|
|
||||||
this.updateTrackVisibility(e.target.checked);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
play() {
|
|
||||||
this.trackStore.toggleAnimation();
|
|
||||||
document.getElementById('playBtn').style.display = 'none';
|
|
||||||
document.getElementById('pauseBtn').style.display = 'block';
|
|
||||||
this.lastFrameTime = performance.now();
|
|
||||||
this.animate(performance.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
pause() {
|
|
||||||
this.trackStore.toggleAnimation();
|
|
||||||
document.getElementById('playBtn').style.display = 'block';
|
|
||||||
document.getElementById('pauseBtn').style.display = 'none';
|
|
||||||
if (this.animationFrame) {
|
|
||||||
cancelAnimationFrame(this.animationFrame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
reset() {
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
this.trackStore.setAnimationTime(state.animation.startTime);
|
|
||||||
this.updateVesselPositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
animate(timestamp) {
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
|
|
||||||
if (!state.animation.playing) return;
|
|
||||||
|
|
||||||
if (!timestamp) timestamp = performance.now();
|
|
||||||
|
|
||||||
// FPS 제한
|
|
||||||
const targetFrameTime = 1000 / state.performance.targetFPS;
|
|
||||||
const elapsed = timestamp - this.lastFrameTime;
|
|
||||||
|
|
||||||
if (elapsed < targetFrameTime) {
|
|
||||||
this.animationFrame = requestAnimationFrame(this.animate.bind(this));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const frameStartTime = performance.now();
|
|
||||||
|
|
||||||
// 시간 업데이트 - 실제 경과 시간 계산
|
|
||||||
const deltaTime = elapsed * state.animation.speed; // elapsed는 이미 ms 단위
|
|
||||||
const newTime = state.animation.currentTime + deltaTime;
|
|
||||||
|
|
||||||
// 디버그: 1초마다 로그 출력 (제거됨)
|
|
||||||
|
|
||||||
if (newTime > state.animation.endTime) {
|
|
||||||
// 루프 또는 정지
|
|
||||||
this.trackStore.setAnimationTime(state.animation.startTime);
|
|
||||||
} else {
|
|
||||||
this.trackStore.setAnimationTime(newTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선박 위치 업데이트
|
|
||||||
const updateStartTime = performance.now();
|
|
||||||
this.updateVesselPositions();
|
|
||||||
this.performanceMetrics.updateTime = performance.now() - updateStartTime;
|
|
||||||
|
|
||||||
// UI 업데이트
|
|
||||||
this.updateUI();
|
|
||||||
|
|
||||||
// 성능 측정
|
|
||||||
this.performanceMetrics.frameTime = performance.now() - frameStartTime;
|
|
||||||
this.measureFPS(timestamp);
|
|
||||||
|
|
||||||
this.lastFrameTime = timestamp;
|
|
||||||
this.animationFrame = requestAnimationFrame(this.animate.bind(this));
|
|
||||||
}
|
|
||||||
|
|
||||||
updateVesselPositions() {
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
const positions = [];
|
|
||||||
|
|
||||||
// 보이는 선박만 처리
|
|
||||||
const visibleVessels = this.trackStore.getVisibleVessels();
|
|
||||||
|
|
||||||
visibleVessels.forEach(vessel => {
|
|
||||||
const pos = this.trackStore.getVesselPositionAtTime(vessel.id, state.animation.currentTime);
|
|
||||||
if (pos) {
|
|
||||||
positions.push({
|
|
||||||
id: vessel.id,
|
|
||||||
position: [pos.lon, pos.lat],
|
|
||||||
speed: vessel.metadata.avgSpeed,
|
|
||||||
color: this.getVesselColor(vessel.metadata.avgSpeed),
|
|
||||||
size: 12
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// IconLayer 업데이트
|
|
||||||
const iconLayer = new deck.IconLayer({
|
|
||||||
id: 'vessel-icons',
|
|
||||||
data: positions,
|
|
||||||
pickable: true,
|
|
||||||
getIcon: d => ({
|
|
||||||
url: this.iconAtlas,
|
|
||||||
width: 64,
|
|
||||||
height: 64
|
|
||||||
}),
|
|
||||||
getPosition: d => d.position,
|
|
||||||
getSize: d => d.size,
|
|
||||||
getColor: d => d.color,
|
|
||||||
sizeScale: 1,
|
|
||||||
sizeMinPixels: 8,
|
|
||||||
sizeMaxPixels: 24,
|
|
||||||
billboard: false,
|
|
||||||
// 성능 최적화
|
|
||||||
updateTriggers: {
|
|
||||||
getPosition: [state.animation.currentTime]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전역 deckOverlay 사용
|
|
||||||
const globalDeckOverlay = window.deckOverlay || this.deckOverlay;
|
|
||||||
const currentLayers = globalDeckOverlay.props?.layers || [];
|
|
||||||
|
|
||||||
// 궤적 표시 체크박스 상태 확인
|
|
||||||
const showTracks = document.getElementById('showTracksCheck')?.checked || false;
|
|
||||||
|
|
||||||
// vessel-icons 레이어만 업데이트
|
|
||||||
let newLayers = currentLayers.filter(l => l.id !== 'vessel-icons' && l.id !== 'vessel-tracks');
|
|
||||||
|
|
||||||
// 궤적 레이어 추가 (체크된 경우만)
|
|
||||||
if (showTracks && window.updateGisLayers) {
|
|
||||||
const trackLayer = window.updateGisLayers(true);
|
|
||||||
if (trackLayer) {
|
|
||||||
newLayers.push(trackLayer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 아이콘 레이어 추가 (가장 위에)
|
|
||||||
newLayers.push(iconLayer);
|
|
||||||
|
|
||||||
globalDeckOverlay.setProps({ layers: newLayers });
|
|
||||||
|
|
||||||
// 통계 업데이트
|
|
||||||
document.getElementById('animatingVessels').textContent = positions.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
getVesselColor(speed) {
|
|
||||||
if (speed < 5) return [30, 144, 255, 200];
|
|
||||||
if (speed < 10) return [0, 255, 0, 200];
|
|
||||||
if (speed < 15) return [255, 255, 0, 200];
|
|
||||||
if (speed < 20) return [255, 140, 0, 200];
|
|
||||||
return [255, 0, 0, 200];
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTrackVisibility(show) {
|
|
||||||
// updateVesselPositions가 체크박스 상태를 직접 확인하므로
|
|
||||||
// 여기서는 단순히 화면을 업데이트만 함
|
|
||||||
this.updateVesselPositions();
|
|
||||||
}
|
|
||||||
|
|
||||||
updateUI() {
|
|
||||||
const state = this.trackStore.getState();
|
|
||||||
|
|
||||||
// 시간 표시
|
|
||||||
if (state.animation.currentTime && !isNaN(state.animation.currentTime)) {
|
|
||||||
const currentDate = new Date(state.animation.currentTime);
|
|
||||||
document.getElementById('currentTimeDisplay').textContent =
|
|
||||||
currentDate.toTimeString().split(' ')[0];
|
|
||||||
} else {
|
|
||||||
document.getElementById('currentTimeDisplay').textContent = '--:--:--';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 종료 시간 표시
|
|
||||||
if (state.animation.endTime && !isNaN(state.animation.endTime)) {
|
|
||||||
const endDate = new Date(state.animation.endTime);
|
|
||||||
document.getElementById('totalTimeDisplay').textContent =
|
|
||||||
endDate.toTimeString().split(' ')[0];
|
|
||||||
} else {
|
|
||||||
document.getElementById('totalTimeDisplay').textContent = '--:--:--';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 진행률
|
|
||||||
if (state.animation.startTime && state.animation.endTime && state.animation.currentTime) {
|
|
||||||
const progress = (state.animation.currentTime - state.animation.startTime) /
|
|
||||||
(state.animation.endTime - state.animation.startTime);
|
|
||||||
document.getElementById('timelineProgress').style.width = (progress * 100) + '%';
|
|
||||||
document.getElementById('timelineSlider').value = progress * 1000;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
measureFPS(timestamp) {
|
|
||||||
this.frameCount++;
|
|
||||||
|
|
||||||
if (timestamp - this.fpsTimer > 1000) {
|
|
||||||
this.actualFPS = Math.round(this.frameCount * 1000 / (timestamp - this.fpsTimer));
|
|
||||||
document.getElementById('currentFPS').textContent = this.actualFPS;
|
|
||||||
document.getElementById('frameTime').textContent =
|
|
||||||
Math.round(this.performanceMetrics.frameTime);
|
|
||||||
|
|
||||||
this.frameCount = 0;
|
|
||||||
this.fpsTimer = timestamp;
|
|
||||||
|
|
||||||
// 동적 FPS 조정
|
|
||||||
if (this.trackStore.getState().performance.dynamicFPS) {
|
|
||||||
this.adjustFPSForPerformance();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
adjustFPSForPerformance() {
|
|
||||||
const targetFrameTime = 1000 / this.trackStore.getState().performance.targetFPS;
|
|
||||||
|
|
||||||
if (this.performanceMetrics.frameTime > targetFrameTime * 1.5) {
|
|
||||||
// 프레임 시간이 목표보다 50% 이상 길면 FPS 감소
|
|
||||||
const newFPS = Math.max(5, this.trackStore.getState().performance.targetFPS - 5);
|
|
||||||
this.trackStore.setTargetFPS(newFPS);
|
|
||||||
console.log(`Performance: Reducing FPS to ${newFPS}`);
|
|
||||||
} else if (this.performanceMetrics.frameTime < targetFrameTime * 0.7) {
|
|
||||||
// 여유가 있으면 FPS 증가
|
|
||||||
const newFPS = Math.min(30, this.trackStore.getState().performance.targetFPS + 5);
|
|
||||||
this.trackStore.setTargetFPS(newFPS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop() {
|
|
||||||
this.pause();
|
|
||||||
if (this.animationFrame) {
|
|
||||||
cancelAnimationFrame(this.animationFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UI 제거
|
|
||||||
const container = document.getElementById('timelineContainer');
|
|
||||||
if (container) {
|
|
||||||
container.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 등록
|
|
||||||
window.VesselAnimationController = VesselAnimationController;
|
|
||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -1,478 +0,0 @@
|
|||||||
// Zustand store for vessel track management (JavaScript version)
|
|
||||||
// Optimized for handling 20,000-30,000 vessels
|
|
||||||
|
|
||||||
// Track store factory
|
|
||||||
function createTrackStore() {
|
|
||||||
let state = {
|
|
||||||
// 데이터
|
|
||||||
vessels: new Map(),
|
|
||||||
totalVessels: 0,
|
|
||||||
totalPoints: 0,
|
|
||||||
|
|
||||||
// 애니메이션 상태
|
|
||||||
animation: {
|
|
||||||
playing: false,
|
|
||||||
currentTime: 0,
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 0,
|
|
||||||
speed: 1,
|
|
||||||
targetFPS: 30
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성능 설정
|
|
||||||
performance: {
|
|
||||||
maxRenderVessels: 20000, // 5000 -> 20000으로 증가
|
|
||||||
dynamicFPS: true,
|
|
||||||
currentFPS: 30,
|
|
||||||
targetFPS: 30,
|
|
||||||
lastStatsUpdate: 0 // 마지막 통계 업데이트 시간
|
|
||||||
},
|
|
||||||
|
|
||||||
// 뷰포트 정보
|
|
||||||
viewport: {
|
|
||||||
bounds: [120, 30, 140, 45],
|
|
||||||
zoom: 6
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 리스너들
|
|
||||||
const listeners = new Set();
|
|
||||||
|
|
||||||
// 상태 업데이트 함수
|
|
||||||
function setState(updater) {
|
|
||||||
const newState = typeof updater === 'function' ? updater(state) : updater;
|
|
||||||
state = { ...state, ...newState };
|
|
||||||
listeners.forEach(listener => listener(state));
|
|
||||||
}
|
|
||||||
|
|
||||||
// LineStringM 파싱
|
|
||||||
function parseLineStringM(wkt, startTime) {
|
|
||||||
if (!wkt) return [];
|
|
||||||
|
|
||||||
const matches = wkt.match(/LINESTRING M\s*\((.*)\)/);
|
|
||||||
if (!matches) return [];
|
|
||||||
|
|
||||||
// startTime이 ISO string 형식인지 확인
|
|
||||||
const baseTime = typeof startTime === 'string' ?
|
|
||||||
new Date(startTime).getTime() : startTime;
|
|
||||||
|
|
||||||
if (isNaN(baseTime)) {
|
|
||||||
console.error('Invalid startTime:', startTime);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return matches[1].split(',').map(coord => {
|
|
||||||
const parts = coord.trim().split(/\s+/);
|
|
||||||
const mValue = parseFloat(parts[2] || 0);
|
|
||||||
return {
|
|
||||||
lon: parseFloat(parts[0]),
|
|
||||||
lat: parseFloat(parts[1]),
|
|
||||||
time: baseTime + (mValue * 1000) // M값은 초 단위
|
|
||||||
};
|
|
||||||
}).filter(p => !isNaN(p.lon) && !isNaN(p.lat));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing LineStringM:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 병합 함수
|
|
||||||
function mergePathWithDedup(vessel, newSegment) {
|
|
||||||
const segment = {
|
|
||||||
timeBucket: newSegment.timeBucket || newSegment.startTime,
|
|
||||||
points: parseLineStringM(newSegment.trackGeom, newSegment.startTime),
|
|
||||||
distance: newSegment.distanceNm,
|
|
||||||
avgSpeed: newSegment.avgSpeed
|
|
||||||
};
|
|
||||||
|
|
||||||
// 세그먼트 저장
|
|
||||||
vessel.segments.set(segment.timeBucket, segment);
|
|
||||||
|
|
||||||
// 전체 경로 재구성 (시간순 정렬)
|
|
||||||
const allPoints = [];
|
|
||||||
const sortedSegments = Array.from(vessel.segments.values())
|
|
||||||
.sort((a, b) => (a.points[0]?.time || 0) - (b.points[0]?.time || 0));
|
|
||||||
|
|
||||||
sortedSegments.forEach(seg => {
|
|
||||||
seg.points.forEach(point => {
|
|
||||||
// 중복 제거 (1초 이상 차이나는 경우만)
|
|
||||||
if (allPoints.length === 0 ||
|
|
||||||
Math.abs(point.time - allPoints[allPoints.length - 1].time) > 1000) {
|
|
||||||
allPoints.push(point);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
vessel.fullPath = allPoints;
|
|
||||||
|
|
||||||
// 메타데이터 업데이트
|
|
||||||
if (allPoints.length > 0) {
|
|
||||||
vessel.metadata.minTime = allPoints[0].time;
|
|
||||||
vessel.metadata.maxTime = allPoints[allPoints.length - 1].time;
|
|
||||||
}
|
|
||||||
|
|
||||||
vessel.metadata.totalDistance += segment.distance;
|
|
||||||
const avgSpeeds = sortedSegments.map(s => s.avgSpeed).filter(s => s > 0);
|
|
||||||
vessel.metadata.avgSpeed = avgSpeeds.reduce((a, b) => a + b, 0) / (avgSpeeds.length || 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이진 탐색으로 시간에 해당하는 포인트 찾기
|
|
||||||
function findPointAtTime(vessel, targetTime) {
|
|
||||||
const points = vessel.fullPath;
|
|
||||||
if (!points || points.length === 0) return null;
|
|
||||||
|
|
||||||
// 선박이 현재 시간 범위에 있는지 확인
|
|
||||||
const firstTime = points[0].time;
|
|
||||||
const lastTime = points[points.length - 1].time;
|
|
||||||
|
|
||||||
// 시간 범위 밖이면 null 반환 (선박 안 보임)
|
|
||||||
if (targetTime < firstTime - 60000 || targetTime > lastTime + 60000) { // 1분 여유
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetTime <= firstTime) return points[0];
|
|
||||||
if (targetTime >= lastTime) return points[points.length - 1];
|
|
||||||
|
|
||||||
// 이진 탐색
|
|
||||||
let left = 0;
|
|
||||||
let right = points.length - 1;
|
|
||||||
|
|
||||||
while (left < right - 1) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
if (points[mid].time <= targetTime) {
|
|
||||||
left = mid;
|
|
||||||
} else {
|
|
||||||
right = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선형 보간
|
|
||||||
const p1 = points[left];
|
|
||||||
const p2 = points[right];
|
|
||||||
const ratio = (targetTime - p1.time) / (p2.time - p1.time);
|
|
||||||
|
|
||||||
return {
|
|
||||||
lon: p1.lon + (p2.lon - p1.lon) * ratio,
|
|
||||||
lat: p1.lat + (p2.lat - p1.lat) * ratio,
|
|
||||||
time: targetTime
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store API
|
|
||||||
return {
|
|
||||||
// State getter
|
|
||||||
getState: () => state,
|
|
||||||
|
|
||||||
// Subscribe to changes
|
|
||||||
subscribe: (listener) => {
|
|
||||||
listeners.add(listener);
|
|
||||||
return () => listeners.delete(listener);
|
|
||||||
},
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
addProcessedVessel: (vessel) => {
|
|
||||||
// Worker에서 처리된 결과 추가
|
|
||||||
state.vessels.set(vessel.id, vessel);
|
|
||||||
|
|
||||||
// 애니메이션 시간 범위 업데이트
|
|
||||||
let animationUpdate = {};
|
|
||||||
if (vessel.metadata.minTime !== Infinity &&
|
|
||||||
(vessel.metadata.minTime < state.animation.startTime || state.animation.startTime === 0)) {
|
|
||||||
animationUpdate.startTime = vessel.metadata.minTime;
|
|
||||||
if (state.animation.currentTime === 0) {
|
|
||||||
animationUpdate.currentTime = vessel.metadata.minTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vessel.metadata.maxTime !== -Infinity && vessel.metadata.maxTime > state.animation.endTime) {
|
|
||||||
animationUpdate.endTime = vessel.metadata.maxTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성능 설정 업데이트
|
|
||||||
let performanceUpdate = {};
|
|
||||||
if (state.performance.dynamicFPS) {
|
|
||||||
const vesselCount = state.vessels.size;
|
|
||||||
if (vesselCount > 20000) {
|
|
||||||
performanceUpdate.targetFPS = 10;
|
|
||||||
} else if (vesselCount > 10000) {
|
|
||||||
performanceUpdate.targetFPS = 15;
|
|
||||||
} else if (vesselCount > 5000) {
|
|
||||||
performanceUpdate.targetFPS = 20;
|
|
||||||
} else {
|
|
||||||
performanceUpdate.targetFPS = 30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성능 최적화: 통계는 1초에 한 번만 계산
|
|
||||||
const now = Date.now();
|
|
||||||
const shouldUpdateStats = (now - state.performance.lastStatsUpdate > 1000) || vessel.isLastVessel;
|
|
||||||
const totalPoints = shouldUpdateStats ?
|
|
||||||
Array.from(state.vessels.values()).reduce((sum, v) => sum + (v.fullPath?.length || 0), 0) :
|
|
||||||
state.totalPoints + (vessel.fullPath?.length || 0);
|
|
||||||
|
|
||||||
if (shouldUpdateStats) {
|
|
||||||
performanceUpdate.lastStatsUpdate = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({
|
|
||||||
totalVessels: state.vessels.size,
|
|
||||||
totalPoints: totalPoints,
|
|
||||||
animation: { ...state.animation, ...animationUpdate },
|
|
||||||
performance: { ...state.performance, ...performanceUpdate }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
mergeTrackData: (data) => {
|
|
||||||
const vesselKey = `${data.sigSrcCd}_${data.targetId}`;
|
|
||||||
|
|
||||||
let vessel = state.vessels.get(vesselKey);
|
|
||||||
if (!vessel) {
|
|
||||||
vessel = {
|
|
||||||
id: vesselKey,
|
|
||||||
sigSrcCd: data.sigSrcCd,
|
|
||||||
targetId: data.targetId,
|
|
||||||
fullPath: [],
|
|
||||||
segments: new Map(),
|
|
||||||
metadata: {
|
|
||||||
totalDistance: 0,
|
|
||||||
avgSpeed: 0,
|
|
||||||
minTime: Infinity,
|
|
||||||
maxTime: -Infinity
|
|
||||||
}
|
|
||||||
};
|
|
||||||
state.vessels.set(vesselKey, vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// startTime이 없으면 timeBucket 사용
|
|
||||||
if (!data.startTime && data.timeBucket) {
|
|
||||||
data.startTime = data.timeBucket;
|
|
||||||
}
|
|
||||||
|
|
||||||
mergePathWithDedup(vessel, data);
|
|
||||||
|
|
||||||
// 성능 최적화: 매번 전체 통계를 계산하지 않고 증분만 업데이트
|
|
||||||
const newPointsAdded = vessel.fullPath.length;
|
|
||||||
|
|
||||||
// 애니메이션 시간 범위 업데이트
|
|
||||||
let animationUpdate = {};
|
|
||||||
if (vessel.metadata.minTime !== Infinity &&
|
|
||||||
(vessel.metadata.minTime < state.animation.startTime || state.animation.startTime === 0)) {
|
|
||||||
animationUpdate.startTime = vessel.metadata.minTime;
|
|
||||||
// 최초 데이터일 때 currentTime도 설정
|
|
||||||
if (state.animation.currentTime === 0 || state.animation.currentTime > vessel.metadata.maxTime) {
|
|
||||||
animationUpdate.currentTime = vessel.metadata.minTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (vessel.metadata.maxTime !== -Infinity && vessel.metadata.maxTime > state.animation.endTime) {
|
|
||||||
animationUpdate.endTime = vessel.metadata.maxTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2-3만 선박 처리를 위한 동적 FPS 조정 (일정 간격으로만)
|
|
||||||
let performanceUpdate = {};
|
|
||||||
if (state.performance.dynamicFPS && state.vessels.size % 100 === 0) {
|
|
||||||
const vesselCount = state.vessels.size;
|
|
||||||
if (vesselCount > 20000) {
|
|
||||||
performanceUpdate.targetFPS = 10;
|
|
||||||
} else if (vesselCount > 10000) {
|
|
||||||
performanceUpdate.targetFPS = 15;
|
|
||||||
} else if (vesselCount > 5000) {
|
|
||||||
performanceUpdate.targetFPS = 20;
|
|
||||||
} else {
|
|
||||||
performanceUpdate.targetFPS = 30;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 성능 최적화: 통계는 1초에 한 번만 계산
|
|
||||||
const now = Date.now();
|
|
||||||
const shouldUpdateStats = (now - state.performance.lastStatsUpdate > 1000) || data.isLastChunk;
|
|
||||||
const totalPoints = shouldUpdateStats ?
|
|
||||||
Array.from(state.vessels.values()).reduce((sum, v) => sum + v.fullPath.length, 0) :
|
|
||||||
state.totalPoints + newPointsAdded;
|
|
||||||
|
|
||||||
if (shouldUpdateStats) {
|
|
||||||
performanceUpdate.lastStatsUpdate = now;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({
|
|
||||||
totalVessels: state.vessels.size,
|
|
||||||
totalPoints: totalPoints,
|
|
||||||
animation: { ...state.animation, ...animationUpdate },
|
|
||||||
performance: { ...state.performance, ...performanceUpdate }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearTracks: () => {
|
|
||||||
state.vessels.clear();
|
|
||||||
setState({
|
|
||||||
vessels: new Map(),
|
|
||||||
totalVessels: 0,
|
|
||||||
totalPoints: 0,
|
|
||||||
animation: {
|
|
||||||
playing: false,
|
|
||||||
currentTime: 0,
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 0,
|
|
||||||
speed: 1,
|
|
||||||
targetFPS: 30
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setAnimationTime: (time) => {
|
|
||||||
setState((prevState) => {
|
|
||||||
const clampedTime = Math.max(
|
|
||||||
prevState.animation.startTime,
|
|
||||||
Math.min(prevState.animation.endTime, time)
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
animation: {
|
|
||||||
...prevState.animation,
|
|
||||||
currentTime: clampedTime
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setAnimationSpeed: (speed) => {
|
|
||||||
setState({
|
|
||||||
animation: { ...state.animation, speed }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleAnimation: () => {
|
|
||||||
console.log('toggleAnimation called');
|
|
||||||
setState((prevState) => ({
|
|
||||||
animation: { ...prevState.animation, playing: !prevState.animation.playing }
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
updateViewport: (viewport) => {
|
|
||||||
setState({ viewport });
|
|
||||||
},
|
|
||||||
|
|
||||||
setTargetFPS: (fps) => {
|
|
||||||
setState({
|
|
||||||
performance: { ...state.performance, targetFPS: fps },
|
|
||||||
animation: { ...state.animation, targetFPS: fps }
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 애니메이션 시간 범위 설정
|
|
||||||
setAnimationTimeRange: (startTime, endTime) => {
|
|
||||||
setState({
|
|
||||||
animation: {
|
|
||||||
...state.animation,
|
|
||||||
startTime: startTime,
|
|
||||||
endTime: endTime,
|
|
||||||
currentTime: startTime
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// 보이는 선박만 반환 (viewport culling + LOD)
|
|
||||||
getVisibleVessels: (includeAllTime = false) => {
|
|
||||||
const [minLon, minLat, maxLon, maxLat] = state.viewport.bounds;
|
|
||||||
// viewport 버퍼 추가 (1도씩 확장)
|
|
||||||
const bufferMinLon = minLon - 1;
|
|
||||||
const bufferMaxLon = maxLon + 1;
|
|
||||||
const bufferMinLat = minLat - 1;
|
|
||||||
const bufferMaxLat = maxLat + 1;
|
|
||||||
|
|
||||||
const currentTime = state.animation.currentTime || state.animation.startTime || Date.now();
|
|
||||||
|
|
||||||
// 2-3만 선박 처리: 공간 인덱싱 시뮬레이션
|
|
||||||
const candidates = [];
|
|
||||||
|
|
||||||
for (const [id, vessel] of state.vessels) {
|
|
||||||
// 빠른 시간 범위 체크 (느슨하게 변경)
|
|
||||||
if (vessel.metadata.minTime === Infinity || vessel.metadata.maxTime === -Infinity) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// includeAllTime이 true면 모든 궤적 포함
|
|
||||||
if (includeAllTime) {
|
|
||||||
// 궤적이 viewport에 걸치는지 확인
|
|
||||||
const inViewport = vessel.fullPath.some(p =>
|
|
||||||
p.lon >= bufferMinLon && p.lon <= bufferMaxLon &&
|
|
||||||
p.lat >= bufferMinLat && p.lat <= bufferMaxLat
|
|
||||||
);
|
|
||||||
|
|
||||||
if (inViewport) {
|
|
||||||
candidates.push({
|
|
||||||
vessel,
|
|
||||||
position: vessel.fullPath[0], // 첫 위치 사용
|
|
||||||
priority: vessel.metadata.avgSpeed
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 현재 위치 계산
|
|
||||||
const pos = findPointAtTime(vessel, currentTime);
|
|
||||||
if (!pos) continue;
|
|
||||||
|
|
||||||
// viewport 체크 (버퍼 적용)
|
|
||||||
if (pos.lon >= bufferMinLon && pos.lon <= bufferMaxLon &&
|
|
||||||
pos.lat >= bufferMinLat && pos.lat <= bufferMaxLat) {
|
|
||||||
candidates.push({
|
|
||||||
vessel,
|
|
||||||
position: pos,
|
|
||||||
priority: vessel.metadata.avgSpeed // 속도 기준 우선순위
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최대 렌더링 수 제한
|
|
||||||
if (candidates.length > state.performance.maxRenderVessels) {
|
|
||||||
// 우선순위 정렬 후 상위 N개만 선택
|
|
||||||
candidates.sort((a, b) => b.priority - a.priority);
|
|
||||||
return candidates.slice(0, state.performance.maxRenderVessels).map(c => c.vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates.map(c => c.vessel);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 특정 시간의 선박 위치 계산
|
|
||||||
getVesselPositionAtTime: (vesselId, time) => {
|
|
||||||
const vessel = state.vessels.get(vesselId);
|
|
||||||
if (!vessel) return null;
|
|
||||||
|
|
||||||
return findPointAtTime(vessel, time || state.animation.currentTime);
|
|
||||||
},
|
|
||||||
|
|
||||||
// 현재 시간의 모든 선박 위치 (성능 최적화)
|
|
||||||
getAllVesselPositions: () => {
|
|
||||||
const positions = [];
|
|
||||||
const visibleVessels = state.getVisibleVessels();
|
|
||||||
const currentTime = state.animation.currentTime;
|
|
||||||
|
|
||||||
for (const vessel of visibleVessels) {
|
|
||||||
const pos = findPointAtTime(vessel, currentTime);
|
|
||||||
if (pos) {
|
|
||||||
positions.push({
|
|
||||||
id: vessel.id,
|
|
||||||
position: [pos.lon, pos.lat],
|
|
||||||
speed: vessel.metadata.avgSpeed,
|
|
||||||
bearing: 0 // TODO: calculate bearing
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return positions;
|
|
||||||
},
|
|
||||||
|
|
||||||
// 성능 통계
|
|
||||||
getPerformanceStats: () => {
|
|
||||||
const store = window.trackStore;
|
|
||||||
return {
|
|
||||||
totalVessels: state.totalVessels,
|
|
||||||
totalPoints: state.totalPoints,
|
|
||||||
visibleVessels: store.getVisibleVessels().length,
|
|
||||||
targetFPS: state.performance.targetFPS,
|
|
||||||
maxRenderVessels: state.performance.maxRenderVessels
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 store 인스턴스
|
|
||||||
window.trackStore = createTrackStore();
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
// Web Worker for parallel track merging
|
|
||||||
// Optimized for batch processing
|
|
||||||
|
|
||||||
// LineStringM 파싱 (최적화 버전)
|
|
||||||
function parseLineStringM(wkt, startTime) {
|
|
||||||
if (!wkt) return [];
|
|
||||||
|
|
||||||
const startIdx = wkt.indexOf('(') + 1;
|
|
||||||
const endIdx = wkt.lastIndexOf(')');
|
|
||||||
if (startIdx <= 0 || endIdx < startIdx) return [];
|
|
||||||
|
|
||||||
const baseTime = typeof startTime === 'string' ?
|
|
||||||
new Date(startTime).getTime() : startTime;
|
|
||||||
|
|
||||||
if (isNaN(baseTime)) return [];
|
|
||||||
|
|
||||||
const coordsStr = wkt.substring(startIdx, endIdx);
|
|
||||||
const points = [];
|
|
||||||
|
|
||||||
// 정규식 대신 직접 파싱 (더 빠름)
|
|
||||||
const coords = coordsStr.split(',');
|
|
||||||
for (let i = 0; i < coords.length; i++) {
|
|
||||||
const parts = coords[i].trim().split(' ');
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
const lon = parseFloat(parts[0]);
|
|
||||||
const lat = parseFloat(parts[1]);
|
|
||||||
const m = parseFloat(parts[2]);
|
|
||||||
|
|
||||||
if (!isNaN(lon) && !isNaN(lat)) {
|
|
||||||
points.push({
|
|
||||||
lon: lon,
|
|
||||||
lat: lat,
|
|
||||||
time: baseTime + (m * 1000)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return points;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 배치 병합 처리
|
|
||||||
function processBatch(batch) {
|
|
||||||
const vesselMap = new Map();
|
|
||||||
|
|
||||||
for (const track of batch) {
|
|
||||||
const vesselKey = `${track.sigSrcCd}_${track.targetId}`;
|
|
||||||
|
|
||||||
let vessel = vesselMap.get(vesselKey);
|
|
||||||
if (!vessel) {
|
|
||||||
vessel = {
|
|
||||||
id: vesselKey,
|
|
||||||
sigSrcCd: track.sigSrcCd,
|
|
||||||
targetId: track.targetId,
|
|
||||||
segments: new Map(),
|
|
||||||
metadata: {
|
|
||||||
totalDistance: 0,
|
|
||||||
avgSpeed: 0,
|
|
||||||
minTime: Infinity,
|
|
||||||
maxTime: -Infinity
|
|
||||||
}
|
|
||||||
};
|
|
||||||
vesselMap.set(vesselKey, vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// startTime 처리
|
|
||||||
const startTime = track.startTime || track.timeBucket;
|
|
||||||
if (!startTime) continue;
|
|
||||||
|
|
||||||
// 세그먼트 생성
|
|
||||||
const segment = {
|
|
||||||
timeBucket: track.timeBucket || startTime,
|
|
||||||
points: parseLineStringM(track.trackGeom, startTime),
|
|
||||||
distance: track.distanceNm,
|
|
||||||
avgSpeed: track.avgSpeed
|
|
||||||
};
|
|
||||||
|
|
||||||
if (segment.points.length > 0) {
|
|
||||||
vessel.segments.set(segment.timeBucket, segment);
|
|
||||||
|
|
||||||
// 메타데이터 업데이트
|
|
||||||
vessel.metadata.totalDistance += segment.distance || 0;
|
|
||||||
const firstPoint = segment.points[0];
|
|
||||||
const lastPoint = segment.points[segment.points.length - 1];
|
|
||||||
|
|
||||||
vessel.metadata.minTime = Math.min(vessel.metadata.minTime, firstPoint.time);
|
|
||||||
vessel.metadata.maxTime = Math.max(vessel.metadata.maxTime, lastPoint.time);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 각 선박의 전체 경로 구성
|
|
||||||
const results = [];
|
|
||||||
for (const [vesselKey, vessel] of vesselMap) {
|
|
||||||
// 세그먼트 정렬 및 병합
|
|
||||||
const sortedSegments = Array.from(vessel.segments.values())
|
|
||||||
.sort((a, b) => (a.points[0]?.time || 0) - (b.points[0]?.time || 0));
|
|
||||||
|
|
||||||
const fullPath = [];
|
|
||||||
let lastTime = -Infinity;
|
|
||||||
|
|
||||||
for (const seg of sortedSegments) {
|
|
||||||
for (const point of seg.points) {
|
|
||||||
// 1초 이상 차이나는 경우만 추가 (중복 제거)
|
|
||||||
if (point.time - lastTime > 1000) {
|
|
||||||
fullPath.push(point);
|
|
||||||
lastTime = point.time;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 평균 속도 계산
|
|
||||||
const avgSpeeds = sortedSegments
|
|
||||||
.map(s => s.avgSpeed)
|
|
||||||
.filter(s => s > 0);
|
|
||||||
vessel.metadata.avgSpeed = avgSpeeds.length > 0 ?
|
|
||||||
avgSpeeds.reduce((a, b) => a + b, 0) / avgSpeeds.length : 0;
|
|
||||||
|
|
||||||
vessel.fullPath = fullPath;
|
|
||||||
results.push(vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Worker 메시지 핸들러
|
|
||||||
self.addEventListener('message', (e) => {
|
|
||||||
const { type, data, id } = e.data;
|
|
||||||
|
|
||||||
if (type === 'PROCESS_BATCH') {
|
|
||||||
const startTime = performance.now();
|
|
||||||
const results = processBatch(data);
|
|
||||||
const duration = performance.now() - startTime;
|
|
||||||
|
|
||||||
self.postMessage({
|
|
||||||
type: 'BATCH_COMPLETE',
|
|
||||||
id: id,
|
|
||||||
results: results,
|
|
||||||
stats: {
|
|
||||||
processedTracks: data.length,
|
|
||||||
resultVessels: results.length,
|
|
||||||
processingTime: duration
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Track merge worker ready');
|
|
||||||
1556
src/main/resources/static/libs/css/bootstrap-icons.css
vendored
1556
src/main/resources/static/libs/css/bootstrap-icons.css
vendored
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
13
src/main/resources/static/libs/js/chart.min.js
vendored
13
src/main/resources/static/libs/js/chart.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4968
src/main/resources/static/libs/js/deck.gl.min.js
vendored
4968
src/main/resources/static/libs/js/deck.gl.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,132 +0,0 @@
|
|||||||
# Signal Batch Static Resources v2.0
|
|
||||||
|
|
||||||
폐쇄망 환경에서 운용되는 Signal Batch 프로젝트의 개선된 정적 리소스 구조입니다.
|
|
||||||
|
|
||||||
## 구조 개요
|
|
||||||
|
|
||||||
```
|
|
||||||
v2/
|
|
||||||
├── css/ # 스타일시트
|
|
||||||
│ ├── common.css # 공통 스타일
|
|
||||||
│ └── abnormal-tracks.css # 비정상 궤적 전용 스타일
|
|
||||||
├── js/ # JavaScript 모듈
|
|
||||||
│ ├── api/ # API 통신 모듈
|
|
||||||
│ │ └── abnormal-tracks-api.js
|
|
||||||
│ ├── components/ # 재사용 가능한 컴포넌트
|
|
||||||
│ │ ├── map-controller.js
|
|
||||||
│ │ ├── vessel-list.js
|
|
||||||
│ │ └── statistics-panel.js
|
|
||||||
│ ├── pages/ # 페이지별 애플리케이션
|
|
||||||
│ │ └── abnormal-tracks-app.js
|
|
||||||
│ └── utils/ # 유틸리티 함수
|
|
||||||
│ ├── constants.js
|
|
||||||
│ └── helpers.js
|
|
||||||
├── pages/ # HTML 페이지
|
|
||||||
│ └── abnormal-tracks.html
|
|
||||||
└── README.md
|
|
||||||
```
|
|
||||||
|
|
||||||
## 주요 개선사항
|
|
||||||
|
|
||||||
### 1. 모듈화된 구조
|
|
||||||
- 인라인 CSS/JavaScript를 별도 파일로 분리
|
|
||||||
- 기능별로 모듈 분할하여 유지보수성 향상
|
|
||||||
- ES6 모듈 시스템 사용
|
|
||||||
|
|
||||||
### 2. 컴포넌트 기반 설계
|
|
||||||
- **MapController**: 지도 렌더링 및 상호작용 관리
|
|
||||||
- **VesselList**: 선박 목록 표시 및 필터링
|
|
||||||
- **StatisticsPanel**: 통계 정보 표시 및 범례 관리
|
|
||||||
|
|
||||||
### 3. API 모듈 분리
|
|
||||||
- API 호출 로직을 별도 모듈로 분리
|
|
||||||
- 에러 처리 및 로딩 상태 관리 개선
|
|
||||||
|
|
||||||
### 4. 폐쇄망 환경 최적화
|
|
||||||
- 모든 라이브러리는 `/libs` 경로의 로컬 파일 사용 유지
|
|
||||||
- 지도 타일도 로컬 API 엔드포인트 사용
|
|
||||||
- CDN 의존성 없음
|
|
||||||
|
|
||||||
## 사용 방법
|
|
||||||
|
|
||||||
### 기본 사용법
|
|
||||||
1. 웹 서버에서 `/v2/pages/abnormal-tracks.html` 접근
|
|
||||||
2. 기존 기능과 동일하게 작동하되, 개선된 코드 구조
|
|
||||||
|
|
||||||
### 개발 시 주의사항
|
|
||||||
1. **모듈 임포트**: ES6 모듈 문법 사용
|
|
||||||
2. **이벤트 처리**: 컴포넌트 간 이벤트 핸들러 패턴 활용
|
|
||||||
3. **상태 관리**: 각 컴포넌트가 자체 상태를 관리
|
|
||||||
|
|
||||||
## API 엔드포인트
|
|
||||||
|
|
||||||
기존과 동일한 엔드포인트 사용:
|
|
||||||
- `GET /api/v1/abnormal-tracks/recent` - 최근 비정상 궤적 조회
|
|
||||||
- `POST /api/v1/abnormal-tracks/detect` - 사용자 정의 검출
|
|
||||||
- `POST /api/v1/abnormal-tracks/move-to-abnormal` - 비정상 테이블 이동
|
|
||||||
- `GET /api/tiles/world/{z}/{x}/{y}.webp` - 지도 타일
|
|
||||||
|
|
||||||
## 컴포넌트 상세
|
|
||||||
|
|
||||||
### MapController
|
|
||||||
- MapLibre GL JS와 Deck.gl 통합 관리
|
|
||||||
- 트랙 시각화 및 상호작용 처리
|
|
||||||
- GeoJSON 캐싱으로 성능 최적화
|
|
||||||
|
|
||||||
### VesselList
|
|
||||||
- 선박별 궤적 그룹화 및 정렬
|
|
||||||
- 필터링 및 검색 기능
|
|
||||||
- 사용자 정의 검출 모드 지원
|
|
||||||
|
|
||||||
### StatisticsPanel
|
|
||||||
- 실시간 통계 업데이트
|
|
||||||
- 비정상 유형별 범례 생성
|
|
||||||
- 조회 기간 표시
|
|
||||||
|
|
||||||
## 설정 및 상수
|
|
||||||
|
|
||||||
### constants.js
|
|
||||||
```javascript
|
|
||||||
// API 엔드포인트
|
|
||||||
export const API_ENDPOINTS = { ... };
|
|
||||||
|
|
||||||
// 비정상 유형 및 색상 정의
|
|
||||||
export const ABNORMAL_TYPES = { ... };
|
|
||||||
export const ABNORMAL_TYPE_COLORS = { ... };
|
|
||||||
|
|
||||||
// 지도 설정
|
|
||||||
export const MAP_CONFIG = { ... };
|
|
||||||
```
|
|
||||||
|
|
||||||
### helpers.js
|
|
||||||
```javascript
|
|
||||||
// 날짜 처리, 포맷팅, 유틸리티 함수들
|
|
||||||
export function formatDistance(distance) { ... }
|
|
||||||
export function getDateRange(days) { ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
## 성능 최적화
|
|
||||||
|
|
||||||
1. **GeoJSON 캐싱**: 파싱된 GeoJSON 데이터 메모리 캐시
|
|
||||||
2. **디바운스**: 빈번한 API 호출 방지
|
|
||||||
3. **이벤트 위임**: 동적 요소 이벤트 처리 최적화
|
|
||||||
4. **레이어 관리**: Deck.gl 레이어 효율적 업데이트
|
|
||||||
|
|
||||||
## 브라우저 호환성
|
|
||||||
|
|
||||||
- ES6 모듈을 지원하는 모던 브라우저 필요
|
|
||||||
- Chrome 61+, Firefox 60+, Safari 10.1+, Edge 79+
|
|
||||||
|
|
||||||
## 마이그레이션 가이드
|
|
||||||
|
|
||||||
기존 abnormal-tracks.html에서 v2로 이전 시:
|
|
||||||
1. 기능적으로는 100% 동일
|
|
||||||
2. URL만 `/v2/pages/abnormal-tracks.html`로 변경
|
|
||||||
3. 개발자 도구에서 모듈 구조 확인 가능
|
|
||||||
|
|
||||||
## 향후 확장 계획
|
|
||||||
|
|
||||||
1. **TypeScript 도입**: 타입 안전성 확보
|
|
||||||
2. **테스트 코드**: 단위 테스트 및 통합 테스트
|
|
||||||
3. **번들링**: Webpack/Vite를 통한 최적화
|
|
||||||
4. **PWA 기능**: 오프라인 지원 및 캐싱
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
/* 비정상 궤적 모니터링 전용 스타일 */
|
|
||||||
|
|
||||||
/* 레이아웃 */
|
|
||||||
.left-panel {
|
|
||||||
width: 450px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 필터 섹션 */
|
|
||||||
.filter-section {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 선박 목록 */
|
|
||||||
.vessel-list {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-group {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 8px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-header {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 12px 15px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-header:hover {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-header.active {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-info {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-id {
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.95em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-stats {
|
|
||||||
font-size: 0.85em;
|
|
||||||
color: #6c757d;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-header.active .vessel-stats {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-count {
|
|
||||||
background: #dc3545;
|
|
||||||
color: white;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-tracks {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
background: #f8f9fa;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 트랙 아이템 */
|
|
||||||
.track-item {
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.2s;
|
|
||||||
font-size: 0.9em;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-item:hover {
|
|
||||||
background: #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-item.selected {
|
|
||||||
background: #cfe2ff;
|
|
||||||
border-left: 4px solid #007bff;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.track-checkbox {
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 사용자 정의 검출 패널 */
|
|
||||||
.custom-detection-panel {
|
|
||||||
padding: 20px;
|
|
||||||
border-bottom: 2px solid #e0e0e0;
|
|
||||||
background: #f0f8ff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 지도 */
|
|
||||||
#map {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 통계 카드 위치 */
|
|
||||||
.stat-cards {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
gap: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
min-width: 140px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 비정상 유형 배지 */
|
|
||||||
.abnormal-type-badge {
|
|
||||||
padding: 3px 8px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 0.75em;
|
|
||||||
color: white;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-extreme_speed {
|
|
||||||
background-color: #e74c3c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-extreme_distance {
|
|
||||||
background-color: #f39c12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-extreme_transition {
|
|
||||||
background-color: #9b59b6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-extreme_avg_speed_5min {
|
|
||||||
background-color: #3498db;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-impossible_transition {
|
|
||||||
background-color: #c0392b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-user_detected {
|
|
||||||
background-color: #95a5a6;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 범례 위치 */
|
|
||||||
.legend-panel {
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-label {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-count {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.8em;
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 빠른 날짜 선택 버튼 */
|
|
||||||
.date-range-buttons {
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-range-buttons .btn-group {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 정렬 옵션 */
|
|
||||||
.sort-section {
|
|
||||||
padding: 15px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 모달 스타일 */
|
|
||||||
.modal-content {
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check.border {
|
|
||||||
transition: background-color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check.border:hover {
|
|
||||||
background-color: #f8f9fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-check-input:checked + .form-check-label {
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 액션 버튼 */
|
|
||||||
#actionButtons {
|
|
||||||
background-color: #fff3cd;
|
|
||||||
border-left: 4px solid #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
#moveToAbnormalBtn:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 백 버튼 */
|
|
||||||
#backToAutoBtn {
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 통계 카드 애니메이션 */
|
|
||||||
@keyframes pulse {
|
|
||||||
0% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card.updating {
|
|
||||||
animation: pulse 0.5s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 디자인 */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.left-panel {
|
|
||||||
width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-cards {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.main-content {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.left-panel {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 50vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-cards {
|
|
||||||
position: relative;
|
|
||||||
top: auto;
|
|
||||||
right: auto;
|
|
||||||
flex-direction: row;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,299 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chunked Streaming GIS Animated - 특화 스타일
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* 기본 레이아웃 */
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
#mapContainer {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 제어 패널 */
|
|
||||||
.control-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 380px;
|
|
||||||
min-width: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 통계 패널 */
|
|
||||||
.stats-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
min-width: 250px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-value {
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 타임라인 패널 */
|
|
||||||
.timeline-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
min-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-controls {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-slider {
|
|
||||||
flex: 1;
|
|
||||||
margin: 0 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time-display {
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 14px;
|
|
||||||
min-width: 150px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 애니메이션 제어 */
|
|
||||||
.speed-control {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-speed {
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 렌더링 옵션 */
|
|
||||||
.render-options {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로그 패널 */
|
|
||||||
.log-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 120px;
|
|
||||||
left: 10px;
|
|
||||||
width: calc(40% - 20px);
|
|
||||||
max-width: 600px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
max-height: 250px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-entry {
|
|
||||||
font-family: 'Consolas', 'Monaco', monospace;
|
|
||||||
font-size: 11px;
|
|
||||||
padding: 2px 4px;
|
|
||||||
margin: 1px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로그 타입별 색상 */
|
|
||||||
.log-info {
|
|
||||||
color: #004085;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-success {
|
|
||||||
color: #155724;
|
|
||||||
background: #d4edda;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-error {
|
|
||||||
color: #721c24;
|
|
||||||
background: #f8d7da;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-chunk {
|
|
||||||
color: #856404;
|
|
||||||
background: #fff3cd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 범례 */
|
|
||||||
.legend {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 120px;
|
|
||||||
right: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 6px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 24px;
|
|
||||||
height: 4px;
|
|
||||||
margin-right: 8px;
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-icon {
|
|
||||||
width: 12px;
|
|
||||||
height: 12px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 툴팁 */
|
|
||||||
#vesselTooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
color: white;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 폼 섹션 */
|
|
||||||
.form-section {
|
|
||||||
margin-bottom: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-bottom: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 진행률 바 */
|
|
||||||
.progress {
|
|
||||||
height: 25px;
|
|
||||||
margin: 15px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 조정 */
|
|
||||||
@media (max-width: 1200px) {
|
|
||||||
.control-panel {
|
|
||||||
max-width: 320px;
|
|
||||||
min-width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-panel {
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-panel {
|
|
||||||
min-width: 500px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-panel {
|
|
||||||
width: calc(35% - 20px);
|
|
||||||
max-width: 450px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.control-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
max-width: none;
|
|
||||||
min-width: 0;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stats-panel {
|
|
||||||
position: fixed;
|
|
||||||
top: auto;
|
|
||||||
bottom: 200px;
|
|
||||||
right: 10px;
|
|
||||||
min-width: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timeline-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 10px;
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
min-width: 0;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.log-panel {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 120px;
|
|
||||||
left: 10px;
|
|
||||||
right: 200px;
|
|
||||||
width: auto;
|
|
||||||
max-width: none;
|
|
||||||
max-height: 150px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 200px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.speed-control {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-speed {
|
|
||||||
font-size: 10px;
|
|
||||||
padding: 3px 6px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,327 +0,0 @@
|
|||||||
/* 공통 스타일 - 모든 페이지에서 사용 */
|
|
||||||
|
|
||||||
/* 기본 레이아웃 */
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
||||||
background-color: #f5f5f5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 컨테이너 */
|
|
||||||
.app-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-container {
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.main-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 헤더 */
|
|
||||||
.header-panel {
|
|
||||||
background: white;
|
|
||||||
padding: 15px 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 패널 */
|
|
||||||
.left-panel {
|
|
||||||
background: white;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-panel {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-panel {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(255, 255, 255, 0.95);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 카드 */
|
|
||||||
.metric-card {
|
|
||||||
background: white;
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
margin-bottom: 20px;
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-card:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-card {
|
|
||||||
background: white;
|
|
||||||
padding: 15px 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 통계 */
|
|
||||||
.stat-number {
|
|
||||||
font-size: 1.8em;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
margin: 5px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.stat-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.85em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-value {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #007bff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-label {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 상태 표시 */
|
|
||||||
.status-normal {
|
|
||||||
color: #28a745;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-warning {
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-critical {
|
|
||||||
color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-completed {
|
|
||||||
background-color: #28a745;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-failed {
|
|
||||||
background-color: #dc3545;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-started {
|
|
||||||
background-color: #ffc107;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-stopped {
|
|
||||||
background-color: #6c757d;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로딩 */
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
padding: 50px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-data {
|
|
||||||
text-align: center;
|
|
||||||
padding: 50px;
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 에러 메시지 */
|
|
||||||
.error-message {
|
|
||||||
background: #f8d7da;
|
|
||||||
color: #721c24;
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 툴팁 */
|
|
||||||
.tooltip-popup {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(0, 0, 0, 0.9);
|
|
||||||
color: white;
|
|
||||||
padding: 10px 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
max-width: 300px;
|
|
||||||
line-height: 1.4;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-popup .tooltip-header {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 5px;
|
|
||||||
color: #ffc107;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tooltip-popup .tooltip-row {
|
|
||||||
margin: 2px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 차트 컨테이너 */
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 400px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 테이블 */
|
|
||||||
.table-container {
|
|
||||||
max-height: 600px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-header th {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: white;
|
|
||||||
z-index: 10;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 스크롤바 스타일링 */
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 그리드 */
|
|
||||||
.stats-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
||||||
gap: 15px;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 버튼 그룹 */
|
|
||||||
.action-buttons {
|
|
||||||
padding: 15px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 범례 */
|
|
||||||
.legend-panel {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-title {
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
font-size: 0.9em;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 3px;
|
|
||||||
margin-right: 8px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 유틸리티 클래스 */
|
|
||||||
.text-center {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: #6c757d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-0 {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-2 {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mb-3 {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mt-2 {
|
|
||||||
margin-top: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.w-100 {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.d-flex {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.flex-1 {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gap-2 {
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
@ -1,514 +0,0 @@
|
|||||||
/* GIS 모니터링 전용 스타일 */
|
|
||||||
|
|
||||||
/* 메인 컨테이너 */
|
|
||||||
#mapContainer {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
position: relative;
|
|
||||||
background: #1a1a1a;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 컨트롤 패널 */
|
|
||||||
.gis-control-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 10px;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1000;
|
|
||||||
max-width: 420px;
|
|
||||||
min-width: 360px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gis-control-panel.collapsed {
|
|
||||||
padding: 10px;
|
|
||||||
min-width: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gis-control-panel.collapsed .panel-content {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-toggle {
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.panel-toggle:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 맵 컨트롤 */
|
|
||||||
.map-controls {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
width: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 트랙 정보 패널 */
|
|
||||||
.track-info-panel {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
left: 450px;
|
|
||||||
width: 380px;
|
|
||||||
max-height: 450px;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
overflow-y: auto;
|
|
||||||
display: none;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1001;
|
|
||||||
animation: slideIn 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 선박 리스트 */
|
|
||||||
.vessel-list-section {
|
|
||||||
margin-top: 15px;
|
|
||||||
padding-top: 15px;
|
|
||||||
border-top: 1px solid #dee2e6;
|
|
||||||
max-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: max-height 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-list-section.expanded {
|
|
||||||
max-height: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item {
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 6px;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
transition: all 0.2s;
|
|
||||||
background: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item:hover {
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-color: #007bff;
|
|
||||||
transform: translateX(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-item.selected {
|
|
||||||
background: #e7f3ff;
|
|
||||||
border-color: #007bff;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,123,255,0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-checkbox {
|
|
||||||
margin-right: 12px;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-info {
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-info strong {
|
|
||||||
display: block;
|
|
||||||
margin-bottom: 2px;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-info small {
|
|
||||||
color: #6c757d;
|
|
||||||
font-size: 0.85em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 정렬 컨트롤 */
|
|
||||||
.sort-controls {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
align-items: center;
|
|
||||||
padding: 8px;
|
|
||||||
background: #f8f9fa;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sort-controls label {
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #495057;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 필터 컨트롤 */
|
|
||||||
.filter-controls {
|
|
||||||
background: #f8f9fa;
|
|
||||||
padding: 15px;
|
|
||||||
border-radius: 6px;
|
|
||||||
margin-top: 12px;
|
|
||||||
border: 1px solid #e9ecef;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-controls h6 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: #495057;
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-range {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-range input {
|
|
||||||
width: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Sequential Passage Panel */
|
|
||||||
.sequential-panel {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
right: 20px;
|
|
||||||
width: 400px;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 20px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1001;
|
|
||||||
display: none;
|
|
||||||
animation: slideUp 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-selector {
|
|
||||||
border: 2px dashed #dee2e6;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
background: #fafafa;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-selector.has-zones {
|
|
||||||
border-color: #007bff;
|
|
||||||
border-style: solid;
|
|
||||||
background: #f0f8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-tag {
|
|
||||||
display: inline-block;
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 20px;
|
|
||||||
margin: 3px;
|
|
||||||
font-size: 0.85em;
|
|
||||||
animation: fadeIn 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: scale(0.8); }
|
|
||||||
to { opacity: 1; transform: scale(1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-tag .remove-zone {
|
|
||||||
margin-left: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zone-tag .remove-zone:hover {
|
|
||||||
color: #ffcccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 범례 */
|
|
||||||
.legend {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 20px;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
padding: 18px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 1002;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend h6 {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
font-size: 0.95em;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin: 8px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-color {
|
|
||||||
width: 24px;
|
|
||||||
height: 24px;
|
|
||||||
margin-right: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-gradient {
|
|
||||||
width: 220px;
|
|
||||||
height: 24px;
|
|
||||||
margin: 12px 0;
|
|
||||||
background: linear-gradient(to right,
|
|
||||||
#e0e0e0 0%,
|
|
||||||
#90EE90 25%,
|
|
||||||
#FFD700 50%,
|
|
||||||
#FFA500 75%,
|
|
||||||
#FF4500 100%);
|
|
||||||
border: 1px solid #333;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-labels {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 11px;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 로딩 오버레이 */
|
|
||||||
.loading-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
display: none;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 9999;
|
|
||||||
backdrop-filter: blur(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
color: white;
|
|
||||||
font-size: 48px;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
from { transform: rotate(0deg); }
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-text {
|
|
||||||
color: white;
|
|
||||||
margin-top: 15px;
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 컨텍스트 메뉴 */
|
|
||||||
.context-menu {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid #ddd;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
z-index: 10000;
|
|
||||||
display: none;
|
|
||||||
min-width: 180px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item {
|
|
||||||
padding: 10px 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item:hover {
|
|
||||||
background: #f0f8ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item.disabled {
|
|
||||||
color: #999;
|
|
||||||
cursor: not-allowed;
|
|
||||||
background: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item.disabled:hover {
|
|
||||||
background: #f8f8f8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.context-menu-item i {
|
|
||||||
width: 20px;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 툴팁 */
|
|
||||||
.vessel-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
background: rgba(255, 255, 255, 0.98);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 13px;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 9999;
|
|
||||||
max-width: 320px;
|
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 선택 카운트 배지 */
|
|
||||||
.selected-count {
|
|
||||||
background: #007bff;
|
|
||||||
color: white;
|
|
||||||
padding: 2px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
margin-left: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 액션 버튼 그룹 */
|
|
||||||
.action-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons .btn {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 0.9em;
|
|
||||||
padding: 6px 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 검색 결과 */
|
|
||||||
#sequentialResults {
|
|
||||||
margin-top: 15px;
|
|
||||||
max-height: 320px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid #e0e0e0;
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#sequentialResults .vessel-item {
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 반응형 개선 */
|
|
||||||
@media (max-width: 1400px) {
|
|
||||||
.track-info-panel {
|
|
||||||
left: 10px;
|
|
||||||
top: 280px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.gis-control-panel,
|
|
||||||
.map-controls,
|
|
||||||
.track-info-panel,
|
|
||||||
.sequential-panel {
|
|
||||||
position: fixed;
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
left: 10px;
|
|
||||||
right: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sequential-panel {
|
|
||||||
bottom: 10px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 개선된 스크롤바 */
|
|
||||||
.vessel-list-content::-webkit-scrollbar,
|
|
||||||
#sequentialResults::-webkit-scrollbar {
|
|
||||||
width: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-list-content::-webkit-scrollbar-track,
|
|
||||||
#sequentialResults::-webkit-scrollbar-track {
|
|
||||||
background: #f1f1f1;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-list-content::-webkit-scrollbar-thumb,
|
|
||||||
#sequentialResults::-webkit-scrollbar-thumb {
|
|
||||||
background: #888;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vessel-list-content::-webkit-scrollbar-thumb:hover,
|
|
||||||
#sequentialResults::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 포커스 스타일 */
|
|
||||||
input:focus,
|
|
||||||
select:focus,
|
|
||||||
button:focus {
|
|
||||||
outline: none;
|
|
||||||
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 트랜지션 개선 */
|
|
||||||
* {
|
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* 비정상 궤적 API 모듈
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { API_ENDPOINTS } from '../utils/constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 비정상 궤적 조회
|
|
||||||
* @param {number} hours - 조회할 시간 범위
|
|
||||||
* @returns {Promise<Array>} 비정상 궤적 목록
|
|
||||||
*/
|
|
||||||
export async function fetchRecentTracks(hours) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_ENDPOINTS.ABNORMAL_TRACKS.RECENT}?hours=${hours}`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching recent tracks:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 정의 비정상 궤적 검출
|
|
||||||
* @param {Object} params - 검출 파라미터
|
|
||||||
* @returns {Promise<Array>} 검출된 궤적 목록
|
|
||||||
*/
|
|
||||||
export async function detectCustomTracks(params) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_ENDPOINTS.ABNORMAL_TRACKS.DETECT, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error detecting custom tracks:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택된 궤적을 비정상 테이블로 이동
|
|
||||||
* @param {Object} params - 이동 파라미터
|
|
||||||
* @returns {Promise<Object>} 처리 결과
|
|
||||||
*/
|
|
||||||
export async function moveTracksToAbnormal(params) {
|
|
||||||
try {
|
|
||||||
const response = await fetch(API_ENDPOINTS.ABNORMAL_TRACKS.MOVE_TO_ABNORMAL, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
return await response.json();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error moving tracks to abnormal:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터링된 비정상 궤적 조회
|
|
||||||
* @param {Object} filters - 필터 조건
|
|
||||||
* @returns {Promise<Array>} 필터링된 궤적 목록
|
|
||||||
*/
|
|
||||||
export async function fetchFilteredTracks(filters) {
|
|
||||||
const { startDate, endDate, type, vesselId } = filters;
|
|
||||||
|
|
||||||
// 날짜를 시간 차이로 변환
|
|
||||||
const start = new Date(startDate);
|
|
||||||
const end = new Date(endDate);
|
|
||||||
end.setHours(23, 59, 59);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const hoursFromNow = Math.ceil((now - start) / (1000 * 60 * 60));
|
|
||||||
|
|
||||||
// 전체 데이터 조회
|
|
||||||
const allTracks = await fetchRecentTracks(hoursFromNow);
|
|
||||||
|
|
||||||
// 클라이언트 측 필터링
|
|
||||||
let filteredTracks = allTracks.filter(track => {
|
|
||||||
const trackDate = new Date(track.detectedAt);
|
|
||||||
return trackDate >= start && trackDate <= end;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (type) {
|
|
||||||
filteredTracks = filteredTracks.filter(t => t.abnormalType === type);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vesselId) {
|
|
||||||
filteredTracks = filteredTracks.filter(t => t.vesselId.includes(vesselId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return filteredTracks;
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
/**
|
|
||||||
* GIS 모니터링 API 모듈
|
|
||||||
*/
|
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haegu (해구) 관련 API
|
|
||||||
*/
|
|
||||||
export const HaeguAPI = {
|
|
||||||
/**
|
|
||||||
* Haegu 경계 데이터 조회
|
|
||||||
*/
|
|
||||||
async getBoundaries() {
|
|
||||||
const response = await fetch(`${API_BASE}/haegu/boundaries`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load haegu boundaries');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haegu 선박 통계 조회
|
|
||||||
* @param {number} minutes - 조회 시간 범위 (분)
|
|
||||||
*/
|
|
||||||
async getVesselStats(minutes) {
|
|
||||||
const response = await fetch(`${API_BASE}/haegu/vessel-stats?minutes=${minutes}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load haegu vessel stats');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 Haegu의 트랙 조회
|
|
||||||
* @param {string} haeguNo - Haegu 번호
|
|
||||||
* @param {number} minutes - 조회 시간 범위 (분)
|
|
||||||
*/
|
|
||||||
async getTracks(haeguNo, minutes) {
|
|
||||||
const response = await fetch(`${API_BASE}/tracks/haegu/${haeguNo}?minutes=${minutes}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load haegu tracks');
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Area (사용자 정의 구역) 관련 API
|
|
||||||
*/
|
|
||||||
export const AreaAPI = {
|
|
||||||
/**
|
|
||||||
* Area 경계 데이터 조회
|
|
||||||
*/
|
|
||||||
async getBoundaries() {
|
|
||||||
const response = await fetch(`${API_BASE}/areas/boundaries`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load area boundaries');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Area 선박 통계 조회
|
|
||||||
* @param {number} minutes - 조회 시간 범위 (분)
|
|
||||||
*/
|
|
||||||
async getVesselStats(minutes) {
|
|
||||||
const response = await fetch(`${API_BASE}/areas/vessel-stats?minutes=${minutes}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load area vessel stats');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 Area의 트랙 조회
|
|
||||||
* @param {string} areaId - Area ID
|
|
||||||
* @param {number} minutes - 조회 시간 범위 (분)
|
|
||||||
*/
|
|
||||||
async getTracks(areaId, minutes) {
|
|
||||||
const response = await fetch(`${API_BASE}/tracks/area/${areaId}?minutes=${minutes}`);
|
|
||||||
if (!response.ok) throw new Error('Failed to load area tracks');
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 관련 API
|
|
||||||
*/
|
|
||||||
export const TrackAPI = {
|
|
||||||
/**
|
|
||||||
* 구역별 트랙 조회 (통합)
|
|
||||||
* @param {string} type - 'haegu' 또는 'area'
|
|
||||||
* @param {string} id - 구역 ID
|
|
||||||
* @param {number} minutes - 조회 시간 범위 (분)
|
|
||||||
*/
|
|
||||||
async getByArea(type, id, minutes) {
|
|
||||||
const response = await fetch(`${API_BASE}/tracks/${type}/${id}?minutes=${minutes}`);
|
|
||||||
if (!response.ok) throw new Error(`Failed to load ${type} tracks`);
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 선박들의 트랙 조회
|
|
||||||
* @param {Object} params - 조회 파라미터
|
|
||||||
*/
|
|
||||||
async getVesselTracks(params) {
|
|
||||||
const response = await fetch(`${API_BASE}/tracks/vessels`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params)
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to load vessel tracks');
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sequential Passage 관련 API
|
|
||||||
*/
|
|
||||||
export const PassageAPI = {
|
|
||||||
/**
|
|
||||||
* 순차 통과 선박 검색
|
|
||||||
* @param {Object} params - 검색 파라미터
|
|
||||||
*/
|
|
||||||
async searchSequential(params) {
|
|
||||||
const response = await fetch(`${API_BASE}/passages/sequential`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params)
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to search sequential passages');
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 구역 통과 선박 검색
|
|
||||||
* @param {Object} params - 검색 파라미터
|
|
||||||
*/
|
|
||||||
async searchAllZones(params) {
|
|
||||||
const response = await fetch(`${API_BASE}/passages/all-zones`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(params)
|
|
||||||
});
|
|
||||||
if (!response.ok) throw new Error('Failed to search all zones passages');
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* API 호출 헬퍼 함수
|
|
||||||
*/
|
|
||||||
export class APIHelper {
|
|
||||||
/**
|
|
||||||
* 병렬 API 호출
|
|
||||||
* @param {Array<Promise>} promises - API 호출 프로미스 배열
|
|
||||||
* @returns {Promise<Array>} 결과 배열
|
|
||||||
*/
|
|
||||||
static async parallel(promises) {
|
|
||||||
try {
|
|
||||||
return await Promise.all(promises);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Parallel API call failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 재시도 로직이 포함된 API 호출
|
|
||||||
* @param {Function} apiCall - API 호출 함수
|
|
||||||
* @param {number} maxRetries - 최대 재시도 횟수
|
|
||||||
* @param {number} delay - 재시도 간격 (ms)
|
|
||||||
*/
|
|
||||||
static async withRetry(apiCall, maxRetries = 3, delay = 1000) {
|
|
||||||
let lastError;
|
|
||||||
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
return await apiCall();
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
if (i < maxRetries - 1) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw lastError;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임스탬프 포맷팅
|
|
||||||
* @param {string} timestamp - 타임스탬프
|
|
||||||
* @returns {string} 포맷된 타임스탬프
|
|
||||||
*/
|
|
||||||
static formatTimestamp(timestamp) {
|
|
||||||
if (!timestamp) return '';
|
|
||||||
|
|
||||||
// T 형식이 포함된 경우 초 추가
|
|
||||||
if (timestamp.includes('T') && !timestamp.includes(':00', timestamp.lastIndexOf(':'))) {
|
|
||||||
return timestamp + ':00';
|
|
||||||
}
|
|
||||||
|
|
||||||
return timestamp;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* WebSocket API for chunked streaming
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class WebSocketAPI {
|
|
||||||
constructor() {
|
|
||||||
this.stompClient = null;
|
|
||||||
this.currentQueryId = null;
|
|
||||||
this.isConnected = false;
|
|
||||||
this.onConnectionChange = null;
|
|
||||||
this.handlers = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 연결
|
|
||||||
*/
|
|
||||||
connect(wsUrl = null) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
if (this.isConnected) {
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket URL 결정: 파라미터 > 환경변수 > 현재 호스트
|
|
||||||
const socketUrl = wsUrl ||
|
|
||||||
window.WS_ENDPOINT ||
|
|
||||||
`${window.location.protocol}//${window.location.hostname}:8090/ws-tracks`;
|
|
||||||
|
|
||||||
console.log('Connecting to WebSocket:', socketUrl);
|
|
||||||
|
|
||||||
const socket = new SockJS(socketUrl);
|
|
||||||
this.stompClient = Stomp.over(socket);
|
|
||||||
this.stompClient.debug = null; // 디버그 끄기
|
|
||||||
|
|
||||||
this.stompClient.connect({}, (frame) => {
|
|
||||||
this.isConnected = true;
|
|
||||||
this.onConnectionChange?.(true);
|
|
||||||
this.setupSubscriptions();
|
|
||||||
resolve(frame);
|
|
||||||
}, (error) => {
|
|
||||||
this.isConnected = false;
|
|
||||||
this.onConnectionChange?.(false);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 연결 해제
|
|
||||||
*/
|
|
||||||
disconnect() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (this.stompClient && this.stompClient.connected) {
|
|
||||||
this.stompClient.disconnect(() => {
|
|
||||||
this.isConnected = false;
|
|
||||||
this.onConnectionChange?.(false);
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구독 설정
|
|
||||||
*/
|
|
||||||
setupSubscriptions() {
|
|
||||||
if (!this.stompClient || !this.stompClient.connected) return;
|
|
||||||
|
|
||||||
this.stompClient.subscribe('/user/queue/tracks/response', (message) => {
|
|
||||||
this.handlers.response?.(JSON.parse(message.body));
|
|
||||||
});
|
|
||||||
|
|
||||||
this.stompClient.subscribe('/user/queue/tracks/status', (message) => {
|
|
||||||
this.handlers.status?.(JSON.parse(message.body));
|
|
||||||
});
|
|
||||||
|
|
||||||
this.stompClient.subscribe('/user/queue/tracks/chunk', (message) => {
|
|
||||||
this.handlers.chunk?.(message);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.stompClient.subscribe('/user/queue/errors', (message) => {
|
|
||||||
this.handlers.error?.(message.body);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 시작
|
|
||||||
*/
|
|
||||||
startQuery(queryParams) {
|
|
||||||
if (!this.isConnected || !this.stompClient) {
|
|
||||||
throw new Error('WebSocket이 연결되지 않았습니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const request = {
|
|
||||||
startTime: queryParams.startTime,
|
|
||||||
endTime: queryParams.endTime,
|
|
||||||
viewport: queryParams.viewport,
|
|
||||||
zoomLevel: queryParams.zoomLevel,
|
|
||||||
chunkedMode: true,
|
|
||||||
chunkSize: queryParams.chunkSize || 20000
|
|
||||||
};
|
|
||||||
|
|
||||||
this.stompClient.send('/app/tracks/query', {}, JSON.stringify(request));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 취소
|
|
||||||
*/
|
|
||||||
cancelQuery() {
|
|
||||||
if (this.currentQueryId && this.stompClient && this.stompClient.connected) {
|
|
||||||
this.stompClient.send('/app/tracks/cancel/' + this.currentQueryId, {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setHandlers(handlers) {
|
|
||||||
this.handlers = { ...this.handlers, ...handlers };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 연결 상태 핸들러 설정
|
|
||||||
*/
|
|
||||||
onConnectionStatusChange(callback) {
|
|
||||||
this.onConnectionChange = callback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 연결 상태 반환
|
|
||||||
*/
|
|
||||||
getConnectionStatus() {
|
|
||||||
return this.isConnected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 쿼리 ID 설정
|
|
||||||
*/
|
|
||||||
setCurrentQueryId(queryId) {
|
|
||||||
this.currentQueryId = queryId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 쿼리 ID 반환
|
|
||||||
*/
|
|
||||||
getCurrentQueryId() {
|
|
||||||
return this.currentQueryId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
/**
|
|
||||||
* Animation Controller for Chunked Streaming
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ChunkedAnimationController {
|
|
||||||
constructor() {
|
|
||||||
this.animationState = {
|
|
||||||
playing: false,
|
|
||||||
currentTime: 0,
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 0,
|
|
||||||
speed: 1,
|
|
||||||
lastFrameTime: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
this.animationFrame = null;
|
|
||||||
this.onTimeUpdate = null;
|
|
||||||
this.onPlayStateChange = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 시간 범위 설정
|
|
||||||
*/
|
|
||||||
setTimeRange(startTime, endTime) {
|
|
||||||
this.animationState.startTime = startTime;
|
|
||||||
this.animationState.endTime = endTime;
|
|
||||||
this.animationState.currentTime = startTime;
|
|
||||||
|
|
||||||
// console.debug('Animation time range set:', { startTime, endTime });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 재생/일시정지 토글
|
|
||||||
*/
|
|
||||||
togglePlay() {
|
|
||||||
this.animationState.playing = !this.animationState.playing;
|
|
||||||
|
|
||||||
if (this.animationState.playing) {
|
|
||||||
this.start();
|
|
||||||
} else {
|
|
||||||
this.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onPlayStateChange?.(this.animationState.playing);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 시작
|
|
||||||
*/
|
|
||||||
start() {
|
|
||||||
if (this.animationState.playing) {
|
|
||||||
this.animationState.lastFrameTime = performance.now();
|
|
||||||
this.animate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 강제 시작 (디버깅용)
|
|
||||||
*/
|
|
||||||
forceStart() {
|
|
||||||
this.animationState.lastFrameTime = performance.now();
|
|
||||||
this.animate();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 중지
|
|
||||||
*/
|
|
||||||
stop() {
|
|
||||||
if (this.animationFrame) {
|
|
||||||
cancelAnimationFrame(this.animationFrame);
|
|
||||||
this.animationFrame = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 프레임 처리
|
|
||||||
*/
|
|
||||||
animate() {
|
|
||||||
if (!this.animationState.playing) return;
|
|
||||||
|
|
||||||
const now = performance.now();
|
|
||||||
const deltaTime = now - this.animationState.lastFrameTime;
|
|
||||||
this.animationState.lastFrameTime = now;
|
|
||||||
|
|
||||||
// 시간 범위 검증
|
|
||||||
if (this.animationState.startTime === 0 || this.animationState.endTime === 0) {
|
|
||||||
// 시간 범위가 설정되지 않으면 애니메이션 중지
|
|
||||||
console.warn('Animation time range not set');
|
|
||||||
this.animationState.playing = false;
|
|
||||||
this.onPlayStateChange?.(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시간 업데이트
|
|
||||||
this.animationState.currentTime += deltaTime * this.animationState.speed;
|
|
||||||
|
|
||||||
// 루프 처리
|
|
||||||
if (this.animationState.currentTime > this.animationState.endTime) {
|
|
||||||
this.animationState.currentTime = this.animationState.startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onTimeUpdate?.(this.animationState.currentTime);
|
|
||||||
|
|
||||||
this.animationFrame = requestAnimationFrame(() => this.animate());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 속도 설정
|
|
||||||
*/
|
|
||||||
setSpeed(speed) {
|
|
||||||
this.animationState.speed = speed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 시간 설정 (타임라인 슬라이더에서 사용)
|
|
||||||
*/
|
|
||||||
setCurrentTime(time) {
|
|
||||||
this.animationState.currentTime = time;
|
|
||||||
this.onTimeUpdate?.(this.animationState.currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 슬라이더 진행률로 시간 설정
|
|
||||||
*/
|
|
||||||
setTimeFromProgress(progress) {
|
|
||||||
if (!this.animationState.startTime || !this.animationState.endTime) return;
|
|
||||||
|
|
||||||
const time = this.animationState.startTime +
|
|
||||||
(this.animationState.endTime - this.animationState.startTime) * (progress / 100);
|
|
||||||
this.setCurrentTime(time);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 진행률 계산
|
|
||||||
*/
|
|
||||||
getProgress() {
|
|
||||||
if (!this.animationState.startTime || !this.animationState.endTime) return 0;
|
|
||||||
|
|
||||||
return (this.animationState.currentTime - this.animationState.startTime) /
|
|
||||||
(this.animationState.endTime - this.animationState.startTime) * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 초기화
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.stop();
|
|
||||||
this.animationState = {
|
|
||||||
playing: false,
|
|
||||||
currentTime: 0,
|
|
||||||
startTime: 0,
|
|
||||||
endTime: 0,
|
|
||||||
speed: 1,
|
|
||||||
lastFrameTime: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
this.onTimeUpdate = handlers.onTimeUpdate;
|
|
||||||
this.onPlayStateChange = handlers.onPlayStateChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 재생 상태 반환
|
|
||||||
*/
|
|
||||||
isPlaying() {
|
|
||||||
return this.animationState.playing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 시간 반환
|
|
||||||
*/
|
|
||||||
getCurrentTime() {
|
|
||||||
return this.animationState.currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시간 범위 반환
|
|
||||||
*/
|
|
||||||
getTimeRange() {
|
|
||||||
return {
|
|
||||||
startTime: this.animationState.startTime,
|
|
||||||
endTime: this.animationState.endTime
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 속도 반환
|
|
||||||
*/
|
|
||||||
getSpeed() {
|
|
||||||
return this.animationState.speed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임라인 데이터 생성
|
|
||||||
*/
|
|
||||||
getTimelineData() {
|
|
||||||
const currentDate = new Date(this.animationState.currentTime);
|
|
||||||
const startDate = new Date(this.animationState.startTime);
|
|
||||||
const endDate = new Date(this.animationState.endTime);
|
|
||||||
|
|
||||||
return {
|
|
||||||
progress: this.getProgress(),
|
|
||||||
currentDate: currentDate.toLocaleDateString('ko-KR', {
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit'
|
|
||||||
}),
|
|
||||||
currentTime: currentDate.toLocaleTimeString(),
|
|
||||||
startTime: startDate.toLocaleTimeString(),
|
|
||||||
endTime: endDate.toLocaleTimeString()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 상태 디버깅
|
|
||||||
*/
|
|
||||||
debugState() {
|
|
||||||
return {
|
|
||||||
playing: this.animationState.playing,
|
|
||||||
currentTime: new Date(this.animationState.currentTime).toISOString(),
|
|
||||||
startTime: new Date(this.animationState.startTime).toISOString(),
|
|
||||||
endTime: new Date(this.animationState.endTime).toISOString(),
|
|
||||||
speed: this.animationState.speed,
|
|
||||||
hasTimeRange: this.animationState.startTime !== 0 && this.animationState.endTime !== 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,251 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chunked Data Manager - 선박 데이터 관리
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ChunkedDataManager {
|
|
||||||
constructor() {
|
|
||||||
this.vesselChunks = new Map();
|
|
||||||
this.vesselTimeIndex = new Map();
|
|
||||||
this.chunksReceived = 0;
|
|
||||||
this.totalDataSize = 0;
|
|
||||||
this.totalPoints = 0;
|
|
||||||
this.startTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기화
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.vesselChunks.clear();
|
|
||||||
this.vesselTimeIndex.clear();
|
|
||||||
this.chunksReceived = 0;
|
|
||||||
this.totalDataSize = 0;
|
|
||||||
this.totalPoints = 0;
|
|
||||||
this.startTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 청크 데이터 처리
|
|
||||||
*/
|
|
||||||
processChunk(message) {
|
|
||||||
const parseStart = performance.now();
|
|
||||||
const chunk = JSON.parse(message.body);
|
|
||||||
|
|
||||||
this.chunksReceived++;
|
|
||||||
this.totalDataSize += message.body.length / 1024;
|
|
||||||
|
|
||||||
// 첫 번째와 마지막 청크만 로그
|
|
||||||
if (this.chunksReceived === 1 || chunk.isLast) {
|
|
||||||
console.log(`[CHUNK ${this.chunksReceived}] Received:`, {
|
|
||||||
tracksCount: chunk.compactTracks ? chunk.compactTracks.length : 0,
|
|
||||||
isLast: chunk.isLast
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.compactTracks) {
|
|
||||||
chunk.compactTracks.forEach((track, idx) => {
|
|
||||||
this.processTrack(track, idx);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseTime = performance.now() - parseStart;
|
|
||||||
|
|
||||||
return {
|
|
||||||
chunk,
|
|
||||||
parseTime,
|
|
||||||
stats: chunk.stats || {}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 트랙 처리
|
|
||||||
*/
|
|
||||||
processTrack(track, idx) {
|
|
||||||
const vesselId = track.vesselId;
|
|
||||||
|
|
||||||
// 디버그 모드일 때만 첫 번째 트랙 로그
|
|
||||||
if (idx === 0 && this.chunksReceived === 1) {
|
|
||||||
console.log(`[FIRST_TRACK] ${vesselId}:`, {
|
|
||||||
timestampsLength: track.timestamps ? track.timestamps.length : 0,
|
|
||||||
geometryLength: track.geometry ? track.geometry.length : 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 청크 단위로 저장 (병합하지 않음)
|
|
||||||
if (!this.vesselChunks.has(vesselId)) {
|
|
||||||
this.vesselChunks.set(vesselId, {
|
|
||||||
sigSrcCd: track.sigSrcCd,
|
|
||||||
targetId: track.targetId,
|
|
||||||
chunks: [],
|
|
||||||
cachedPath: null,
|
|
||||||
totalDistance: 0,
|
|
||||||
avgSpeed: 0,
|
|
||||||
maxSpeed: 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const vessel = this.vesselChunks.get(vesselId);
|
|
||||||
|
|
||||||
// 청크 추가
|
|
||||||
vessel.chunks.push({
|
|
||||||
geometry: track.geometry,
|
|
||||||
timestamps: track.timestamps,
|
|
||||||
speeds: track.speeds,
|
|
||||||
pointCount: track.geometry.length
|
|
||||||
});
|
|
||||||
|
|
||||||
// 캐시 무효화
|
|
||||||
vessel.cachedPath = null;
|
|
||||||
|
|
||||||
// 통계 업데이트
|
|
||||||
vessel.totalDistance += track.totalDistance || 0;
|
|
||||||
vessel.maxSpeed = Math.max(vessel.maxSpeed, track.maxSpeed || 0);
|
|
||||||
vessel.avgSpeed = track.avgSpeed || 0;
|
|
||||||
|
|
||||||
this.totalPoints += track.geometry.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 경로 생성 (렌더링 시점에만)
|
|
||||||
*/
|
|
||||||
createVesselPath(vessel) {
|
|
||||||
// flatMap으로 가상 병합 (메모리 복사 없음)
|
|
||||||
const path = {
|
|
||||||
geometry: vessel.chunks.flatMap(c => c.geometry),
|
|
||||||
timestamps: vessel.chunks.flatMap(c => c.timestamps),
|
|
||||||
speeds: vessel.chunks.flatMap(c => c.speeds)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 필요시에만 디버그 로그 (개발 환경에서만)
|
|
||||||
// console.debug(`Path created for ${vessel.sigSrcCd}_${vessel.targetId}`);
|
|
||||||
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 시간의 선박 위치 계산
|
|
||||||
*/
|
|
||||||
getVesselPositionsAtTime(targetTime) {
|
|
||||||
const positions = [];
|
|
||||||
|
|
||||||
this.vesselChunks.forEach(vessel => {
|
|
||||||
const pos = this.interpolatePosition(vessel, targetTime);
|
|
||||||
if (pos) {
|
|
||||||
positions.push({
|
|
||||||
vesselId: `${vessel.sigSrcCd}_${vessel.targetId}`,
|
|
||||||
position: [pos.lon, pos.lat],
|
|
||||||
speed: pos.speed || vessel.avgSpeed
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return positions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치 보간 (청크 기반)
|
|
||||||
*/
|
|
||||||
interpolatePosition(vessel, targetTime) {
|
|
||||||
// 캐시된 경로가 없으면 생성
|
|
||||||
if (!vessel.cachedPath) {
|
|
||||||
vessel.cachedPath = this.createVesselPath(vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timestamps = vessel.cachedPath.timestamps;
|
|
||||||
if (!timestamps || timestamps.length === 0) return null;
|
|
||||||
|
|
||||||
// Unix timestamp(초) 문자열을 밀리초로 변환 (KST 보정)
|
|
||||||
const times = timestamps.map(t => {
|
|
||||||
if (typeof t === 'string' && t.match(/^\d{10,}$/)) {
|
|
||||||
// Unix timestamp - KST로 저장된 값이므로 -9시간 보정
|
|
||||||
const utcTime = parseInt(t) - (9 * 60 * 60);
|
|
||||||
return utcTime * 1000;
|
|
||||||
} else {
|
|
||||||
return new Date(t).getTime();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstTime = times[0];
|
|
||||||
const lastTime = times[times.length - 1];
|
|
||||||
|
|
||||||
if (targetTime < firstTime || targetTime > lastTime) return null;
|
|
||||||
|
|
||||||
// 이진 탐색으로 인덱스 찾기
|
|
||||||
let left = 0;
|
|
||||||
let right = times.length - 1;
|
|
||||||
|
|
||||||
while (left < right - 1) {
|
|
||||||
const mid = Math.floor((left + right) / 2);
|
|
||||||
if (times[mid] <= targetTime) {
|
|
||||||
left = mid;
|
|
||||||
} else {
|
|
||||||
right = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left === right) {
|
|
||||||
return {
|
|
||||||
lon: vessel.cachedPath.geometry[left][0],
|
|
||||||
lat: vessel.cachedPath.geometry[left][1],
|
|
||||||
speed: vessel.cachedPath.speeds[left]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선형 보간
|
|
||||||
const t1 = times[left];
|
|
||||||
const t2 = times[right];
|
|
||||||
const ratio = (targetTime - t1) / (t2 - t1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
lon: vessel.cachedPath.geometry[left][0] + (vessel.cachedPath.geometry[right][0] - vessel.cachedPath.geometry[left][0]) * ratio,
|
|
||||||
lat: vessel.cachedPath.geometry[left][1] + (vessel.cachedPath.geometry[right][1] - vessel.cachedPath.geometry[left][1]) * ratio,
|
|
||||||
speed: vessel.cachedPath.speeds[left] + (vessel.cachedPath.speeds[right] - vessel.cachedPath.speeds[left]) * ratio
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시간 인덱스 구축 (필요시)
|
|
||||||
*/
|
|
||||||
buildTimeIndex() {
|
|
||||||
this.vesselTimeIndex.clear();
|
|
||||||
|
|
||||||
this.vesselChunks.forEach((vessel, vesselId) => {
|
|
||||||
if (!vessel.cachedPath) {
|
|
||||||
vessel.cachedPath = this.createVesselPath(vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
vessel.cachedPath.timestamps.forEach((timestamp, idx) => {
|
|
||||||
const time = new Date(timestamp).getTime();
|
|
||||||
const minute = Math.floor(time / 60000) * 60000; // 분 단위
|
|
||||||
|
|
||||||
if (!this.vesselTimeIndex.has(minute)) {
|
|
||||||
this.vesselTimeIndex.set(minute, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.vesselTimeIndex.get(minute).push({
|
|
||||||
vesselId,
|
|
||||||
idx,
|
|
||||||
time
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
getVesselChunks() {
|
|
||||||
return this.vesselChunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatistics() {
|
|
||||||
return {
|
|
||||||
chunksReceived: this.chunksReceived,
|
|
||||||
vesselCount: this.vesselChunks.size,
|
|
||||||
pointCount: this.totalPoints,
|
|
||||||
dataSize: this.totalDataSize
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
hasData() {
|
|
||||||
return this.vesselChunks.size > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,300 +0,0 @@
|
|||||||
/**
|
|
||||||
* Map Renderer for Chunked Streaming
|
|
||||||
*/
|
|
||||||
|
|
||||||
export class ChunkedMapRenderer {
|
|
||||||
constructor(map) {
|
|
||||||
this.map = map;
|
|
||||||
this.deckOverlay = null;
|
|
||||||
this.dataManager = null;
|
|
||||||
this.currentLayers = [];
|
|
||||||
this.renderOptions = {
|
|
||||||
showTracks: true,
|
|
||||||
showVessels: true,
|
|
||||||
colorBySpeed: true,
|
|
||||||
trackOpacity: 60
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기화
|
|
||||||
*/
|
|
||||||
initialize() {
|
|
||||||
this.deckOverlay = new deck.MapboxOverlay({
|
|
||||||
interleaved: true,
|
|
||||||
layers: []
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.addControl(this.deckOverlay);
|
|
||||||
this.map.addControl(new maplibregl.NavigationControl());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터 매니저 설정
|
|
||||||
*/
|
|
||||||
setDataManager(dataManager) {
|
|
||||||
this.dataManager = dataManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 렌더링 옵션 설정
|
|
||||||
*/
|
|
||||||
setRenderOptions(options) {
|
|
||||||
this.renderOptions = { ...this.renderOptions, ...options };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateLayers(currentTime = null) {
|
|
||||||
if (!this.dataManager) return;
|
|
||||||
|
|
||||||
const layers = [];
|
|
||||||
|
|
||||||
// 디버그 로그 제거
|
|
||||||
|
|
||||||
// 궤적 레이어
|
|
||||||
if (this.renderOptions.showTracks) {
|
|
||||||
const pathLayer = this.createPathLayer();
|
|
||||||
if (pathLayer) layers.push(pathLayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선박 아이콘 레이어
|
|
||||||
if (this.renderOptions.showVessels && currentTime) {
|
|
||||||
const vesselLayer = this.createVesselLayer(currentTime);
|
|
||||||
if (vesselLayer) layers.push(vesselLayer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.currentLayers = layers;
|
|
||||||
this.deckOverlay.setProps({ layers });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 궤적 레이어 생성
|
|
||||||
*/
|
|
||||||
createPathLayer() {
|
|
||||||
const pathData = [];
|
|
||||||
const opacity = this.renderOptions.trackOpacity / 100;
|
|
||||||
const colorBySpeed = this.renderOptions.colorBySpeed;
|
|
||||||
|
|
||||||
let debugCount = 0;
|
|
||||||
this.dataManager.getVesselChunks().forEach(vessel => {
|
|
||||||
// 캐시된 경로가 없으면 생성
|
|
||||||
if (!vessel.cachedPath) {
|
|
||||||
vessel.cachedPath = this.dataManager.createVesselPath(vessel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (vessel.cachedPath.geometry.length > 1) {
|
|
||||||
const timestamps = vessel.cachedPath.timestamps;
|
|
||||||
|
|
||||||
// 디버그 로그 제거 (필요시 console.debug 사용)
|
|
||||||
debugCount++;
|
|
||||||
|
|
||||||
// 시간 정보 처리
|
|
||||||
const { startTime, endTime } = this.processTimestamps(timestamps, vessel);
|
|
||||||
|
|
||||||
pathData.push({
|
|
||||||
path: vessel.cachedPath.geometry,
|
|
||||||
color: colorBySpeed ? this.getSpeedColor(vessel.avgSpeed) : [0, 255, 0],
|
|
||||||
width: 2,
|
|
||||||
vesselId: `${vessel.sigSrcCd}_${vessel.targetId}`,
|
|
||||||
avgSpeed: vessel.avgSpeed,
|
|
||||||
distance: vessel.totalDistance,
|
|
||||||
startTime: startTime,
|
|
||||||
endTime: endTime
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pathData.length === 0) return null;
|
|
||||||
|
|
||||||
return new deck.PathLayer({
|
|
||||||
id: 'vessel-tracks',
|
|
||||||
data: pathData,
|
|
||||||
pickable: true,
|
|
||||||
widthScale: 1,
|
|
||||||
widthMinPixels: 1,
|
|
||||||
widthMaxPixels: 3,
|
|
||||||
getPath: d => d.path,
|
|
||||||
getColor: d => [...d.color, opacity * 255],
|
|
||||||
getWidth: d => d.width,
|
|
||||||
onHover: this.showTooltip.bind(this)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 아이콘 레이어 생성
|
|
||||||
*/
|
|
||||||
createVesselLayer(currentTime) {
|
|
||||||
const vesselPositions = this.dataManager.getVesselPositionsAtTime(currentTime);
|
|
||||||
|
|
||||||
if (vesselPositions.length === 0) return null;
|
|
||||||
|
|
||||||
return new deck.ScatterplotLayer({
|
|
||||||
id: 'vessel-icons',
|
|
||||||
data: vesselPositions,
|
|
||||||
pickable: true,
|
|
||||||
opacity: 0.8,
|
|
||||||
stroked: true,
|
|
||||||
filled: true,
|
|
||||||
radiusScale: 1,
|
|
||||||
radiusMinPixels: 3,
|
|
||||||
radiusMaxPixels: 8,
|
|
||||||
getPosition: d => d.position,
|
|
||||||
getFillColor: [255, 0, 0],
|
|
||||||
getLineColor: [255, 255, 255],
|
|
||||||
getRadius: 5,
|
|
||||||
onHover: this.showVesselTooltip.bind(this)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임스탬프 처리
|
|
||||||
*/
|
|
||||||
processTimestamps(timestamps, vessel) {
|
|
||||||
let startTime = null, endTime = null;
|
|
||||||
|
|
||||||
// 첫 번째 timestamp 처리
|
|
||||||
if (timestamps && timestamps.length > 0 && timestamps[0]) {
|
|
||||||
const firstTs = String(timestamps[0]);
|
|
||||||
if (firstTs.match(/^\d{10,}$/)) {
|
|
||||||
startTime = new Date(parseInt(firstTs) * 1000);
|
|
||||||
} else if (firstTs.includes('-') || firstTs.includes('T')) {
|
|
||||||
startTime = new Date(firstTs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 마지막 timestamp 처리
|
|
||||||
if (timestamps && timestamps.length > 0 && timestamps[timestamps.length - 1]) {
|
|
||||||
const lastTs = String(timestamps[timestamps.length - 1]);
|
|
||||||
if (lastTs.match(/^\d{10,}$/)) {
|
|
||||||
const utcTime = parseInt(lastTs) - (9 * 60 * 60); // -9시간 보정
|
|
||||||
endTime = new Date(utcTime * 1000);
|
|
||||||
} else if (lastTs.includes('-') || lastTs.includes('T')) {
|
|
||||||
endTime = new Date(lastTs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유효성 검사
|
|
||||||
if (!startTime || isNaN(startTime.getTime())) {
|
|
||||||
console.warn(`Invalid startTime for vessel ${vessel.sigSrcCd}_${vessel.targetId}`);
|
|
||||||
startTime = new Date();
|
|
||||||
}
|
|
||||||
if (!endTime || isNaN(endTime.getTime())) {
|
|
||||||
console.warn(`Invalid endTime for vessel ${vessel.sigSrcCd}_${vessel.targetId}`);
|
|
||||||
endTime = new Date();
|
|
||||||
}
|
|
||||||
|
|
||||||
return { startTime, endTime };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 속도별 색상 반환
|
|
||||||
*/
|
|
||||||
getSpeedColor(speed) {
|
|
||||||
if (speed < 5) return [30, 144, 255]; // 0-5 knots: 파란색
|
|
||||||
if (speed < 10) return [0, 255, 0]; // 5-10 knots: 초록색
|
|
||||||
if (speed < 15) return [255, 255, 0]; // 10-15 knots: 노란색
|
|
||||||
if (speed < 20) return [255, 140, 0]; // 15-20 knots: 주황색
|
|
||||||
return [255, 0, 0]; // 20+ knots: 빨간색
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 속도별 색상 반환
|
|
||||||
*/
|
|
||||||
getVesselColor(speed) {
|
|
||||||
if (speed < 1) return [255, 0, 0]; // 정지: 빨간색
|
|
||||||
if (speed < 5) return [255, 255, 0]; // 저속: 노란색
|
|
||||||
return [0, 255, 0]; // 이동중: 초록색
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 궤적 툴팁 표시
|
|
||||||
*/
|
|
||||||
showTooltip(info) {
|
|
||||||
const tooltip = document.getElementById('vesselTooltip');
|
|
||||||
if (!tooltip) return;
|
|
||||||
|
|
||||||
if (info.object) {
|
|
||||||
const startDate = info.object.startTime instanceof Date ?
|
|
||||||
info.object.startTime : new Date(info.object.startTime);
|
|
||||||
const endDate = info.object.endTime instanceof Date ?
|
|
||||||
info.object.endTime : new Date(info.object.endTime);
|
|
||||||
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<strong>선박 ID:</strong> ${info.object.vesselId}<br>
|
|
||||||
<strong>평균 속도:</strong> ${info.object.avgSpeed.toFixed(1)} knots<br>
|
|
||||||
<strong>이동 거리:</strong> ${info.object.distance.toFixed(1)} nm<br>
|
|
||||||
<strong>시작 시간:</strong> ${startDate.toLocaleString('ko-KR')}<br>
|
|
||||||
<strong>종료 시간:</strong> ${endDate.toLocaleString('ko-KR')}
|
|
||||||
`;
|
|
||||||
tooltip.style.left = info.x + 10 + 'px';
|
|
||||||
tooltip.style.top = info.y + 10 + 'px';
|
|
||||||
tooltip.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 아이콘 툴팁 표시
|
|
||||||
*/
|
|
||||||
showVesselTooltip(info) {
|
|
||||||
const tooltip = document.getElementById('vesselTooltip');
|
|
||||||
if (!tooltip) return;
|
|
||||||
|
|
||||||
if (info.object) {
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<strong>선박 ID:</strong> ${info.object.vesselId}<br>
|
|
||||||
<strong>현재 속도:</strong> ${info.object.speed.toFixed(1)} knots<br>
|
|
||||||
<strong>위치:</strong> ${info.object.position[1].toFixed(4)}°N, ${info.object.position[0].toFixed(4)}°E
|
|
||||||
`;
|
|
||||||
tooltip.style.left = info.x + 10 + 'px';
|
|
||||||
tooltip.style.top = info.y + 10 + 'px';
|
|
||||||
tooltip.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 맵 범위 조정
|
|
||||||
*/
|
|
||||||
fitMapBounds() {
|
|
||||||
if (!this.dataManager) return;
|
|
||||||
|
|
||||||
const bounds = new maplibregl.LngLatBounds();
|
|
||||||
let hasPoints = false;
|
|
||||||
|
|
||||||
this.dataManager.getVesselChunks().forEach(vessel => {
|
|
||||||
if (!vessel.cachedPath) {
|
|
||||||
vessel.cachedPath = this.dataManager.createVesselPath(vessel);
|
|
||||||
}
|
|
||||||
vessel.cachedPath.geometry.forEach(point => {
|
|
||||||
bounds.extend(point);
|
|
||||||
hasPoints = true;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasPoints) {
|
|
||||||
this.map.fitBounds(bounds, { padding: 50 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 초기화
|
|
||||||
*/
|
|
||||||
clearLayers() {
|
|
||||||
this.currentLayers = [];
|
|
||||||
this.deckOverlay?.setProps({ layers: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
getRenderOptions() {
|
|
||||||
return this.renderOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentLayers() {
|
|
||||||
return this.currentLayers;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,448 +0,0 @@
|
|||||||
/**
|
|
||||||
* GIS 지도 컨트롤러
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getVesselCountColor, getTrackColor, calculateBounds } from '../utils/gis-utils.js';
|
|
||||||
|
|
||||||
export class GISMapController {
|
|
||||||
constructor(containerId = 'mapContainer') {
|
|
||||||
this.containerId = containerId;
|
|
||||||
this.map = null;
|
|
||||||
this.deckOverlay = null;
|
|
||||||
this.layers = new Map();
|
|
||||||
this.tooltipCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도 초기화
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.map = new maplibregl.Map({
|
|
||||||
container: this.containerId,
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'local-tiles': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['/api/tiles/world/{z}/{x}/{y}.webp'],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: 'Local Tile Server'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layers: [{
|
|
||||||
id: 'base-tiles',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'local-tiles',
|
|
||||||
minzoom: 4,
|
|
||||||
maxzoom: 14
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
center: [126.0, 35.0],
|
|
||||||
zoom: 6
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.on('load', () => {
|
|
||||||
this.initDeckOverlay();
|
|
||||||
this.addNavigationControl();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deck.gl 오버레이 초기화
|
|
||||||
*/
|
|
||||||
initDeckOverlay() {
|
|
||||||
this.deckOverlay = new deck.MapboxOverlay({
|
|
||||||
layers: [],
|
|
||||||
getTooltip: this.getTooltip.bind(this)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.addControl(this.deckOverlay);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 네비게이션 컨트롤 추가
|
|
||||||
*/
|
|
||||||
addNavigationControl() {
|
|
||||||
this.map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haegu 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateHaeguLayer(haegus, stats) {
|
|
||||||
if (!haegus || haegus.length === 0) {
|
|
||||||
this.layers.delete('haegu-layer');
|
|
||||||
this.updateDeckLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = new deck.GeoJsonLayer({
|
|
||||||
id: 'haegu-layer',
|
|
||||||
data: {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: haegus.map(haegu => ({
|
|
||||||
type: 'Feature',
|
|
||||||
properties: {
|
|
||||||
haegu_no: haegu.haegu_no,
|
|
||||||
vessel_count: stats?.[haegu.haegu_no]?.vessel_count || 0,
|
|
||||||
total_distance: stats?.[haegu.haegu_no]?.total_distance || 0
|
|
||||||
},
|
|
||||||
geometry: JSON.parse(haegu.geom_json)
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
filled: true,
|
|
||||||
stroked: true,
|
|
||||||
getFillColor: d => getVesselCountColor(d.properties.vessel_count),
|
|
||||||
getLineColor: [0, 100, 200, 255],
|
|
||||||
getLineWidth: 2,
|
|
||||||
lineWidthMinPixels: 1,
|
|
||||||
pickable: true,
|
|
||||||
autoHighlight: true,
|
|
||||||
highlightColor: [255, 255, 0, 100],
|
|
||||||
onClick: (info) => this.handleAreaClick('haegu', info),
|
|
||||||
onRightClick: (info) => this.handleAreaRightClick('haegu', info)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.layers.set('haegu-layer', layer);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Area 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateAreaLayer(areas, stats) {
|
|
||||||
if (!areas || areas.length === 0) {
|
|
||||||
this.layers.delete('area-layer');
|
|
||||||
this.updateDeckLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = new deck.GeoJsonLayer({
|
|
||||||
id: 'area-layer',
|
|
||||||
data: {
|
|
||||||
type: 'FeatureCollection',
|
|
||||||
features: areas.map(area => ({
|
|
||||||
type: 'Feature',
|
|
||||||
properties: {
|
|
||||||
area_id: area.area_id,
|
|
||||||
area_name: area.area_name,
|
|
||||||
vessel_count: stats?.[area.area_id]?.vessel_count || 0,
|
|
||||||
total_distance: stats?.[area.area_id]?.total_distance || 0
|
|
||||||
},
|
|
||||||
geometry: JSON.parse(area.geom_json)
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
filled: true,
|
|
||||||
stroked: true,
|
|
||||||
getFillColor: d => getVesselCountColor(d.properties.vessel_count),
|
|
||||||
getLineColor: [200, 100, 0, 255],
|
|
||||||
getLineWidth: 3,
|
|
||||||
lineWidthMinPixels: 2,
|
|
||||||
pickable: true,
|
|
||||||
autoHighlight: true,
|
|
||||||
highlightColor: [255, 255, 0, 100],
|
|
||||||
onClick: (info) => this.handleAreaClick('area', info),
|
|
||||||
onRightClick: (info) => this.handleAreaRightClick('area', info)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.layers.set('area-layer', layer);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateTrackLayer(tracks, layerId = 'track-layer') {
|
|
||||||
if (!tracks || tracks.length === 0) {
|
|
||||||
this.layers.delete(layerId);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = new deck.PathLayer({
|
|
||||||
id: layerId,
|
|
||||||
data: tracks,
|
|
||||||
getPath: d => d.path,
|
|
||||||
getColor: d => getTrackColor(d.avg_speed, d.selected),
|
|
||||||
getWidth: d => d.selected ? 4 : 3,
|
|
||||||
pickable: true,
|
|
||||||
widthMinPixels: 2,
|
|
||||||
widthMaxPixels: 10,
|
|
||||||
capRounded: true,
|
|
||||||
jointRounded: true,
|
|
||||||
billboard: false
|
|
||||||
});
|
|
||||||
|
|
||||||
this.layers.set(layerId, layer);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updatePositionLayer(positions) {
|
|
||||||
if (!positions || positions.length === 0) {
|
|
||||||
this.layers.delete('position-layer');
|
|
||||||
this.updateDeckLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = new deck.ScatterplotLayer({
|
|
||||||
id: 'position-layer',
|
|
||||||
data: positions,
|
|
||||||
getPosition: d => d.position,
|
|
||||||
getRadius: 5,
|
|
||||||
radiusMinPixels: 4,
|
|
||||||
radiusMaxPixels: 10,
|
|
||||||
getFillColor: d => [255, 0, 0, 255],
|
|
||||||
getLineColor: [255, 255, 255, 255],
|
|
||||||
lineWidthMinPixels: 2,
|
|
||||||
stroked: true,
|
|
||||||
pickable: true
|
|
||||||
});
|
|
||||||
|
|
||||||
this.layers.set('position-layer', layer);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 히트맵 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateHeatmapLayer(points) {
|
|
||||||
if (!points || points.length === 0) {
|
|
||||||
this.layers.delete('heatmap-layer');
|
|
||||||
this.updateDeckLayers();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const layer = new deck.HeatmapLayer({
|
|
||||||
id: 'heatmap-layer',
|
|
||||||
data: points,
|
|
||||||
getPosition: d => d.position,
|
|
||||||
getWeight: d => d.weight || 1,
|
|
||||||
radiusPixels: 30,
|
|
||||||
intensity: 1,
|
|
||||||
threshold: 0.05,
|
|
||||||
colorRange: [
|
|
||||||
[255, 255, 178],
|
|
||||||
[254, 217, 118],
|
|
||||||
[254, 178, 76],
|
|
||||||
[253, 141, 60],
|
|
||||||
[240, 59, 32],
|
|
||||||
[189, 0, 38]
|
|
||||||
]
|
|
||||||
});
|
|
||||||
|
|
||||||
this.layers.set('heatmap-layer', layer);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Deck.gl 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateDeckLayers() {
|
|
||||||
const layers = Array.from(this.layers.values());
|
|
||||||
this.deckOverlay.setProps({ layers });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도 범위 맞추기
|
|
||||||
*/
|
|
||||||
fitBounds(coordinates) {
|
|
||||||
if (!coordinates || coordinates.length === 0) return;
|
|
||||||
|
|
||||||
const bounds = calculateBounds(coordinates);
|
|
||||||
if (!bounds) return;
|
|
||||||
|
|
||||||
this.map.fitBounds([
|
|
||||||
[bounds.minLng, bounds.minLat],
|
|
||||||
[bounds.maxLng, bounds.maxLat]
|
|
||||||
], {
|
|
||||||
padding: 50,
|
|
||||||
duration: 1000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 위치로 이동
|
|
||||||
*/
|
|
||||||
flyTo(lng, lat, zoom = 10) {
|
|
||||||
this.map.flyTo({
|
|
||||||
center: [lng, lat],
|
|
||||||
zoom: zoom,
|
|
||||||
duration: 1500
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 토글
|
|
||||||
*/
|
|
||||||
toggleLayer(layerId, visible) {
|
|
||||||
const layer = this.layers.get(layerId);
|
|
||||||
if (layer) {
|
|
||||||
layer.visible = visible;
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 모든 레이어 초기화
|
|
||||||
*/
|
|
||||||
clearAllLayers() {
|
|
||||||
this.layers.clear();
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 특정 레이어 제거
|
|
||||||
*/
|
|
||||||
removeLayer(layerId) {
|
|
||||||
this.layers.delete(layerId);
|
|
||||||
this.updateDeckLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 툴팁 생성
|
|
||||||
*/
|
|
||||||
getTooltip({object, layer}) {
|
|
||||||
if (!object) return null;
|
|
||||||
|
|
||||||
let html = null;
|
|
||||||
|
|
||||||
if (layer.id === 'haegu-layer') {
|
|
||||||
html = `
|
|
||||||
<strong>Haegu ${object.properties.haegu_no}</strong><br>
|
|
||||||
선박: ${object.properties.vessel_count}척<br>
|
|
||||||
총 거리: ${object.properties.total_distance.toFixed(1)} nm
|
|
||||||
`;
|
|
||||||
} else if (layer.id === 'area-layer') {
|
|
||||||
html = `
|
|
||||||
<strong>${object.properties.area_name || object.properties.area_id}</strong><br>
|
|
||||||
선박: ${object.properties.vessel_count}척<br>
|
|
||||||
총 거리: ${object.properties.total_distance.toFixed(1)} nm
|
|
||||||
`;
|
|
||||||
} else if (layer.id === 'track-layer' || layer.id === 'all-tracks-layer') {
|
|
||||||
html = `
|
|
||||||
<strong>선박: ${object.vessel_id}</strong><br>
|
|
||||||
평균 속도: ${object.avg_speed.toFixed(1)} kts<br>
|
|
||||||
거리: ${object.distance.toFixed(1)} nm
|
|
||||||
`;
|
|
||||||
} else if (layer.id === 'position-layer') {
|
|
||||||
html = `
|
|
||||||
<strong>선박: ${object.vessel_id}</strong><br>
|
|
||||||
속도: ${object.speed.toFixed(1)} kts<br>
|
|
||||||
시간: ${new Date(object.time).toLocaleString('ko-KR')}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html ? {
|
|
||||||
html: html,
|
|
||||||
style: {
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
|
||||||
color: '#000',
|
|
||||||
fontSize: '13px',
|
|
||||||
padding: '12px',
|
|
||||||
borderRadius: '6px',
|
|
||||||
boxShadow: '0 2px 8px rgba(0,0,0,0.15)'
|
|
||||||
}
|
|
||||||
} : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 클릭 핸들러
|
|
||||||
*/
|
|
||||||
handleAreaClick(type, info) {
|
|
||||||
if (info.object) {
|
|
||||||
this.onAreaClick?.(type, info.object.properties);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 우클릭 핸들러
|
|
||||||
*/
|
|
||||||
handleAreaRightClick(type, info) {
|
|
||||||
if (info.object) {
|
|
||||||
this.onAreaRightClick?.(type, info.object.properties, info);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨텍스트 메뉴 설정
|
|
||||||
*/
|
|
||||||
setupContextMenu() {
|
|
||||||
const canvas = this.map.getCanvas();
|
|
||||||
|
|
||||||
canvas.addEventListener('contextmenu', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const pickInfo = this.deckOverlay._deck.pickObject({
|
|
||||||
x: e.offsetX,
|
|
||||||
y: e.offsetY,
|
|
||||||
radius: 1,
|
|
||||||
layerIds: ['haegu-layer', 'area-layer']
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pickInfo && pickInfo.object) {
|
|
||||||
const layerId = pickInfo.layer.id;
|
|
||||||
const type = layerId === 'haegu-layer' ? 'haegu' : 'area';
|
|
||||||
|
|
||||||
this.onContextMenu?.(type, pickInfo.object.properties, {
|
|
||||||
clientX: e.clientX,
|
|
||||||
clientY: e.clientY
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
this.onAreaClick = handlers.onAreaClick;
|
|
||||||
this.onAreaRightClick = handlers.onAreaRightClick;
|
|
||||||
this.onContextMenu = handlers.onContextMenu;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 뷰포트 정보 가져오기
|
|
||||||
*/
|
|
||||||
getViewport() {
|
|
||||||
const bounds = this.map.getBounds();
|
|
||||||
const center = this.map.getCenter();
|
|
||||||
const zoom = this.map.getZoom();
|
|
||||||
|
|
||||||
return {
|
|
||||||
bounds: {
|
|
||||||
north: bounds.getNorth(),
|
|
||||||
south: bounds.getSouth(),
|
|
||||||
east: bounds.getEast(),
|
|
||||||
west: bounds.getWest()
|
|
||||||
},
|
|
||||||
center: {
|
|
||||||
lng: center.lng,
|
|
||||||
lat: center.lat
|
|
||||||
},
|
|
||||||
zoom
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 스크린샷 캡처
|
|
||||||
*/
|
|
||||||
async captureScreenshot() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.map.once('render', () => {
|
|
||||||
const canvas = this.map.getCanvas();
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
resolve(blob);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
this.map.triggerRepaint();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,305 +0,0 @@
|
|||||||
/**
|
|
||||||
* Sequential Passage 관리자 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PassageAPI, APIHelper } from '../api/gis-api.js';
|
|
||||||
import { normalizeZoneId, parseVesselId } from '../utils/gis-utils.js';
|
|
||||||
|
|
||||||
export class GISSequentialManager {
|
|
||||||
constructor() {
|
|
||||||
this.zones = [];
|
|
||||||
this.maxZones = 3;
|
|
||||||
this.results = null;
|
|
||||||
this.mode = 'sequential';
|
|
||||||
this.zoneType = 'GRID';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 추가
|
|
||||||
*/
|
|
||||||
addZone(zoneId) {
|
|
||||||
const normalized = normalizeZoneId(zoneId);
|
|
||||||
|
|
||||||
if (!normalized) {
|
|
||||||
return { success: false, message: '구역 ID를 입력하세요' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.zones.length >= this.maxZones) {
|
|
||||||
return { success: false, message: `최대 ${this.maxZones}개 구역만 선택 가능합니다` };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.zones.includes(normalized)) {
|
|
||||||
return { success: false, message: '이미 추가된 구역입니다' };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.zones.push(normalized);
|
|
||||||
this.onZonesChange?.(this.zones);
|
|
||||||
|
|
||||||
return { success: true, zone: normalized };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 제거
|
|
||||||
*/
|
|
||||||
removeZone(index) {
|
|
||||||
if (index >= 0 && index < this.zones.length) {
|
|
||||||
const removed = this.zones.splice(index, 1)[0];
|
|
||||||
this.onZonesChange?.(this.zones);
|
|
||||||
return removed;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 구역 초기화
|
|
||||||
*/
|
|
||||||
clearZones() {
|
|
||||||
this.zones = [];
|
|
||||||
this.onZonesChange?.(this.zones);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 순차 통과 검색
|
|
||||||
*/
|
|
||||||
async searchSequential(startTime, endTime) {
|
|
||||||
if (this.zones.length !== 3) {
|
|
||||||
throw new Error('순차 통과 검색은 정확히 3개의 구역이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
type: this.zoneType,
|
|
||||||
zoneIds: this.zones,
|
|
||||||
startTime: APIHelper.formatTimestamp(startTime),
|
|
||||||
endTime: APIHelper.formatTimestamp(endTime)
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await PassageAPI.searchSequential(params);
|
|
||||||
this.results = results;
|
|
||||||
return this.processResults(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sequential search failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 구역 통과 검색
|
|
||||||
*/
|
|
||||||
async searchAllZones(startTime, endTime) {
|
|
||||||
if (this.zones.length === 0) {
|
|
||||||
throw new Error('최소 1개 이상의 구역을 선택하세요');
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
type: this.zoneType,
|
|
||||||
zoneIds: this.zones,
|
|
||||||
startTime: APIHelper.formatTimestamp(startTime),
|
|
||||||
endTime: APIHelper.formatTimestamp(endTime)
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const results = await PassageAPI.searchAllZones(params);
|
|
||||||
this.results = results;
|
|
||||||
return this.processResults(results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('All zones search failed:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검색 실행
|
|
||||||
*/
|
|
||||||
async search(mode, type, startTime, endTime) {
|
|
||||||
this.mode = mode;
|
|
||||||
this.zoneType = type;
|
|
||||||
|
|
||||||
if (mode === 'sequential') {
|
|
||||||
return this.searchSequential(startTime, endTime);
|
|
||||||
} else {
|
|
||||||
return this.searchAllZones(startTime, endTime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 결과 처리
|
|
||||||
*/
|
|
||||||
processResults(results) {
|
|
||||||
if (!results) return [];
|
|
||||||
|
|
||||||
// 결과 구조 정규화
|
|
||||||
const vessels = results.passages || results.vessels || [];
|
|
||||||
|
|
||||||
return vessels.map(vessel => {
|
|
||||||
// 다양한 필드명 처리
|
|
||||||
const sigSrcCd = vessel.sigSrcCd || vessel.sig_src_cd;
|
|
||||||
const targetId = vessel.targetId || vessel.target_id;
|
|
||||||
const vesselId = `${sigSrcCd}_${targetId}`;
|
|
||||||
|
|
||||||
// 선박 정보 추출
|
|
||||||
const vesselInfo = vessel.vesselInfo || {};
|
|
||||||
const shipName = vesselInfo.shipName ||
|
|
||||||
vesselInfo.ship_name ||
|
|
||||||
vessel.ship_name ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
vesselId,
|
|
||||||
sigSrcCd,
|
|
||||||
targetId,
|
|
||||||
shipName,
|
|
||||||
vesselInfo,
|
|
||||||
// 추가 정보가 있다면 포함
|
|
||||||
passages: vessel.passages,
|
|
||||||
firstTime: vessel.firstTime || vessel.first_time,
|
|
||||||
lastTime: vessel.lastTime || vessel.last_time
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 렌더링 (HTML)
|
|
||||||
*/
|
|
||||||
renderZones() {
|
|
||||||
if (this.zones.length === 0) {
|
|
||||||
return '<small class="text-muted">선택된 구역이 없습니다</small>';
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.zones.map((zone, index) => `
|
|
||||||
<span class="zone-tag">
|
|
||||||
${zone}
|
|
||||||
<span class="remove-zone" data-index="${index}" style="cursor: pointer;">
|
|
||||||
<i class="bi bi-x"></i>
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검색 결과 렌더링 (HTML)
|
|
||||||
*/
|
|
||||||
renderResults() {
|
|
||||||
const processed = this.getProcessedResults();
|
|
||||||
|
|
||||||
if (processed.length === 0) {
|
|
||||||
return '<div class="text-muted text-center p-3">검색 결과가 없습니다</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = `<h6 class="mb-3">${processed.length}척의 선박 발견</h6>`;
|
|
||||||
|
|
||||||
processed.forEach(vessel => {
|
|
||||||
const displayName = vessel.shipName || vessel.vesselId;
|
|
||||||
const subtitle = vessel.shipName ? vessel.vesselId : '선박명 없음';
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="vessel-item" data-vessel-id="${vessel.vesselId}" style="cursor: pointer;">
|
|
||||||
<div class="vessel-info">
|
|
||||||
<strong>${displayName}</strong>
|
|
||||||
<small class="text-muted d-block">${subtitle}</small>
|
|
||||||
${vessel.firstTime ? `
|
|
||||||
<small class="text-muted">
|
|
||||||
${new Date(vessel.firstTime).toLocaleString('ko-KR')}
|
|
||||||
</small>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 검색 파라미터 검증
|
|
||||||
*/
|
|
||||||
validateSearchParams(startTime, endTime) {
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
if (!startTime || !endTime) {
|
|
||||||
errors.push('시작 시간과 종료 시간을 모두 입력하세요');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (new Date(startTime) >= new Date(endTime)) {
|
|
||||||
errors.push('종료 시간은 시작 시간보다 이후여야 합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.zones.length === 0) {
|
|
||||||
errors.push('최소 1개 이상의 구역을 선택하세요');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.mode === 'sequential' && this.zones.length !== 3) {
|
|
||||||
errors.push('순차 통과 모드는 정확히 3개의 구역이 필요합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 설정 변경
|
|
||||||
*/
|
|
||||||
setMode(mode) {
|
|
||||||
this.mode = mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
setZoneType(type) {
|
|
||||||
this.zoneType = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMaxZones(max) {
|
|
||||||
this.maxZones = max;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
this.onZonesChange = handlers.onZonesChange;
|
|
||||||
this.onResultSelect = handlers.onResultSelect;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 상태 초기화
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.zones = [];
|
|
||||||
this.results = null;
|
|
||||||
this.onZonesChange?.(this.zones);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
getZones() {
|
|
||||||
return [...this.zones];
|
|
||||||
}
|
|
||||||
|
|
||||||
getZoneCount() {
|
|
||||||
return this.zones.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
hasZone(zoneId) {
|
|
||||||
return this.zones.includes(normalizeZoneId(zoneId));
|
|
||||||
}
|
|
||||||
|
|
||||||
canAddZone() {
|
|
||||||
return this.zones.length < this.maxZones;
|
|
||||||
}
|
|
||||||
|
|
||||||
getResults() {
|
|
||||||
return this.results;
|
|
||||||
}
|
|
||||||
|
|
||||||
getProcessedResults() {
|
|
||||||
return this.results ? this.processResults(this.results) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
getMode() {
|
|
||||||
return this.mode;
|
|
||||||
}
|
|
||||||
|
|
||||||
getZoneType() {
|
|
||||||
return this.zoneType;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
/**
|
|
||||||
* GIS 선박 관리자 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { parseLineStringM, formatDistance, formatSpeed, passesFilters } from '../utils/gis-utils.js';
|
|
||||||
import { TrackAPI } from '../api/gis-api.js';
|
|
||||||
|
|
||||||
export class GISVesselManager {
|
|
||||||
constructor() {
|
|
||||||
this.vesselList = [];
|
|
||||||
this.selectedVessels = new Set();
|
|
||||||
this.tracks = [];
|
|
||||||
this.currentArea = null;
|
|
||||||
this.filters = {
|
|
||||||
minSpeed: 0,
|
|
||||||
maxSpeed: 50,
|
|
||||||
minDist: 0,
|
|
||||||
maxDist: 200
|
|
||||||
};
|
|
||||||
this.sortBy = 'id';
|
|
||||||
this.sortOrder = 'asc';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역의 선박 목록 로드
|
|
||||||
*/
|
|
||||||
async loadVesselList(type, id, timeRange) {
|
|
||||||
this.currentArea = { type, id };
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tracks = await TrackAPI.getByArea(type, id, timeRange);
|
|
||||||
|
|
||||||
// 선박별로 그룹화
|
|
||||||
const vesselMap = new Map();
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const vesselId = `${track.sig_src_cd}_${track.target_id}`;
|
|
||||||
|
|
||||||
if (!vesselMap.has(vesselId)) {
|
|
||||||
vesselMap.set(vesselId, {
|
|
||||||
id: vesselId,
|
|
||||||
sig_src_cd: track.sig_src_cd,
|
|
||||||
target_id: track.target_id,
|
|
||||||
distance: 0,
|
|
||||||
speeds: [],
|
|
||||||
tracks: [],
|
|
||||||
type: type,
|
|
||||||
area_id: id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const vessel = vesselMap.get(vesselId);
|
|
||||||
vessel.distance += parseFloat(track.distance_nm) || 0;
|
|
||||||
vessel.speeds.push(parseFloat(track.avg_speed) || 0);
|
|
||||||
vessel.tracks.push(track);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 통계 계산
|
|
||||||
this.vesselList = Array.from(vesselMap.values()).map(vessel => ({
|
|
||||||
...vessel,
|
|
||||||
avg_speed: vessel.speeds.reduce((a, b) => a + b, 0) / vessel.speeds.length,
|
|
||||||
max_speed: Math.max(...vessel.speeds),
|
|
||||||
track_count: vessel.tracks.length
|
|
||||||
}));
|
|
||||||
|
|
||||||
return this.vesselList;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load vessel list:', error);
|
|
||||||
this.vesselList = [];
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택된 선박들의 트랙 로드
|
|
||||||
*/
|
|
||||||
async loadSelectedTracks(period) {
|
|
||||||
this.tracks = [];
|
|
||||||
|
|
||||||
if (this.selectedVessels.size === 0) {
|
|
||||||
return this.tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trackPromises = [];
|
|
||||||
|
|
||||||
for (const vessel of this.vesselList) {
|
|
||||||
if (this.selectedVessels.has(vessel.id)) {
|
|
||||||
trackPromises.push(
|
|
||||||
this.loadVesselTrack(vessel, period)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(trackPromises);
|
|
||||||
|
|
||||||
results.forEach(result => {
|
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
|
||||||
this.tracks.push(result.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 개별 선박 트랙 로드
|
|
||||||
*/
|
|
||||||
async loadVesselTrack(vessel, period) {
|
|
||||||
try {
|
|
||||||
const tracks = await TrackAPI.getByArea(vessel.type, vessel.area_id, period);
|
|
||||||
|
|
||||||
const vesselTracks = tracks.filter(t =>
|
|
||||||
`${t.sig_src_cd}_${t.target_id}` === vessel.id
|
|
||||||
);
|
|
||||||
|
|
||||||
if (vesselTracks.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랙 포인트 병합
|
|
||||||
const allPoints = [];
|
|
||||||
let totalDistance = 0;
|
|
||||||
const speeds = [];
|
|
||||||
|
|
||||||
vesselTracks.forEach(track => {
|
|
||||||
const points = parseLineStringM(track.track_geom);
|
|
||||||
allPoints.push(...points);
|
|
||||||
totalDistance += parseFloat(track.distance_nm) || 0;
|
|
||||||
speeds.push(parseFloat(track.avg_speed) || 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (allPoints.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
path: allPoints,
|
|
||||||
vessel_id: vessel.id,
|
|
||||||
distance: totalDistance,
|
|
||||||
avg_speed: speeds.reduce((a, b) => a + b, 0) / speeds.length,
|
|
||||||
max_speed: Math.max(...speeds),
|
|
||||||
selected: true
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`Failed to load track for vessel ${vessel.id}:`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 선택 토글
|
|
||||||
*/
|
|
||||||
toggleSelection(vesselId) {
|
|
||||||
if (this.selectedVessels.has(vesselId)) {
|
|
||||||
this.selectedVessels.delete(vesselId);
|
|
||||||
} else {
|
|
||||||
this.selectedVessels.add(vesselId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onSelectionChange?.(this.selectedVessels);
|
|
||||||
return this.selectedVessels.has(vesselId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 선택
|
|
||||||
*/
|
|
||||||
selectAll() {
|
|
||||||
this.vesselList.forEach(v => this.selectedVessels.add(v.id));
|
|
||||||
this.onSelectionChange?.(this.selectedVessels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 해제
|
|
||||||
*/
|
|
||||||
deselectAll() {
|
|
||||||
this.selectedVessels.clear();
|
|
||||||
this.onSelectionChange?.(this.selectedVessels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택 초기화
|
|
||||||
*/
|
|
||||||
clearSelection() {
|
|
||||||
this.selectedVessels.clear();
|
|
||||||
this.tracks = [];
|
|
||||||
this.onSelectionChange?.(this.selectedVessels);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정렬 및 필터 적용
|
|
||||||
*/
|
|
||||||
sortAndFilter() {
|
|
||||||
// 필터 적용
|
|
||||||
let filtered = this.vesselList.filter(v =>
|
|
||||||
passesFilters(v, this.filters)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 정렬
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let compareValue = 0;
|
|
||||||
|
|
||||||
switch(this.sortBy) {
|
|
||||||
case 'id':
|
|
||||||
compareValue = a.id.localeCompare(b.id);
|
|
||||||
break;
|
|
||||||
case 'speed':
|
|
||||||
compareValue = a.avg_speed - b.avg_speed;
|
|
||||||
break;
|
|
||||||
case 'distance':
|
|
||||||
compareValue = a.distance - b.distance;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.sortOrder === 'asc' ? compareValue : -compareValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 설정
|
|
||||||
*/
|
|
||||||
setFilters(filters) {
|
|
||||||
this.filters = { ...this.filters, ...filters };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 초기화
|
|
||||||
*/
|
|
||||||
resetFilters() {
|
|
||||||
this.filters = {
|
|
||||||
minSpeed: 0,
|
|
||||||
maxSpeed: 50,
|
|
||||||
minDist: 0,
|
|
||||||
maxDist: 200
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정렬 설정
|
|
||||||
*/
|
|
||||||
setSort(sortBy, sortOrder) {
|
|
||||||
this.sortBy = sortBy;
|
|
||||||
this.sortOrder = sortOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 목록 렌더링 (HTML 생성)
|
|
||||||
*/
|
|
||||||
renderVesselList() {
|
|
||||||
const filtered = this.sortAndFilter();
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
return '<div class="text-muted text-center p-3">선박이 없습니다</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
let html = `<div class="mb-2 text-muted">총 ${filtered.length}척</div>`;
|
|
||||||
|
|
||||||
filtered.forEach(vessel => {
|
|
||||||
const isSelected = this.selectedVessels.has(vessel.id);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="vessel-item ${isSelected ? 'selected' : ''}"
|
|
||||||
data-vessel-id="${vessel.id}">
|
|
||||||
<input type="checkbox"
|
|
||||||
class="vessel-checkbox"
|
|
||||||
data-vessel-id="${vessel.id}"
|
|
||||||
${isSelected ? 'checked' : ''}>
|
|
||||||
<div class="vessel-info">
|
|
||||||
<strong>${vessel.id}</strong>
|
|
||||||
<small>
|
|
||||||
거리: ${formatDistance(vessel.distance)} |
|
|
||||||
평균속도: ${formatSpeed(vessel.avg_speed)}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 통계 가져오기
|
|
||||||
*/
|
|
||||||
getStatistics() {
|
|
||||||
const filtered = this.sortAndFilter();
|
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
|
||||||
return {
|
|
||||||
count: 0,
|
|
||||||
totalDistance: 0,
|
|
||||||
avgSpeed: 0,
|
|
||||||
maxSpeed: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalDistance = filtered.reduce((sum, v) => sum + v.distance, 0);
|
|
||||||
const avgSpeed = filtered.reduce((sum, v) => sum + v.avg_speed, 0) / filtered.length;
|
|
||||||
const maxSpeed = Math.max(...filtered.map(v => v.max_speed || v.avg_speed));
|
|
||||||
|
|
||||||
return {
|
|
||||||
count: filtered.length,
|
|
||||||
totalDistance,
|
|
||||||
avgSpeed,
|
|
||||||
maxSpeed
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
this.onSelectionChange = handlers.onSelectionChange;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 상태 초기화
|
|
||||||
*/
|
|
||||||
reset() {
|
|
||||||
this.vesselList = [];
|
|
||||||
this.selectedVessels.clear();
|
|
||||||
this.tracks = [];
|
|
||||||
this.currentArea = null;
|
|
||||||
this.resetFilters();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
|
||||||
getSelectedVessels() {
|
|
||||||
return this.selectedVessels;
|
|
||||||
}
|
|
||||||
|
|
||||||
getSelectedCount() {
|
|
||||||
return this.selectedVessels.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTracks() {
|
|
||||||
return this.tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentArea() {
|
|
||||||
return this.currentArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
getVesselList() {
|
|
||||||
return this.vesselList;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,246 +0,0 @@
|
|||||||
/**
|
|
||||||
* 지도 컨트롤러 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { MAP_CONFIG, LAYER_CONFIG, COLORS } from '../utils/constants.js';
|
|
||||||
import { getColorByType } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
export class MapController {
|
|
||||||
constructor(containerId = 'map') {
|
|
||||||
this.containerId = containerId;
|
|
||||||
this.map = null;
|
|
||||||
this.deckOverlay = null;
|
|
||||||
this.parsedGeoJsonCache = new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도 초기화
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
this.map = new maplibregl.Map({
|
|
||||||
container: this.containerId,
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'raster-tiles': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['/api/tiles/world/{z}/{x}/{y}.webp'],
|
|
||||||
tileSize: MAP_CONFIG.TILE_SIZE,
|
|
||||||
attribution: 'Local Tile Server'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layers: [{
|
|
||||||
id: 'simple-tiles',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'raster-tiles',
|
|
||||||
minzoom: MAP_CONFIG.MIN_ZOOM,
|
|
||||||
maxzoom: MAP_CONFIG.MAX_ZOOM
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
center: MAP_CONFIG.DEFAULT_CENTER,
|
|
||||||
zoom: MAP_CONFIG.DEFAULT_ZOOM
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.on('load', () => {
|
|
||||||
this.deckOverlay = new deck.MapboxOverlay({
|
|
||||||
layers: []
|
|
||||||
});
|
|
||||||
|
|
||||||
this.map.addControl(this.deckOverlay);
|
|
||||||
this.map.addControl(new maplibregl.NavigationControl());
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도 업데이트
|
|
||||||
* @param {Array} tracks - 표시할 트랙 데이터
|
|
||||||
* @param {Set} selectedTracks - 선택된 트랙 ID
|
|
||||||
* @param {string} activeVesselId - 활성 선박 ID
|
|
||||||
*/
|
|
||||||
updateMap(tracks, selectedTracks = new Set(), activeVesselId = null) {
|
|
||||||
if (!this.deckOverlay) return;
|
|
||||||
|
|
||||||
const pathData = [];
|
|
||||||
const pointData = [];
|
|
||||||
|
|
||||||
// 렌더링할 트랙 결정
|
|
||||||
let tracksToRender = [];
|
|
||||||
|
|
||||||
if (activeVesselId) {
|
|
||||||
tracksToRender = tracks.filter(t => t.vesselId === activeVesselId);
|
|
||||||
} else if (selectedTracks.size > 0) {
|
|
||||||
tracksToRender = tracks.filter(t => selectedTracks.has(String(t.id)));
|
|
||||||
} else {
|
|
||||||
tracksToRender = tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랙 데이터 처리
|
|
||||||
tracksToRender.forEach(track => {
|
|
||||||
const geojson = this.parseTrackGeoJson(track);
|
|
||||||
if (!geojson) return;
|
|
||||||
|
|
||||||
const isSelected = selectedTracks.has(String(track.id));
|
|
||||||
const isInActiveGroup = activeVesselId && track.vesselId === activeVesselId;
|
|
||||||
const color = getColorByType(track.abnormalType).slice(0, 3);
|
|
||||||
|
|
||||||
// 색상 및 너비 결정
|
|
||||||
let finalColor = color;
|
|
||||||
let width = LAYER_CONFIG.PATH.DEFAULT_WIDTH;
|
|
||||||
let opacity = LAYER_CONFIG.PATH.ACTIVE_OPACITY;
|
|
||||||
|
|
||||||
if (isInActiveGroup) {
|
|
||||||
if (isSelected) {
|
|
||||||
width = LAYER_CONFIG.PATH.SELECTED_WIDTH;
|
|
||||||
opacity = LAYER_CONFIG.PATH.SELECTED_OPACITY;
|
|
||||||
finalColor = COLORS.SELECTED;
|
|
||||||
} else {
|
|
||||||
width = LAYER_CONFIG.PATH.ACTIVE_WIDTH;
|
|
||||||
opacity = LAYER_CONFIG.PATH.ACTIVE_OPACITY;
|
|
||||||
}
|
|
||||||
} else if (selectedTracks.size > 0 && !isSelected) {
|
|
||||||
finalColor = COLORS.DEFAULT_TRACK;
|
|
||||||
opacity = LAYER_CONFIG.PATH.INACTIVE_OPACITY;
|
|
||||||
}
|
|
||||||
|
|
||||||
pathData.push({
|
|
||||||
path: geojson.coordinates.map(coord => [coord[0], coord[1]]),
|
|
||||||
color: finalColor,
|
|
||||||
width: width,
|
|
||||||
opacity: opacity,
|
|
||||||
trackId: String(track.id),
|
|
||||||
vesselId: track.vesselId,
|
|
||||||
selected: isSelected,
|
|
||||||
isInActiveGroup: isInActiveGroup
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택된 트랙의 포인트 표시
|
|
||||||
if (isSelected) {
|
|
||||||
geojson.coordinates.forEach((coord, idx) => {
|
|
||||||
pointData.push({
|
|
||||||
position: [coord[0], coord[1]],
|
|
||||||
vesselId: track.vesselId,
|
|
||||||
trackId: track.id,
|
|
||||||
time: new Date((coord[2] || 0) * 1000),
|
|
||||||
index: idx,
|
|
||||||
totalPoints: geojson.coordinates.length
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택 상태에 따라 정렬
|
|
||||||
pathData.sort((a, b) => {
|
|
||||||
if (a.selected && !b.selected) return 1;
|
|
||||||
if (!a.selected && b.selected) return -1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.renderLayers(pathData, pointData);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 렌더링
|
|
||||||
*/
|
|
||||||
renderLayers(pathData, pointData) {
|
|
||||||
const layers = [
|
|
||||||
new deck.PathLayer({
|
|
||||||
id: 'tracks',
|
|
||||||
data: pathData,
|
|
||||||
getPath: d => d.path,
|
|
||||||
getColor: d => [...d.color, d.opacity],
|
|
||||||
getWidth: d => d.width,
|
|
||||||
widthMinPixels: LAYER_CONFIG.PATH.WIDTH_MIN,
|
|
||||||
widthMaxPixels: LAYER_CONFIG.PATH.WIDTH_MAX,
|
|
||||||
pickable: true,
|
|
||||||
autoHighlight: true,
|
|
||||||
highlightColor: [255, 255, 0, 200],
|
|
||||||
onClick: ({object}) => {
|
|
||||||
if (object && object.trackId) {
|
|
||||||
this.onTrackClick?.(object.trackId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
new deck.ScatterplotLayer({
|
|
||||||
id: 'points',
|
|
||||||
data: pointData,
|
|
||||||
getPosition: d => d.position,
|
|
||||||
getFillColor: [255, 255, 255],
|
|
||||||
getRadius: LAYER_CONFIG.POINT.RADIUS,
|
|
||||||
radiusMinPixels: LAYER_CONFIG.POINT.RADIUS_MIN,
|
|
||||||
radiusMaxPixels: LAYER_CONFIG.POINT.RADIUS_MAX,
|
|
||||||
stroked: true,
|
|
||||||
lineWidthMinPixels: LAYER_CONFIG.POINT.STROKE_WIDTH,
|
|
||||||
getLineColor: [0, 0, 0],
|
|
||||||
pickable: true,
|
|
||||||
onHover: ({object, x, y}) => {
|
|
||||||
this.onPointHover?.(object, x, y);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
];
|
|
||||||
|
|
||||||
this.deckOverlay.setProps({ layers });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GeoJSON 파싱
|
|
||||||
*/
|
|
||||||
parseTrackGeoJson(track) {
|
|
||||||
let geojson = this.parsedGeoJsonCache.get(track.id);
|
|
||||||
if (!geojson && track.trackGeoJson) {
|
|
||||||
try {
|
|
||||||
geojson = JSON.parse(track.trackGeoJson);
|
|
||||||
this.parsedGeoJsonCache.set(track.id, geojson);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to parse GeoJSON:', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (geojson && geojson.type === 'LineString') {
|
|
||||||
return geojson;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도를 트랙에 맞게 조정
|
|
||||||
*/
|
|
||||||
fitToTracks(tracks) {
|
|
||||||
const bounds = new maplibregl.LngLatBounds();
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const geojson = this.parseTrackGeoJson(track);
|
|
||||||
if (geojson) {
|
|
||||||
geojson.coordinates.forEach(coord => {
|
|
||||||
bounds.extend([coord[0], coord[1]]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!bounds.isEmpty()) {
|
|
||||||
this.map.fitBounds(bounds, { padding: 100 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 초기화
|
|
||||||
*/
|
|
||||||
clearCache() {
|
|
||||||
this.parsedGeoJsonCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
if (handlers.onTrackClick) {
|
|
||||||
this.onTrackClick = handlers.onTrackClick;
|
|
||||||
}
|
|
||||||
if (handlers.onPointHover) {
|
|
||||||
this.onPointHover = handlers.onPointHover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
/**
|
|
||||||
* 통계 패널 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getTypeLabel, getColorByType, formatDistance, getPeriodLabel } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
export class StatisticsPanel {
|
|
||||||
constructor() {
|
|
||||||
this.totalCountEl = null;
|
|
||||||
this.vesselCountEl = null;
|
|
||||||
this.avgDistanceEl = null;
|
|
||||||
this.legendContentEl = null;
|
|
||||||
this.legendTitleEl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기화
|
|
||||||
*/
|
|
||||||
init() {
|
|
||||||
this.totalCountEl = document.getElementById('totalCount');
|
|
||||||
this.vesselCountEl = document.getElementById('vesselCount');
|
|
||||||
this.avgDistanceEl = document.getElementById('avgDistance');
|
|
||||||
this.legendContentEl = document.getElementById('legendContent');
|
|
||||||
this.legendTitleEl = document.getElementById('legendTitle');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통계 업데이트
|
|
||||||
* @param {Array} tracks - 궤적 데이터
|
|
||||||
*/
|
|
||||||
updateStatistics(tracks) {
|
|
||||||
const uniqueVessels = new Set();
|
|
||||||
let totalDistance = 0;
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
uniqueVessels.add(track.vesselId);
|
|
||||||
totalDistance += parseFloat(track.distanceNm) || 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 카운트 업데이트
|
|
||||||
if (this.totalCountEl) {
|
|
||||||
this.totalCountEl.textContent = tracks.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.vesselCountEl) {
|
|
||||||
this.vesselCountEl.textContent = uniqueVessels.size;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 평균 거리 계산
|
|
||||||
if (this.avgDistanceEl) {
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
const avgDist = (totalDistance / tracks.length).toFixed(1);
|
|
||||||
this.avgDistanceEl.textContent = `${avgDist} nm`;
|
|
||||||
} else {
|
|
||||||
this.avgDistanceEl.textContent = '0 nm';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 범례 업데이트
|
|
||||||
* @param {Array} tracks - 궤적 데이터
|
|
||||||
* @param {Map} vesselGroups - 선박별 그룹 데이터
|
|
||||||
* @param {string} startDate - 시작 날짜
|
|
||||||
* @param {string} endDate - 종료 날짜
|
|
||||||
*/
|
|
||||||
updateLegend(tracks, vesselGroups, startDate, endDate) {
|
|
||||||
// 범례 제목 업데이트
|
|
||||||
if (this.legendTitleEl) {
|
|
||||||
const periodLabel = getPeriodLabel(startDate, endDate);
|
|
||||||
this.legendTitleEl.innerHTML = `비정상 유형별 현황 <span style="font-size: 0.8em; color: #6c757d; font-weight: normal;">(${periodLabel})</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.legendContentEl) return;
|
|
||||||
|
|
||||||
const typeStats = this.calculateTypeStatistics(tracks, vesselGroups);
|
|
||||||
|
|
||||||
if (typeStats.size === 0) {
|
|
||||||
this.legendContentEl.innerHTML = '<div style="color: #6c757d; font-size: 0.85em;">데이터 없음</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 트랙 수 기준으로 정렬
|
|
||||||
const sortedTypes = Array.from(typeStats.entries())
|
|
||||||
.sort((a, b) => b[1].tracks - a[1].tracks);
|
|
||||||
|
|
||||||
this.legendContentEl.innerHTML = sortedTypes.map(([type, stats]) => {
|
|
||||||
const color = getColorByType(type).slice(0, 3);
|
|
||||||
const description = this.getTypeDescription(type);
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="legend-item" title="${description}">
|
|
||||||
<div style="display: flex; align-items: center; flex: 1;">
|
|
||||||
<div class="legend-color" style="background-color: rgb(${color.join(',')})"></div>
|
|
||||||
<div class="legend-label">${getTypeLabel(type)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="legend-count">${stats.vessels}척 / ${stats.tracks}건</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유형별 통계 계산
|
|
||||||
* @param {Array} tracks - 궤적 데이터
|
|
||||||
* @param {Map} vesselGroups - 선박별 그룹 데이터
|
|
||||||
* @returns {Map} 유형별 통계
|
|
||||||
*/
|
|
||||||
calculateTypeStatistics(tracks, vesselGroups) {
|
|
||||||
const typeStats = new Map();
|
|
||||||
const vesselsByType = new Map();
|
|
||||||
|
|
||||||
// 각 선박의 가장 최근 비정상 유형 결정
|
|
||||||
vesselGroups.forEach((tracks, vesselId) => {
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
const latestType = tracks[0].abnormalType;
|
|
||||||
vesselsByType.set(vesselId, latestType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 모든 유형에 대해 통계 집계
|
|
||||||
const allTypes = ['extreme_speed', 'extreme_distance', 'extreme_transition',
|
|
||||||
'extreme_avg_speed_5min', 'impossible_transition', 'user_detected'];
|
|
||||||
|
|
||||||
allTypes.forEach(type => {
|
|
||||||
const trackCount = tracks.filter(t => t.abnormalType === type).length;
|
|
||||||
const vesselCount = Array.from(vesselsByType.values()).filter(t => t === type).length;
|
|
||||||
|
|
||||||
if (trackCount > 0 || vesselCount > 0) {
|
|
||||||
typeStats.set(type, {
|
|
||||||
tracks: trackCount,
|
|
||||||
vessels: vesselCount
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return typeStats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 유형별 설명 가져오기
|
|
||||||
* @param {string} type - 비정상 유형
|
|
||||||
* @returns {string} 설명
|
|
||||||
*/
|
|
||||||
getTypeDescription(type) {
|
|
||||||
const descriptions = {
|
|
||||||
'extreme_speed': '평균속도가 선박 500knots, 항공기 800knots 초과',
|
|
||||||
'extreme_distance': '5분간 이동거리가 100nm 초과',
|
|
||||||
'extreme_transition': '버킷 전환 시 암시된 속도가 비정상적으로 높음',
|
|
||||||
'extreme_avg_speed_5min': '5분 평균속도가 1000knots 초과 (네트워크 오류)',
|
|
||||||
'impossible_transition': '시간대별 집계 시 물리적으로 불가능한 전환 (500knots 초과)',
|
|
||||||
'user_detected': '사용자가 수동으로 검출한 비정상 궤적'
|
|
||||||
};
|
|
||||||
return descriptions[type] || '비정상 궤적';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통계 카드에 애니메이션 효과 추가
|
|
||||||
*/
|
|
||||||
animateStatCards() {
|
|
||||||
const statCards = document.querySelectorAll('.stat-card');
|
|
||||||
statCards.forEach(card => {
|
|
||||||
card.classList.add('updating');
|
|
||||||
setTimeout(() => {
|
|
||||||
card.classList.remove('updating');
|
|
||||||
}, 500);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
/**
|
|
||||||
* 선박 목록 컴포넌트
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { SORT_OPTIONS } from '../utils/constants.js';
|
|
||||||
import { escapeSelector, formatDistance, formatSpeed, getTypeLabel } from '../utils/helpers.js';
|
|
||||||
|
|
||||||
export class VesselList {
|
|
||||||
constructor(containerId = 'vesselList') {
|
|
||||||
this.containerId = containerId;
|
|
||||||
this.container = null;
|
|
||||||
this.vesselGroups = new Map();
|
|
||||||
this.activeVesselId = null;
|
|
||||||
this.selectedTracks = new Set();
|
|
||||||
this.sortBy = SORT_OPTIONS.COUNT;
|
|
||||||
this.isCustomDetectionMode = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기화
|
|
||||||
*/
|
|
||||||
init() {
|
|
||||||
this.container = document.getElementById(this.containerId);
|
|
||||||
if (!this.container) {
|
|
||||||
console.error(`Container element not found: ${this.containerId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박별로 궤적 그룹화
|
|
||||||
*/
|
|
||||||
groupTracksByVessel(tracks) {
|
|
||||||
this.vesselGroups.clear();
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const vesselId = track.vesselId;
|
|
||||||
if (!this.vesselGroups.has(vesselId)) {
|
|
||||||
this.vesselGroups.set(vesselId, []);
|
|
||||||
}
|
|
||||||
this.vesselGroups.get(vesselId).push(track);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 각 그룹 내에서 시간순 정렬
|
|
||||||
this.vesselGroups.forEach((tracks, vesselId) => {
|
|
||||||
tracks.sort((a, b) => new Date(b.detectedAt) - new Date(a.detectedAt));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 목록 렌더링
|
|
||||||
*/
|
|
||||||
render() {
|
|
||||||
if (!this.container) return;
|
|
||||||
|
|
||||||
if (this.vesselGroups.size === 0) {
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="no-data">
|
|
||||||
검출된 비정상 궤적이 없습니다.
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vesselData = this.prepareVesselData();
|
|
||||||
this.sortVesselData(vesselData);
|
|
||||||
|
|
||||||
this.container.innerHTML = vesselData.map(data =>
|
|
||||||
this.renderVesselGroup(data)
|
|
||||||
).join('');
|
|
||||||
|
|
||||||
this.attachEventListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 데이터 준비
|
|
||||||
*/
|
|
||||||
prepareVesselData() {
|
|
||||||
return Array.from(this.vesselGroups.entries()).map(([vesselId, tracks]) => {
|
|
||||||
const totalDistance = tracks.reduce((sum, t) => sum + (parseFloat(t.distanceNm) || 0), 0);
|
|
||||||
const totalTime = tracks.reduce((sum, t) => {
|
|
||||||
const dist = parseFloat(t.distanceNm) || 0;
|
|
||||||
const speed = parseFloat(t.avgSpeed) || 1;
|
|
||||||
return sum + (dist / speed);
|
|
||||||
}, 0);
|
|
||||||
const avgSpeed = totalTime > 0 ? totalDistance / totalTime : 0;
|
|
||||||
const latestTime = new Date(tracks[0].detectedAt);
|
|
||||||
|
|
||||||
return {
|
|
||||||
vesselId,
|
|
||||||
tracks,
|
|
||||||
totalDistance,
|
|
||||||
avgSpeed,
|
|
||||||
latestTime,
|
|
||||||
count: tracks.length
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 데이터 정렬
|
|
||||||
*/
|
|
||||||
sortVesselData(vesselData) {
|
|
||||||
switch (this.sortBy) {
|
|
||||||
case SORT_OPTIONS.COUNT:
|
|
||||||
vesselData.sort((a, b) => b.count - a.count);
|
|
||||||
break;
|
|
||||||
case SORT_OPTIONS.DISTANCE:
|
|
||||||
vesselData.sort((a, b) => b.totalDistance - a.totalDistance);
|
|
||||||
break;
|
|
||||||
case SORT_OPTIONS.SPEED:
|
|
||||||
vesselData.sort((a, b) => b.avgSpeed - a.avgSpeed);
|
|
||||||
break;
|
|
||||||
case SORT_OPTIONS.RECENT:
|
|
||||||
vesselData.sort((a, b) => b.latestTime - a.latestTime);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 그룹 렌더링
|
|
||||||
*/
|
|
||||||
renderVesselGroup({vesselId, tracks, totalDistance, avgSpeed}) {
|
|
||||||
const typeCounts = {};
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const type = track.abnormalType;
|
|
||||||
typeCounts[type] = (typeCounts[type] || 0) + 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
const topTypes = Object.entries(typeCounts)
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 2)
|
|
||||||
.map(([type]) => `
|
|
||||||
<span class="abnormal-type-badge type-${type} ms-1" style="font-size: 0.7em; padding: 2px 6px;">
|
|
||||||
${getTypeLabel(type)}
|
|
||||||
</span>
|
|
||||||
`).join('');
|
|
||||||
|
|
||||||
const isActive = this.activeVesselId === vesselId;
|
|
||||||
const headerClass = isActive ? 'vessel-header active' : 'vessel-header';
|
|
||||||
const tracksDisplay = isActive ? 'block' : 'none';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="vessel-group" data-vessel-id="${vesselId}">
|
|
||||||
<div class="${headerClass}" data-vessel-id="${vesselId}">
|
|
||||||
<div class="vessel-info">
|
|
||||||
<div class="vessel-id">
|
|
||||||
${vesselId}
|
|
||||||
${topTypes}
|
|
||||||
</div>
|
|
||||||
<div class="vessel-stats">
|
|
||||||
총 거리: ${formatDistance(totalDistance)} | 평균 속도: ${formatSpeed(avgSpeed)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="vessel-count">${tracks.length}</span>
|
|
||||||
</div>
|
|
||||||
<div class="vessel-tracks" id="tracks-${escapeSelector(vesselId)}" style="display: ${tracksDisplay};">
|
|
||||||
${this.renderTrackList(tracks, vesselId)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 목록 렌더링
|
|
||||||
*/
|
|
||||||
renderTrackList(tracks, vesselId) {
|
|
||||||
return tracks.map(track => {
|
|
||||||
const isSelected = this.selectedTracks.has(String(track.id));
|
|
||||||
const selectedClass = isSelected ? 'selected' : '';
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="track-item ${selectedClass}"
|
|
||||||
data-track-id="${track.id}"
|
|
||||||
data-vessel-id="${vesselId}">
|
|
||||||
${this.isCustomDetectionMode ? `
|
|
||||||
<input type="checkbox" class="track-checkbox"
|
|
||||||
data-track-id="${track.id}"
|
|
||||||
data-vessel-id="${vesselId}"
|
|
||||||
${isSelected ? 'checked' : ''}
|
|
||||||
onclick="event.stopPropagation()">
|
|
||||||
` : ''}
|
|
||||||
<div style="display: flex; justify-content: space-between; align-items: start; flex: 1;">
|
|
||||||
<div>
|
|
||||||
<div style="font-weight: 500;">
|
|
||||||
${new Date(track.timeBucket).toLocaleString('ko-KR')}
|
|
||||||
</div>
|
|
||||||
<div style="font-size: 0.85em; color: #6c757d;">
|
|
||||||
${formatDistance(track.distanceNm)} | ${formatSpeed(track.avgSpeed)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="abnormal-type-badge type-${track.abnormalType}">
|
|
||||||
${getTypeLabel(track.abnormalType)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 리스너 연결
|
|
||||||
*/
|
|
||||||
attachEventListeners() {
|
|
||||||
// 선박 헤더 클릭
|
|
||||||
this.container.querySelectorAll('.vessel-header').forEach(header => {
|
|
||||||
header.addEventListener('click', (e) => {
|
|
||||||
const vesselId = header.dataset.vesselId;
|
|
||||||
this.toggleVessel(vesselId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 트랙 아이템 클릭
|
|
||||||
if (!this.isCustomDetectionMode) {
|
|
||||||
this.container.querySelectorAll('.track-item').forEach(item => {
|
|
||||||
item.addEventListener('click', (e) => {
|
|
||||||
if (!e.target.classList.contains('track-checkbox')) {
|
|
||||||
const trackId = item.dataset.trackId;
|
|
||||||
const vesselId = item.dataset.vesselId;
|
|
||||||
this.selectTrack(trackId, vesselId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 체크박스 변경
|
|
||||||
if (this.isCustomDetectionMode) {
|
|
||||||
this.container.querySelectorAll('.track-checkbox').forEach(checkbox => {
|
|
||||||
checkbox.addEventListener('change', () => {
|
|
||||||
this.onCheckboxChange?.();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 토글
|
|
||||||
*/
|
|
||||||
toggleVessel(vesselId) {
|
|
||||||
const tracksEl = document.getElementById(`tracks-${escapeSelector(vesselId)}`);
|
|
||||||
const header = this.container.querySelector(`.vessel-header[data-vessel-id="${vesselId}"]`);
|
|
||||||
|
|
||||||
if (this.activeVesselId === vesselId) {
|
|
||||||
// 닫기
|
|
||||||
tracksEl.style.display = 'none';
|
|
||||||
header.classList.remove('active');
|
|
||||||
this.activeVesselId = null;
|
|
||||||
this.selectedTracks.clear();
|
|
||||||
} else {
|
|
||||||
// 다른 모든 선박 접기
|
|
||||||
this.container.querySelectorAll('.vessel-tracks').forEach(el => {
|
|
||||||
el.style.display = 'none';
|
|
||||||
});
|
|
||||||
this.container.querySelectorAll('.vessel-header').forEach(el => {
|
|
||||||
el.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선택한 선박 열기
|
|
||||||
tracksEl.style.display = 'block';
|
|
||||||
header.classList.add('active');
|
|
||||||
this.activeVesselId = vesselId;
|
|
||||||
|
|
||||||
// 첫 번째 트랙 자동 선택
|
|
||||||
const tracks = this.vesselGroups.get(vesselId);
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
this.selectedTracks.clear();
|
|
||||||
this.selectedTracks.add(String(tracks[0].id));
|
|
||||||
this.updateTrackSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.onVesselToggle?.(vesselId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 선택
|
|
||||||
*/
|
|
||||||
selectTrack(trackId, vesselId) {
|
|
||||||
// 현재 활성 그룹이 아니면 먼저 활성화
|
|
||||||
if (this.activeVesselId !== vesselId) {
|
|
||||||
this.toggleVessel(vesselId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택 상태 토글
|
|
||||||
trackId = String(trackId);
|
|
||||||
if (this.selectedTracks.has(trackId)) {
|
|
||||||
this.selectedTracks.delete(trackId);
|
|
||||||
} else {
|
|
||||||
this.selectedTracks.clear();
|
|
||||||
this.selectedTracks.add(trackId);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateTrackSelection();
|
|
||||||
this.onTrackSelect?.(trackId, vesselId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 선택 UI 업데이트
|
|
||||||
*/
|
|
||||||
updateTrackSelection() {
|
|
||||||
this.container.querySelectorAll('.track-item').forEach(item => {
|
|
||||||
const trackId = item.dataset.trackId;
|
|
||||||
item.classList.toggle('selected', this.selectedTracks.has(trackId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정렬 기준 설정
|
|
||||||
*/
|
|
||||||
setSortBy(sortBy) {
|
|
||||||
this.sortBy = sortBy;
|
|
||||||
this.render();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 정의 검출 모드 설정
|
|
||||||
*/
|
|
||||||
setCustomDetectionMode(enabled) {
|
|
||||||
this.isCustomDetectionMode = enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택된 체크박스 트랙 가져오기
|
|
||||||
*/
|
|
||||||
getSelectedCheckboxTracks() {
|
|
||||||
const selected = new Set();
|
|
||||||
this.container.querySelectorAll('.track-checkbox:checked').forEach(checkbox => {
|
|
||||||
const trackId = checkbox.dataset.trackId;
|
|
||||||
if (trackId) {
|
|
||||||
selected.add(trackId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setEventHandlers(handlers) {
|
|
||||||
if (handlers.onVesselToggle) {
|
|
||||||
this.onVesselToggle = handlers.onVesselToggle;
|
|
||||||
}
|
|
||||||
if (handlers.onTrackSelect) {
|
|
||||||
this.onTrackSelect = handlers.onTrackSelect;
|
|
||||||
}
|
|
||||||
if (handlers.onCheckboxChange) {
|
|
||||||
this.onCheckboxChange = handlers.onCheckboxChange;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로딩 상태 표시
|
|
||||||
*/
|
|
||||||
showLoading() {
|
|
||||||
if (this.container) {
|
|
||||||
this.container.innerHTML = `
|
|
||||||
<div class="loading">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">데이터를 불러오는 중...</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,568 +0,0 @@
|
|||||||
/**
|
|
||||||
* 비정상 궤적 모니터링 메인 애플리케이션
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { fetchFilteredTracks, detectCustomTracks, moveTracksToAbnormal } from '../api/abnormal-tracks-api.js';
|
|
||||||
import { MapController } from '../components/map-controller.js';
|
|
||||||
import { VesselList } from '../components/vessel-list.js';
|
|
||||||
import { StatisticsPanel } from '../components/statistics-panel.js';
|
|
||||||
import { getDateRange, debounce } from '../utils/helpers.js';
|
|
||||||
import { REFRESH_INTERVAL, TABLE_TYPES } from '../utils/constants.js';
|
|
||||||
|
|
||||||
class AbnormalTracksApp {
|
|
||||||
constructor() {
|
|
||||||
this.mapController = new MapController('map');
|
|
||||||
this.vesselList = new VesselList('vesselList');
|
|
||||||
this.statisticsPanel = new StatisticsPanel();
|
|
||||||
|
|
||||||
this.abnormalTracks = [];
|
|
||||||
this.isCustomDetectionMode = false;
|
|
||||||
this.customDetectedTracks = [];
|
|
||||||
this.autoRefreshTimer = null;
|
|
||||||
|
|
||||||
// 디바운스된 함수들
|
|
||||||
this.debouncedLoadTracks = debounce(this.loadAbnormalTracks.bind(this), 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애플리케이션 초기화
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
try {
|
|
||||||
// 컴포넌트 초기화
|
|
||||||
await this.mapController.init();
|
|
||||||
this.vesselList.init();
|
|
||||||
this.statisticsPanel.init();
|
|
||||||
|
|
||||||
// 이벤트 핸들러 설정
|
|
||||||
this.setupEventHandlers();
|
|
||||||
|
|
||||||
// 기본 날짜 설정 (최근 1일)
|
|
||||||
this.setDateRange(1);
|
|
||||||
|
|
||||||
// 초기 데이터 로드
|
|
||||||
await this.loadAbnormalTracks();
|
|
||||||
|
|
||||||
// 자동 새로고침 시작
|
|
||||||
this.startAutoRefresh();
|
|
||||||
|
|
||||||
console.log('Abnormal Tracks App initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize app:', error);
|
|
||||||
this.showError('애플리케이션 초기화 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupEventHandlers() {
|
|
||||||
// 컴포넌트 이벤트 핸들러
|
|
||||||
this.mapController.setEventHandlers({
|
|
||||||
onTrackClick: (trackId) => this.handleMapTrackClick(trackId),
|
|
||||||
onPointHover: (object, x, y) => this.handlePointHover(object, x, y)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.vesselList.setEventHandlers({
|
|
||||||
onVesselToggle: (vesselId) => this.handleVesselToggle(vesselId),
|
|
||||||
onTrackSelect: (trackId, vesselId) => this.handleTrackSelect(trackId, vesselId),
|
|
||||||
onCheckboxChange: () => this.handleCheckboxChange()
|
|
||||||
});
|
|
||||||
|
|
||||||
// UI 이벤트 핸들러
|
|
||||||
this.setupUIEventHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupUIEventHandlers() {
|
|
||||||
// 날짜 빠른 선택
|
|
||||||
document.querySelectorAll('[data-days]').forEach(btn => {
|
|
||||||
btn.addEventListener('click', () => {
|
|
||||||
const days = parseInt(btn.dataset.days);
|
|
||||||
this.setDateRange(days);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 조회 버튼
|
|
||||||
document.getElementById('loadTracksBtn').addEventListener('click', () => {
|
|
||||||
this.loadAbnormalTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 날짜 입력 변경
|
|
||||||
document.getElementById('startDateInput').addEventListener('change', () => {
|
|
||||||
if (document.getElementById('endDateInput').value) {
|
|
||||||
this.debouncedLoadTracks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('endDateInput').addEventListener('change', () => {
|
|
||||||
if (document.getElementById('startDateInput').value) {
|
|
||||||
this.debouncedLoadTracks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선박 ID 엔터키 검색
|
|
||||||
document.getElementById('vesselIdInput').addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') {
|
|
||||||
this.loadAbnormalTracks();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 정렬 변경
|
|
||||||
document.getElementById('sortSelect').addEventListener('change', (e) => {
|
|
||||||
this.vesselList.setSortBy(e.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 정의 검출
|
|
||||||
document.getElementById('detectCustomBtn').addEventListener('click', () => {
|
|
||||||
this.detectCustomAbnormalTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전체 보기 / 선택 해제
|
|
||||||
document.getElementById('showAllBtn').addEventListener('click', () => {
|
|
||||||
this.showAllTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('clearSelectionBtn').addEventListener('click', () => {
|
|
||||||
this.clearSelection();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 자동 검출 모드로 돌아가기
|
|
||||||
document.getElementById('backToAutoBtn').addEventListener('click', () => {
|
|
||||||
this.showAllTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 체크박스 전체 선택/해제
|
|
||||||
document.getElementById('selectAllBtn').addEventListener('click', () => {
|
|
||||||
this.selectAllTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('deselectAllBtn').addEventListener('click', () => {
|
|
||||||
this.deselectAllTracks();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 비정상 이동
|
|
||||||
document.getElementById('moveToAbnormalBtn').addEventListener('click', () => {
|
|
||||||
this.showMoveToAbnormalModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 범위 설정
|
|
||||||
*/
|
|
||||||
setDateRange(days) {
|
|
||||||
const { startDate, endDate } = getDateRange(days);
|
|
||||||
document.getElementById('startDateInput').value = startDate;
|
|
||||||
document.getElementById('endDateInput').value = endDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 궤적 데이터 로드
|
|
||||||
*/
|
|
||||||
async loadAbnormalTracks() {
|
|
||||||
try {
|
|
||||||
this.vesselList.showLoading();
|
|
||||||
|
|
||||||
const startDate = document.getElementById('startDateInput').value;
|
|
||||||
const endDate = document.getElementById('endDateInput').value;
|
|
||||||
const type = document.getElementById('typeSelect').value;
|
|
||||||
const vesselId = document.getElementById('vesselIdInput').value;
|
|
||||||
|
|
||||||
// 기본값 설정
|
|
||||||
if (!startDate || !endDate) {
|
|
||||||
this.setDateRange(1);
|
|
||||||
return this.loadAbnormalTracks();
|
|
||||||
}
|
|
||||||
|
|
||||||
const filters = { startDate, endDate, type, vesselId };
|
|
||||||
this.abnormalTracks = await fetchFilteredTracks(filters);
|
|
||||||
|
|
||||||
this.vesselList.groupTracksByVessel(this.abnormalTracks);
|
|
||||||
this.vesselList.render();
|
|
||||||
|
|
||||||
this.statisticsPanel.updateStatistics(this.abnormalTracks);
|
|
||||||
this.statisticsPanel.updateLegend(
|
|
||||||
this.abnormalTracks,
|
|
||||||
this.vesselList.vesselGroups,
|
|
||||||
startDate,
|
|
||||||
endDate
|
|
||||||
);
|
|
||||||
|
|
||||||
this.mapController.updateMap(this.abnormalTracks);
|
|
||||||
|
|
||||||
// 캐시 초기화
|
|
||||||
this.mapController.clearCache();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading abnormal tracks:', error);
|
|
||||||
this.showError('데이터 로드 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 정의 비정상 궤적 검출
|
|
||||||
*/
|
|
||||||
async detectCustomAbnormalTracks() {
|
|
||||||
try {
|
|
||||||
this.vesselList.showLoading();
|
|
||||||
|
|
||||||
const tableType = document.getElementById('tableTypeSelect').value;
|
|
||||||
const days = parseInt(document.getElementById('customPeriodSelect').value);
|
|
||||||
const minDistance = parseFloat(document.getElementById('minDistanceInput').value) || 0;
|
|
||||||
const minSpeed = parseFloat(document.getElementById('minSpeedInput').value) || 0;
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
tableType,
|
|
||||||
startTime: startTime.toISOString().split('.')[0],
|
|
||||||
endTime: now.toISOString().split('.')[0],
|
|
||||||
minDistance,
|
|
||||||
minSpeed
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracks = await detectCustomTracks(params);
|
|
||||||
|
|
||||||
// 사용자 정의 검출 모드 활성화
|
|
||||||
this.isCustomDetectionMode = true;
|
|
||||||
this.customDetectedTracks = tracks;
|
|
||||||
this.abnormalTracks = tracks;
|
|
||||||
|
|
||||||
this.vesselList.setCustomDetectionMode(true);
|
|
||||||
this.vesselList.groupTracksByVessel(tracks);
|
|
||||||
this.vesselList.render();
|
|
||||||
|
|
||||||
this.statisticsPanel.updateStatistics(tracks);
|
|
||||||
this.statisticsPanel.updateLegend(tracks, this.vesselList.vesselGroups);
|
|
||||||
|
|
||||||
this.mapController.updateMap(tracks);
|
|
||||||
|
|
||||||
// UI 상태 변경
|
|
||||||
this.showCustomDetectionUI();
|
|
||||||
|
|
||||||
alert(`${tracks.length}개의 궤적이 검출되었습니다.`);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error detecting custom abnormal tracks:', error);
|
|
||||||
this.showError('검출 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사용자 정의 검출 UI 표시
|
|
||||||
*/
|
|
||||||
showCustomDetectionUI() {
|
|
||||||
document.getElementById('filterSection').style.display = 'none';
|
|
||||||
document.getElementById('actionButtons').style.display = 'block';
|
|
||||||
document.getElementById('backToAutoBtn').style.display = 'inline-block';
|
|
||||||
|
|
||||||
this.stopAutoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 보기 모드로 돌아가기
|
|
||||||
*/
|
|
||||||
showAllTracks() {
|
|
||||||
this.isCustomDetectionMode = false;
|
|
||||||
this.vesselList.setCustomDetectionMode(false);
|
|
||||||
|
|
||||||
document.getElementById('filterSection').style.display = 'block';
|
|
||||||
document.getElementById('actionButtons').style.display = 'none';
|
|
||||||
document.getElementById('backToAutoBtn').style.display = 'none';
|
|
||||||
|
|
||||||
this.clearSelection();
|
|
||||||
this.loadAbnormalTracks();
|
|
||||||
this.startAutoRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택 해제
|
|
||||||
*/
|
|
||||||
clearSelection() {
|
|
||||||
this.vesselList.selectedTracks.clear();
|
|
||||||
this.vesselList.activeVesselId = null;
|
|
||||||
this.vesselList.render();
|
|
||||||
this.mapController.updateMap(this.abnormalTracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지도에서 트랙 클릭 처리
|
|
||||||
*/
|
|
||||||
handleMapTrackClick(trackId) {
|
|
||||||
const track = this.abnormalTracks.find(t => String(t.id) === String(trackId));
|
|
||||||
if (!track) return;
|
|
||||||
|
|
||||||
const vesselId = track.vesselId;
|
|
||||||
|
|
||||||
// 선박 그룹 활성화 및 트랙 선택
|
|
||||||
this.vesselList.toggleVessel(vesselId);
|
|
||||||
this.vesselList.selectTrack(trackId, vesselId);
|
|
||||||
|
|
||||||
this.mapController.updateMap(
|
|
||||||
this.abnormalTracks,
|
|
||||||
this.vesselList.selectedTracks,
|
|
||||||
this.vesselList.activeVesselId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 선택된 트랙으로 지도 조정
|
|
||||||
this.mapController.fitToTracks([track]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 포인트 호버 처리
|
|
||||||
*/
|
|
||||||
handlePointHover(object, x, y) {
|
|
||||||
const tooltip = document.getElementById('tooltip');
|
|
||||||
if (object) {
|
|
||||||
tooltip.innerHTML = `
|
|
||||||
<div class="tooltip-header">선박: ${object.vesselId}</div>
|
|
||||||
<div class="tooltip-row">시간: ${object.time.toLocaleString('ko-KR')}</div>
|
|
||||||
<div class="tooltip-row">포인트: ${object.index + 1} / ${object.totalPoints}</div>
|
|
||||||
`;
|
|
||||||
tooltip.style.left = (x + 10) + 'px';
|
|
||||||
tooltip.style.top = (y - 10) + 'px';
|
|
||||||
tooltip.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
tooltip.style.display = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 토글 처리
|
|
||||||
*/
|
|
||||||
handleVesselToggle(vesselId) {
|
|
||||||
this.mapController.updateMap(
|
|
||||||
this.abnormalTracks,
|
|
||||||
this.vesselList.selectedTracks,
|
|
||||||
this.vesselList.activeVesselId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 선박의 모든 트랙에 맞게 지도 조정
|
|
||||||
const tracks = this.vesselList.vesselGroups.get(vesselId);
|
|
||||||
if (tracks && tracks.length > 0) {
|
|
||||||
this.mapController.fitToTracks(tracks);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 선택 처리
|
|
||||||
*/
|
|
||||||
handleTrackSelect(trackId, vesselId) {
|
|
||||||
this.mapController.updateMap(
|
|
||||||
this.abnormalTracks,
|
|
||||||
this.vesselList.selectedTracks,
|
|
||||||
this.vesselList.activeVesselId
|
|
||||||
);
|
|
||||||
|
|
||||||
// 선택된 트랙으로 지도 조정
|
|
||||||
const selectedTrack = this.abnormalTracks.find(t => String(t.id) === trackId);
|
|
||||||
if (selectedTrack) {
|
|
||||||
this.mapController.fitToTracks([selectedTrack]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 체크박스 변경 처리
|
|
||||||
*/
|
|
||||||
handleCheckboxChange() {
|
|
||||||
const selectedTracks = this.vesselList.getSelectedCheckboxTracks();
|
|
||||||
document.getElementById('selectedCount').textContent = selectedTracks.size;
|
|
||||||
document.getElementById('moveToAbnormalBtn').disabled = selectedTracks.size === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 선택
|
|
||||||
*/
|
|
||||||
selectAllTracks() {
|
|
||||||
document.querySelectorAll('.track-checkbox').forEach(checkbox => {
|
|
||||||
checkbox.checked = true;
|
|
||||||
});
|
|
||||||
this.handleCheckboxChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 해제
|
|
||||||
*/
|
|
||||||
deselectAllTracks() {
|
|
||||||
document.querySelectorAll('.track-checkbox').forEach(checkbox => {
|
|
||||||
checkbox.checked = false;
|
|
||||||
});
|
|
||||||
this.handleCheckboxChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 이동 모달 표시
|
|
||||||
*/
|
|
||||||
showMoveToAbnormalModal() {
|
|
||||||
const selectedTracks = this.vesselList.getSelectedCheckboxTracks();
|
|
||||||
if (selectedTracks.size === 0) {
|
|
||||||
alert('선택된 궤적이 없습니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap 모달 생성 및 표시 로직
|
|
||||||
this.createMoveToAbnormalModal(selectedTracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 이동 모달 생성
|
|
||||||
*/
|
|
||||||
createMoveToAbnormalModal(selectedTracks) {
|
|
||||||
const modalHtml = `
|
|
||||||
<div class="modal fade" id="moveModal" tabindex="-1">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content">
|
|
||||||
<div class="modal-header">
|
|
||||||
<h5 class="modal-title">비정상 궤적 이동</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">비정상 유형 선택</label>
|
|
||||||
<select class="form-select" id="abnormalTypeSelect">
|
|
||||||
<option value="user_detected">사용자 검출</option>
|
|
||||||
<option value="extreme_speed">극단적 비정상 속도</option>
|
|
||||||
<option value="extreme_distance">극단적 이동거리</option>
|
|
||||||
<option value="extreme_transition">극단적 bucket 전환</option>
|
|
||||||
<option value="impossible_transition">물리적 불가능 전환</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">비정상 판단 이유</label>
|
|
||||||
<textarea class="form-control" id="abnormalReasonInput" rows="3"
|
|
||||||
placeholder="상세한 이유를 입력하세요...">사용자가 수동으로 검출</textarea>
|
|
||||||
</div>
|
|
||||||
<div class="alert alert-warning">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
|
||||||
${selectedTracks.size}개의 궤적이 비정상 테이블로 이동됩니다.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
|
||||||
<button type="button" class="btn btn-danger" id="confirmMoveBtn">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i> 이동
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 기존 모달 제거
|
|
||||||
const existingModal = document.getElementById('moveModal');
|
|
||||||
if (existingModal) {
|
|
||||||
existingModal.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 모달 추가 및 표시
|
|
||||||
document.body.insertAdjacentHTML('beforeend', modalHtml);
|
|
||||||
const modal = new bootstrap.Modal(document.getElementById('moveModal'));
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
// 확인 버튼 이벤트
|
|
||||||
document.getElementById('confirmMoveBtn').addEventListener('click', () => {
|
|
||||||
this.confirmMoveToAbnormal(selectedTracks, modal);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 이동 확인
|
|
||||||
*/
|
|
||||||
async confirmMoveToAbnormal(selectedTracks, modal) {
|
|
||||||
try {
|
|
||||||
const abnormalType = document.getElementById('abnormalTypeSelect').value;
|
|
||||||
const reason = document.getElementById('abnormalReasonInput').value.trim();
|
|
||||||
|
|
||||||
if (!reason) {
|
|
||||||
alert('비정상 판단 이유를 입력해주세요.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tableType = document.getElementById('tableTypeSelect').value;
|
|
||||||
|
|
||||||
// 선택된 트랙 정보 수집
|
|
||||||
const tracks = [];
|
|
||||||
selectedTracks.forEach(trackId => {
|
|
||||||
const track = this.customDetectedTracks.find(t => String(t.id) === String(trackId));
|
|
||||||
if (track) {
|
|
||||||
const timeBucketStr = track.timeBucket.includes(':') && track.timeBucket.split(':').length === 2
|
|
||||||
? track.timeBucket + ':00'
|
|
||||||
: track.timeBucket;
|
|
||||||
|
|
||||||
tracks.push({
|
|
||||||
sigSrcCd: track.sigSrcCd,
|
|
||||||
targetId: track.targetId,
|
|
||||||
timeBucket: timeBucketStr
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const params = { tableType, tracks, abnormalType, reason };
|
|
||||||
const result = await moveTracksToAbnormal(params);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
modal.hide();
|
|
||||||
alert(result.message);
|
|
||||||
this.detectCustomAbnormalTracks(); // 화면 새로고침
|
|
||||||
} else {
|
|
||||||
alert('이동 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error moving tracks to abnormal:', error);
|
|
||||||
alert('이동 중 오류가 발생했습니다.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 새로고침 시작
|
|
||||||
*/
|
|
||||||
startAutoRefresh() {
|
|
||||||
if (this.autoRefreshTimer) {
|
|
||||||
clearInterval(this.autoRefreshTimer);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.autoRefreshTimer = setInterval(() => {
|
|
||||||
if (!this.isCustomDetectionMode) {
|
|
||||||
this.loadAbnormalTracks();
|
|
||||||
}
|
|
||||||
}, REFRESH_INTERVAL);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 새로고침 중지
|
|
||||||
*/
|
|
||||||
stopAutoRefresh() {
|
|
||||||
if (this.autoRefreshTimer) {
|
|
||||||
clearInterval(this.autoRefreshTimer);
|
|
||||||
this.autoRefreshTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 표시
|
|
||||||
*/
|
|
||||||
showError(message) {
|
|
||||||
console.error(message);
|
|
||||||
const vesselList = document.getElementById('vesselList');
|
|
||||||
if (vesselList) {
|
|
||||||
vesselList.innerHTML = `
|
|
||||||
<div class="error-message">
|
|
||||||
<i class="bi bi-exclamation-triangle"></i>
|
|
||||||
${message}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 애플리케이션 시작
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
const app = new AbnormalTracksApp();
|
|
||||||
app.init().catch(error => {
|
|
||||||
console.error('Failed to start application:', error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,542 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chunked Streaming GIS Application
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { WebSocketAPI } from '../api/websocket-api.js';
|
|
||||||
import { ChunkedDataManager } from '../components/chunked-data-manager.js';
|
|
||||||
import { ChunkedAnimationController } from '../components/chunked-animation-controller.js';
|
|
||||||
import { ChunkedMapRenderer } from '../components/chunked-map-renderer.js';
|
|
||||||
import {
|
|
||||||
getDefaultTimeRange,
|
|
||||||
createTimeRange,
|
|
||||||
updateViewportDisplay,
|
|
||||||
addLog,
|
|
||||||
clearLog,
|
|
||||||
toggleDisplay,
|
|
||||||
updateButtonState,
|
|
||||||
updateProgressBar,
|
|
||||||
updateSpeedButtons,
|
|
||||||
updateTimelineUI,
|
|
||||||
updateStatistics,
|
|
||||||
getRenderOptions,
|
|
||||||
safeAddEventListener,
|
|
||||||
validateQueryParams,
|
|
||||||
showError,
|
|
||||||
showSuccess,
|
|
||||||
getMapViewport
|
|
||||||
} from '../utils/chunked-utils.js';
|
|
||||||
|
|
||||||
export class ChunkedStreamingApp {
|
|
||||||
constructor() {
|
|
||||||
this.map = null;
|
|
||||||
this.websocketAPI = new WebSocketAPI();
|
|
||||||
this.dataManager = new ChunkedDataManager();
|
|
||||||
this.animationController = new ChunkedAnimationController();
|
|
||||||
this.mapRenderer = null;
|
|
||||||
|
|
||||||
this.updateLayersTimer = null;
|
|
||||||
this.queryStartTime = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애플리케이션 초기화
|
|
||||||
*/
|
|
||||||
async initialize() {
|
|
||||||
try {
|
|
||||||
this.initializeMap();
|
|
||||||
this.setupEventHandlers();
|
|
||||||
this.setupWebSocketHandlers();
|
|
||||||
this.setDefaultTimeRange();
|
|
||||||
|
|
||||||
addLog('시스템 준비 완료', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('초기화 실패:', error);
|
|
||||||
showError('시스템 초기화에 실패했습니다');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 맵 초기화
|
|
||||||
*/
|
|
||||||
initializeMap() {
|
|
||||||
this.map = new maplibregl.Map({
|
|
||||||
container: 'mapContainer',
|
|
||||||
style: {
|
|
||||||
version: 8,
|
|
||||||
sources: {
|
|
||||||
'raster-tiles': {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['/api/tiles/enc/{z}/{x}/{y}.webp'],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: 'Local Tile Server'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
layers: [{
|
|
||||||
id: 'simple-tiles',
|
|
||||||
type: 'raster',
|
|
||||||
source: 'raster-tiles',
|
|
||||||
minzoom: 0,
|
|
||||||
maxzoom: 22
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
center: [128, 35.5],
|
|
||||||
zoom: 6
|
|
||||||
});
|
|
||||||
|
|
||||||
// 맵 렌더러 초기화
|
|
||||||
this.mapRenderer = new ChunkedMapRenderer(this.map);
|
|
||||||
this.mapRenderer.initialize();
|
|
||||||
this.mapRenderer.setDataManager(this.dataManager);
|
|
||||||
|
|
||||||
// 맵 이벤트 핸들러
|
|
||||||
this.map.on('moveend', () => updateViewportDisplay(this.map));
|
|
||||||
this.map.on('zoomend', () => updateViewportDisplay(this.map));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupEventHandlers() {
|
|
||||||
// 연결 버튼
|
|
||||||
safeAddEventListener('connectBtn', 'click', () => this.connect());
|
|
||||||
safeAddEventListener('disconnectBtn', 'click', () => this.disconnect());
|
|
||||||
|
|
||||||
// 쿼리 제어
|
|
||||||
safeAddEventListener('startBtn', 'click', () => this.startQuery());
|
|
||||||
safeAddEventListener('cancelBtn', 'click', () => this.cancelQuery());
|
|
||||||
|
|
||||||
// 시간 범위 단축키
|
|
||||||
[1, 6, 24, 72, 168].forEach(hours => {
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.textContent === `${hours === 1 ? '1시간' : hours === 6 ? '6시간' : hours === 24 ? '1일' : hours === 72 ? '3일' : '7일'}`) {
|
|
||||||
this.setTimeRange(hours);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 애니메이션 제어
|
|
||||||
safeAddEventListener('playBtn', 'click', () => this.togglePlay());
|
|
||||||
|
|
||||||
// 속도 제어
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.classList.contains('btn-speed')) {
|
|
||||||
const speed = parseFloat(e.target.textContent.replace('x', ''));
|
|
||||||
this.setSpeed(speed);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 타임라인 슬라이더
|
|
||||||
safeAddEventListener('timeSlider', 'input', (e) => {
|
|
||||||
const progress = parseFloat(e.target.value);
|
|
||||||
this.animationController.setTimeFromProgress(progress);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 렌더링 옵션 변경
|
|
||||||
['showTracks', 'showVessels', 'colorBySpeed', 'trackOpacity'].forEach(id => {
|
|
||||||
safeAddEventListener(id, 'change', () => this.updateRenderOptions());
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로그 초기화
|
|
||||||
safeAddEventListener('logContainer', 'click', (e) => {
|
|
||||||
if (e.target.textContent === 'Clear') {
|
|
||||||
clearLog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전체 초기화 (닫기 버튼)
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (e.target.closest('.btn-close')) {
|
|
||||||
this.resetAll();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupWebSocketHandlers() {
|
|
||||||
this.websocketAPI.onConnectionStatusChange((connected) => {
|
|
||||||
this.updateConnectionStatus(connected);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.websocketAPI.setHandlers({
|
|
||||||
response: (response) => this.handleResponse(response),
|
|
||||||
status: (status) => this.handleStatus(status),
|
|
||||||
chunk: (message) => this.handleChunk(message),
|
|
||||||
error: (error) => this.handleError(error)
|
|
||||||
});
|
|
||||||
|
|
||||||
// 애니메이션 컨트롤러 이벤트
|
|
||||||
this.animationController.setEventHandlers({
|
|
||||||
onTimeUpdate: (currentTime) => this.onTimeUpdate(currentTime),
|
|
||||||
onPlayStateChange: (playing) => this.onPlayStateChange(playing)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 시간 범위 설정
|
|
||||||
*/
|
|
||||||
setDefaultTimeRange() {
|
|
||||||
const timeRange = getDefaultTimeRange();
|
|
||||||
document.getElementById('startTime').value = timeRange.startTime;
|
|
||||||
document.getElementById('endTime').value = timeRange.endTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시간 범위 설정
|
|
||||||
*/
|
|
||||||
setTimeRange(hours) {
|
|
||||||
const timeRange = createTimeRange(hours);
|
|
||||||
document.getElementById('startTime').value = timeRange.startTime;
|
|
||||||
document.getElementById('endTime').value = timeRange.endTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 연결
|
|
||||||
*/
|
|
||||||
async connect() {
|
|
||||||
try {
|
|
||||||
await this.websocketAPI.connect();
|
|
||||||
addLog('WebSocket 연결 성공', 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('연결 실패:', error);
|
|
||||||
showError('WebSocket 연결에 실패했습니다');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* WebSocket 연결 해제
|
|
||||||
*/
|
|
||||||
async disconnect() {
|
|
||||||
try {
|
|
||||||
await this.websocketAPI.disconnect();
|
|
||||||
addLog('WebSocket 연결 해제', 'info');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('연결 해제 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 시작
|
|
||||||
*/
|
|
||||||
async startQuery() {
|
|
||||||
// 입력값 검증
|
|
||||||
const startTime = document.getElementById('startTime').value + ':00';
|
|
||||||
const endTime = document.getElementById('endTime').value + ':00';
|
|
||||||
|
|
||||||
const validation = validateQueryParams(startTime, endTime);
|
|
||||||
if (!validation.valid) {
|
|
||||||
validation.errors.forEach(error => showError(error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 초기화
|
|
||||||
this.dataManager.reset();
|
|
||||||
this.queryStartTime = Date.now();
|
|
||||||
|
|
||||||
// 맵 정보 가져오기
|
|
||||||
const { viewport, zoom } = getMapViewport(this.map);
|
|
||||||
|
|
||||||
// 애니메이션 시간 설정
|
|
||||||
const startTimeMs = new Date(startTime).getTime();
|
|
||||||
const endTimeMs = new Date(endTime).getTime();
|
|
||||||
this.animationController.setTimeRange(startTimeMs, endTimeMs);
|
|
||||||
|
|
||||||
// WebSocket 쿼리 시작
|
|
||||||
await this.websocketAPI.startQuery({
|
|
||||||
startTime,
|
|
||||||
endTime,
|
|
||||||
viewport,
|
|
||||||
zoomLevel: zoom,
|
|
||||||
chunkSize: 20000
|
|
||||||
});
|
|
||||||
|
|
||||||
// UI 업데이트
|
|
||||||
toggleDisplay('startBtn', false);
|
|
||||||
toggleDisplay('cancelBtn', true);
|
|
||||||
updateProgressBar(0);
|
|
||||||
|
|
||||||
addLog('쿼리 시작...', 'info');
|
|
||||||
addLog(`요청 시간: ${startTime} ~ ${endTime}`, 'info');
|
|
||||||
addLog(`현재 줌 레벨: ${zoom}`, 'info');
|
|
||||||
addLog(`뷰포트: [${viewport.minLon.toFixed(4)}, ${viewport.minLat.toFixed(4)}] - [${viewport.maxLon.toFixed(4)}, ${viewport.maxLat.toFixed(4)}]`, 'info');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('쿼리 시작 실패:', error);
|
|
||||||
showError('쿼리 시작에 실패했습니다');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 취소
|
|
||||||
*/
|
|
||||||
cancelQuery() {
|
|
||||||
this.websocketAPI.cancelQuery();
|
|
||||||
this.stopTimers();
|
|
||||||
this.animationController.stop();
|
|
||||||
|
|
||||||
updateButtonState('playBtn', true, '<i class="bi bi-play-fill"></i>');
|
|
||||||
addLog('쿼리 취소됨', 'info');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 청크 데이터 처리
|
|
||||||
*/
|
|
||||||
handleChunk(message) {
|
|
||||||
const result = this.dataManager.processChunk(message);
|
|
||||||
const { chunk, stats } = result;
|
|
||||||
|
|
||||||
this.updateStatistics();
|
|
||||||
|
|
||||||
// 첫 청크 수신 시 UI 활성화 및 애니메이션 자동 시작
|
|
||||||
if (this.dataManager.getStatistics().chunksReceived === 1) {
|
|
||||||
toggleDisplay('timelinePanel', true);
|
|
||||||
toggleDisplay('legend', true);
|
|
||||||
this.setSpeed(50); // 50x 속도 설정
|
|
||||||
|
|
||||||
// 애니메이션 자동 시작
|
|
||||||
this.animationController.animationState.playing = true;
|
|
||||||
this.animationController.forceStart();
|
|
||||||
this.onPlayStateChange(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 주기적 레이어 업데이트 (1초마다)
|
|
||||||
if (!this.updateLayersTimer) {
|
|
||||||
this.updateLayersTimer = setInterval(() => {
|
|
||||||
this.updateLayers();
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 로그 메시지
|
|
||||||
const timeProgress = stats.timeProgress || 0;
|
|
||||||
const vesselProgress = stats.vesselProgress || 0;
|
|
||||||
const dataStats = this.dataManager.getStatistics();
|
|
||||||
|
|
||||||
addLog(
|
|
||||||
`청크 ${chunk.chunkIndex + 1}/${chunk.totalChunks || '?'}: ${chunk.compactTracks?.length || 0}척, ${dataStats.pointCount.toLocaleString()}포인트 | 시간: ${timeProgress.toFixed(1)}% | 선박: ${vesselProgress.toFixed(1)}%`,
|
|
||||||
'chunk'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 응답 처리
|
|
||||||
*/
|
|
||||||
handleResponse(response) {
|
|
||||||
if (response.status === 'STARTED') {
|
|
||||||
this.websocketAPI.setCurrentQueryId(response.queryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 상태 처리
|
|
||||||
*/
|
|
||||||
handleStatus(status) {
|
|
||||||
if (status.progressPercentage >= 0) {
|
|
||||||
updateProgressBar(status.progressPercentage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status.status === 'COMPLETED') {
|
|
||||||
this.handleComplete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 처리
|
|
||||||
*/
|
|
||||||
handleError(error) {
|
|
||||||
showError(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 완료 처리
|
|
||||||
*/
|
|
||||||
handleComplete() {
|
|
||||||
const duration = ((Date.now() - this.queryStartTime) / 1000).toFixed(1);
|
|
||||||
const stats = this.dataManager.getStatistics();
|
|
||||||
|
|
||||||
updateStatistics({ processTime: duration });
|
|
||||||
showSuccess(`완료! ${stats.vesselCount}척, ${duration}초 소요`);
|
|
||||||
|
|
||||||
// UI 초기화
|
|
||||||
toggleDisplay('startBtn', true);
|
|
||||||
toggleDisplay('cancelBtn', false);
|
|
||||||
updateProgressBar(0);
|
|
||||||
|
|
||||||
// 타이머 정리
|
|
||||||
this.stopTimers();
|
|
||||||
|
|
||||||
// 마지막 레이어 업데이트
|
|
||||||
this.updateLayers();
|
|
||||||
|
|
||||||
// 맵 범위 조정
|
|
||||||
this.mapRenderer.fitMapBounds();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 토글
|
|
||||||
*/
|
|
||||||
togglePlay() {
|
|
||||||
// 데이터가 없으면 재생하지 않음
|
|
||||||
if (!this.dataManager.hasData()) {
|
|
||||||
addLog('재생할 데이터가 없습니다. 먼저 스트리밍을 시작하세요.', 'info');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 시간 범위가 설정되지 않았으면 설정
|
|
||||||
const timeRange = this.animationController.getTimeRange();
|
|
||||||
if (timeRange.startTime === 0 || timeRange.endTime === 0) {
|
|
||||||
addLog('애니메이션 시간 범위를 설정 중...', 'info');
|
|
||||||
// 현재 입력된 시간으로 설정
|
|
||||||
const startTime = new Date(document.getElementById('startTime').value + ':00').getTime();
|
|
||||||
const endTime = new Date(document.getElementById('endTime').value + ':00').getTime();
|
|
||||||
this.animationController.setTimeRange(startTime, endTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.animationController.togglePlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 시작
|
|
||||||
*/
|
|
||||||
startAnimation() {
|
|
||||||
if (!this.dataManager.hasData()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.animationController.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 속도 설정
|
|
||||||
*/
|
|
||||||
setSpeed(speed) {
|
|
||||||
this.animationController.setSpeed(speed);
|
|
||||||
updateSpeedButtons(speed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시간 업데이트 콜백
|
|
||||||
*/
|
|
||||||
onTimeUpdate(currentTime) {
|
|
||||||
const timelineData = this.animationController.getTimelineData();
|
|
||||||
updateTimelineUI(timelineData);
|
|
||||||
this.updateLayers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 재생 상태 변경 콜백
|
|
||||||
*/
|
|
||||||
onPlayStateChange(playing) {
|
|
||||||
const icon = playing ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
|
|
||||||
updateButtonState('playBtn', true, icon);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 렌더링 옵션 업데이트
|
|
||||||
*/
|
|
||||||
updateRenderOptions() {
|
|
||||||
const options = getRenderOptions();
|
|
||||||
this.mapRenderer.setRenderOptions(options);
|
|
||||||
this.updateLayers();
|
|
||||||
|
|
||||||
// 범례 표시/숨김
|
|
||||||
if (options.showTracks || options.showVessels) {
|
|
||||||
toggleDisplay('legend', true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateLayers() {
|
|
||||||
const currentTime = this.animationController.getCurrentTime();
|
|
||||||
this.mapRenderer.updateLayers(currentTime);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 연결 상태 업데이트
|
|
||||||
*/
|
|
||||||
updateConnectionStatus(connected) {
|
|
||||||
updateButtonState('connectBtn', !connected);
|
|
||||||
updateButtonState('disconnectBtn', connected);
|
|
||||||
updateButtonState('startBtn', connected);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통계 정보 업데이트
|
|
||||||
*/
|
|
||||||
updateStatistics() {
|
|
||||||
const stats = this.dataManager.getStatistics();
|
|
||||||
updateStatistics(stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 초기화
|
|
||||||
*/
|
|
||||||
resetAll() {
|
|
||||||
// 애니메이션 및 타이머 정리
|
|
||||||
this.animationController.reset();
|
|
||||||
this.stopTimers();
|
|
||||||
|
|
||||||
// 데이터 초기화
|
|
||||||
this.dataManager.reset();
|
|
||||||
|
|
||||||
// 맵 레이어 초기화
|
|
||||||
this.mapRenderer.clearLayers();
|
|
||||||
|
|
||||||
// UI 초기화
|
|
||||||
toggleDisplay('timelinePanel', false);
|
|
||||||
toggleDisplay('legend', false);
|
|
||||||
toggleDisplay('startBtn', true);
|
|
||||||
toggleDisplay('cancelBtn', false);
|
|
||||||
updateProgressBar(0);
|
|
||||||
updateButtonState('playBtn', true, '<i class="bi bi-play-fill"></i>');
|
|
||||||
|
|
||||||
// 통계 초기화
|
|
||||||
this.updateStatistics();
|
|
||||||
|
|
||||||
showSuccess('시스템 초기화 완료');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타이머 정리
|
|
||||||
*/
|
|
||||||
stopTimers() {
|
|
||||||
if (this.updateLayersTimer) {
|
|
||||||
clearInterval(this.updateLayersTimer);
|
|
||||||
this.updateLayersTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 인스턴스 생성 및 초기화
|
|
||||||
let app;
|
|
||||||
|
|
||||||
window.addEventListener('load', async () => {
|
|
||||||
app = new ChunkedStreamingApp();
|
|
||||||
await app.initialize();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전역 함수들 (기존 HTML에서 onclick 이벤트용)
|
|
||||||
window.connect = () => app?.connect();
|
|
||||||
window.disconnect = () => app?.disconnect();
|
|
||||||
window.startQuery = () => app?.startQuery();
|
|
||||||
window.cancelQuery = () => app?.cancelQuery();
|
|
||||||
window.togglePlay = () => app?.togglePlay();
|
|
||||||
window.setSpeed = (speed) => app?.setSpeed(speed);
|
|
||||||
window.setTimeRange = (hours) => app?.setTimeRange(hours);
|
|
||||||
window.resetAll = () => app?.resetAll();
|
|
||||||
window.clearLog = () => clearLog();
|
|
||||||
|
|
||||||
// 디버깅용 전역 함수들
|
|
||||||
window.debugAnimation = () => {
|
|
||||||
if (app) {
|
|
||||||
console.log('Animation State:', app.animationController.debugState());
|
|
||||||
console.log('Data Manager:', {
|
|
||||||
hasData: app.dataManager.hasData(),
|
|
||||||
statistics: app.dataManager.getStatistics()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.debugApp = () => app;
|
|
||||||
@ -1,955 +0,0 @@
|
|||||||
/**
|
|
||||||
* GIS 모니터링 메인 애플리케이션
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { GISMapController } from '../components/gis-map-controller.js';
|
|
||||||
import { GISVesselManager } from '../components/gis-vessel-manager.js';
|
|
||||||
import { GISSequentialManager } from '../components/gis-sequential-manager.js';
|
|
||||||
import { HaeguAPI, AreaAPI, TrackAPI, APIHelper } from '../api/gis-api.js';
|
|
||||||
import {
|
|
||||||
processVesselTracks,
|
|
||||||
mergeTrackPoints,
|
|
||||||
extractLatestPositions,
|
|
||||||
formatDistance,
|
|
||||||
formatSpeed,
|
|
||||||
parseVesselId
|
|
||||||
} from '../utils/gis-utils.js';
|
|
||||||
|
|
||||||
class GISMonitoringApp {
|
|
||||||
constructor() {
|
|
||||||
this.mapController = new GISMapController('mapContainer');
|
|
||||||
this.vesselManager = new GISVesselManager();
|
|
||||||
this.sequentialManager = new GISSequentialManager();
|
|
||||||
|
|
||||||
this.dataCache = {
|
|
||||||
haegus: [],
|
|
||||||
areas: [],
|
|
||||||
haeguStats: {},
|
|
||||||
areaStats: {},
|
|
||||||
currentArea: null
|
|
||||||
};
|
|
||||||
|
|
||||||
this.isLoading = false;
|
|
||||||
this.autoRefreshTimer = null;
|
|
||||||
this.contextMenuTarget = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애플리케이션 초기화
|
|
||||||
*/
|
|
||||||
async init() {
|
|
||||||
try {
|
|
||||||
// 지도 초기화
|
|
||||||
await this.mapController.init();
|
|
||||||
|
|
||||||
// 이벤트 핸들러 설정
|
|
||||||
this.setupEventHandlers();
|
|
||||||
|
|
||||||
// 초기 날짜 설정
|
|
||||||
this.initDateTime();
|
|
||||||
|
|
||||||
// 초기 데이터 로드
|
|
||||||
await this.refreshData();
|
|
||||||
|
|
||||||
// 자동 새로고침 시작 (5분마다)
|
|
||||||
this.startAutoRefresh();
|
|
||||||
|
|
||||||
console.log('GIS Monitoring App initialized successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize GIS app:', error);
|
|
||||||
this.showError('애플리케이션 초기화 실패');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupEventHandlers() {
|
|
||||||
// 지도 이벤트
|
|
||||||
this.mapController.setEventHandlers({
|
|
||||||
onAreaClick: (type, properties) => this.handleAreaClick(type, properties),
|
|
||||||
onContextMenu: (type, properties, event) => this.showContextMenu(type, properties, event)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.mapController.setupContextMenu();
|
|
||||||
|
|
||||||
// 선박 관리자 이벤트
|
|
||||||
this.vesselManager.setEventHandlers({
|
|
||||||
onSelectionChange: (selected) => this.handleVesselSelectionChange(selected)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sequential 관리자 이벤트
|
|
||||||
this.sequentialManager.setEventHandlers({
|
|
||||||
onZonesChange: (zones) => this.handleZonesChange(zones)
|
|
||||||
});
|
|
||||||
|
|
||||||
// UI 이벤트
|
|
||||||
this.setupUIEventHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI 이벤트 핸들러 설정
|
|
||||||
*/
|
|
||||||
setupUIEventHandlers() {
|
|
||||||
// 패널 토글
|
|
||||||
$('#panelToggle').on('click', () => this.toggleControlPanel());
|
|
||||||
|
|
||||||
// 새로고침
|
|
||||||
$('#refreshBtn').on('click', () => this.refreshData());
|
|
||||||
|
|
||||||
// 레이어 타입 변경
|
|
||||||
$('#gisLayerType').on('change', () => this.refreshData());
|
|
||||||
|
|
||||||
// Sequential 패널
|
|
||||||
$('#sequentialBtn').on('click', () => this.toggleSequentialPanel());
|
|
||||||
$('#closeSequentialBtn').on('click', () => this.toggleSequentialPanel());
|
|
||||||
|
|
||||||
// 트랙 정보 패널
|
|
||||||
$('#closeTrackPanelBtn').on('click', () => this.hideTrackPanel());
|
|
||||||
|
|
||||||
// 선박 목록
|
|
||||||
$('#hideVesselListBtn').on('click', () => this.toggleVesselList());
|
|
||||||
$('#selectAllBtn').on('click', () => this.selectAllVessels());
|
|
||||||
$('#deselectAllBtn').on('click', () => this.deselectAllVessels());
|
|
||||||
$('#showTracksBtn').on('click', () => this.showSelectedTracks());
|
|
||||||
|
|
||||||
// 정렬
|
|
||||||
$('#sortBy, #sortOrder').on('change', () => this.updateVesselList());
|
|
||||||
|
|
||||||
// 지도 토글
|
|
||||||
$('#showTracks').on('change', () => this.toggleTracks());
|
|
||||||
$('#showAllTracks').on('change', () => this.toggleAllTracks());
|
|
||||||
$('#showHeatmap').on('change', () => this.toggleHeatmap());
|
|
||||||
|
|
||||||
// 필터
|
|
||||||
$('#applyFiltersBtn').on('click', () => this.applyFilters());
|
|
||||||
$('#resetFiltersBtn').on('click', () => this.resetFilters());
|
|
||||||
|
|
||||||
// Sequential Passage
|
|
||||||
$('#addZoneBtn').on('click', () => this.addZone());
|
|
||||||
$('#searchPassageBtn').on('click', () => this.searchSequentialPassage());
|
|
||||||
$('#passageMode').on('change', () => this.handlePassageModeChange());
|
|
||||||
|
|
||||||
// 구역 입력 엔터키
|
|
||||||
$('#zoneInput').on('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter') this.addZone();
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컨텍스트 메뉴
|
|
||||||
$('#addToSequential').on('click', () => this.addToSequentialFromContext());
|
|
||||||
$('#viewAreaDetails').on('click', () => this.viewAreaDetailsFromContext());
|
|
||||||
$('#showAreaTracks').on('click', () => this.showAreaTracksFromContext());
|
|
||||||
|
|
||||||
// 컨텍스트 메뉴 숨기기
|
|
||||||
$(document).on('click', () => $('#contextMenu').hide());
|
|
||||||
|
|
||||||
// 동적 이벤트 위임
|
|
||||||
this.setupDynamicEventHandlers();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 동적 생성 요소 이벤트 핸들러
|
|
||||||
*/
|
|
||||||
setupDynamicEventHandlers() {
|
|
||||||
// 선박 체크박스
|
|
||||||
$(document).on('change', '.vessel-checkbox', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const vesselId = $(e.target).data('vessel-id');
|
|
||||||
const isChecked = this.vesselManager.toggleSelection(vesselId);
|
|
||||||
$(e.target).closest('.vessel-item').toggleClass('selected', isChecked);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 선박 아이템 클릭
|
|
||||||
$(document).on('click', '.vessel-item', (e) => {
|
|
||||||
if (!$(e.target).is('.vessel-checkbox')) {
|
|
||||||
const checkbox = $(e.currentTarget).find('.vessel-checkbox');
|
|
||||||
checkbox.prop('checked', !checkbox.prop('checked')).trigger('change');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sequential 결과 선택
|
|
||||||
$(document).on('click', '#sequentialResults .vessel-item', (e) => {
|
|
||||||
const vesselId = $(e.currentTarget).data('vessel-id');
|
|
||||||
this.selectSequentialVessel(vesselId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 구역 제거
|
|
||||||
$(document).on('click', '.remove-zone', (e) => {
|
|
||||||
const index = $(e.currentTarget).data('index');
|
|
||||||
this.sequentialManager.removeZone(index);
|
|
||||||
this.updateSelectedZones();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 초기 날짜 설정
|
|
||||||
*/
|
|
||||||
initDateTime() {
|
|
||||||
const now = new Date();
|
|
||||||
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
$('#passageStartTime').val(yesterday.toISOString().slice(0, 16));
|
|
||||||
$('#passageEndTime').val(now.toISOString().slice(0, 16));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 데이터 새로고침
|
|
||||||
*/
|
|
||||||
async refreshData() {
|
|
||||||
if (this.isLoading) return;
|
|
||||||
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timeRange = $('#gisTimeRange').val();
|
|
||||||
const layerType = $('#gisLayerType').val();
|
|
||||||
|
|
||||||
// 병렬 데이터 로딩
|
|
||||||
const promises = [];
|
|
||||||
|
|
||||||
if (layerType === 'haegu' || layerType === 'both') {
|
|
||||||
promises.push(this.loadHaeguData(timeRange));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layerType === 'area' || layerType === 'both') {
|
|
||||||
promises.push(this.loadAreaData(timeRange));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
|
|
||||||
// 레이어 업데이트
|
|
||||||
this.updateMapLayers();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to refresh data:', error);
|
|
||||||
this.showError('데이터 로드 실패');
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Haegu 데이터 로드
|
|
||||||
*/
|
|
||||||
async loadHaeguData(timeRange) {
|
|
||||||
const [boundaries, stats] = await APIHelper.parallel([
|
|
||||||
HaeguAPI.getBoundaries(),
|
|
||||||
HaeguAPI.getVesselStats(timeRange)
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.dataCache.haegus = boundaries;
|
|
||||||
this.dataCache.haeguStats = stats;
|
|
||||||
|
|
||||||
this.mapController.updateHaeguLayer(boundaries, stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Area 데이터 로드
|
|
||||||
*/
|
|
||||||
async loadAreaData(timeRange) {
|
|
||||||
const [boundaries, stats] = await APIHelper.parallel([
|
|
||||||
AreaAPI.getBoundaries(),
|
|
||||||
AreaAPI.getVesselStats(timeRange)
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.dataCache.areas = boundaries;
|
|
||||||
this.dataCache.areaStats = stats;
|
|
||||||
|
|
||||||
this.mapController.updateAreaLayer(boundaries, stats);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 레이어 업데이트
|
|
||||||
*/
|
|
||||||
updateMapLayers() {
|
|
||||||
const layerType = $('#gisLayerType').val();
|
|
||||||
|
|
||||||
if (layerType === 'haegu') {
|
|
||||||
this.mapController.removeLayer('area-layer');
|
|
||||||
} else if (layerType === 'area') {
|
|
||||||
this.mapController.removeLayer('haegu-layer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 클릭 처리
|
|
||||||
*/
|
|
||||||
handleAreaClick(type, properties) {
|
|
||||||
this.showAreaDetails(type, properties);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 상세 정보 표시
|
|
||||||
*/
|
|
||||||
showAreaDetails(type, properties) {
|
|
||||||
this.dataCache.currentArea = { type, properties };
|
|
||||||
|
|
||||||
const areaName = type === 'haegu' ?
|
|
||||||
`Haegu ${properties.haegu_no}` :
|
|
||||||
(properties.area_name || properties.area_id);
|
|
||||||
|
|
||||||
$('#trackPanelTitle').text(areaName);
|
|
||||||
$('#trackPanelContent').html(this.generateAreaDetailsHTML(type, properties));
|
|
||||||
$('#trackInfoPanel').show();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 상세 정보 HTML 생성
|
|
||||||
*/
|
|
||||||
generateAreaDetailsHTML(type, properties) {
|
|
||||||
const id = type === 'haegu' ? properties.haegu_no : properties.area_id;
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="mb-3">
|
|
||||||
<p><strong>활성 선박:</strong> ${properties.vessel_count || 0}척</p>
|
|
||||||
<p><strong>총 이동거리:</strong> ${formatDistance(properties.total_distance)}</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-grid gap-2">
|
|
||||||
<button class="btn btn-primary btn-sm" onclick="GISApp.showVesselList('${type}', '${id}')">
|
|
||||||
<i class="bi bi-list"></i> 선박 목록 보기
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-success btn-sm" onclick="GISApp.showVesselPositions('${type}', '${id}')">
|
|
||||||
<i class="bi bi-geo-fill"></i> 현재 위치 표시
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-warning btn-sm" onclick="GISApp.showAreaAllTracks('${type}', '${id}')">
|
|
||||||
<i class="bi bi-map"></i> 구역 내 모든 트랙
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 목록 표시
|
|
||||||
*/
|
|
||||||
async showVesselList(type, id) {
|
|
||||||
const timeRange = $('#gisTimeRange').val();
|
|
||||||
$('#vesselListTitle').text(`${type === 'haegu' ? 'Haegu ' + id : 'Area ' + id} 선박 목록`);
|
|
||||||
|
|
||||||
this.toggleVesselList(true);
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.vesselManager.loadVesselList(type, id, timeRange);
|
|
||||||
this.updateVesselList();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load vessel list:', error);
|
|
||||||
$('#vesselListContent').html('<div class="text-danger">선박 목록 로드 실패</div>');
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 위치 표시
|
|
||||||
*/
|
|
||||||
async showVesselPositions(type, id) {
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timeRange = $('#gisTimeRange').val();
|
|
||||||
const tracks = await TrackAPI.getByArea(type, id, timeRange);
|
|
||||||
const positions = extractLatestPositions(tracks);
|
|
||||||
|
|
||||||
this.mapController.updatePositionLayer(positions);
|
|
||||||
|
|
||||||
if (positions.length > 0) {
|
|
||||||
this.mapController.fitBounds(positions.map(p => p.position));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load vessel positions:', error);
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 내 모든 트랙 표시
|
|
||||||
*/
|
|
||||||
async showAreaAllTracks(type, id) {
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const period = $('#trackPeriod').val();
|
|
||||||
const tracks = await TrackAPI.getByArea(type, id, period);
|
|
||||||
|
|
||||||
const vesselTracks = processVesselTracks(tracks);
|
|
||||||
const processedTracks = [];
|
|
||||||
|
|
||||||
Object.values(vesselTracks).forEach(vessel => {
|
|
||||||
const allPoints = mergeTrackPoints(vessel.tracks);
|
|
||||||
|
|
||||||
if (allPoints.length > 0) {
|
|
||||||
processedTracks.push({
|
|
||||||
path: allPoints,
|
|
||||||
vessel_id: vessel.vessel_id,
|
|
||||||
distance: vessel.total_distance,
|
|
||||||
avg_speed: vessel.avg_speed,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#showTracks').prop('checked', true);
|
|
||||||
this.mapController.updateTrackLayer(processedTracks);
|
|
||||||
|
|
||||||
if (processedTracks.length > 0) {
|
|
||||||
const allPoints = processedTracks.flatMap(t => t.path);
|
|
||||||
this.mapController.fitBounds(allPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hideTrackPanel();
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load area tracks:', error);
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 목록 업데이트
|
|
||||||
*/
|
|
||||||
updateVesselList() {
|
|
||||||
const sortBy = $('#sortBy').val();
|
|
||||||
const sortOrder = $('#sortOrder').val();
|
|
||||||
|
|
||||||
this.vesselManager.setSort(sortBy, sortOrder);
|
|
||||||
const html = this.vesselManager.renderVesselList();
|
|
||||||
$('#vesselListContent').html(html);
|
|
||||||
|
|
||||||
this.updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택 카운트 업데이트
|
|
||||||
*/
|
|
||||||
updateSelectedCount() {
|
|
||||||
const count = this.vesselManager.getSelectedCount();
|
|
||||||
const $count = $('#selectedCount');
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
$count.text(count).show();
|
|
||||||
} else {
|
|
||||||
$count.hide();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 선택 변경 처리
|
|
||||||
*/
|
|
||||||
handleVesselSelectionChange(selected) {
|
|
||||||
this.updateSelectedCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 선박 선택
|
|
||||||
*/
|
|
||||||
selectAllVessels() {
|
|
||||||
this.vesselManager.selectAll();
|
|
||||||
this.updateVesselList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 선박 선택 해제
|
|
||||||
*/
|
|
||||||
deselectAllVessels() {
|
|
||||||
this.vesselManager.deselectAll();
|
|
||||||
this.updateVesselList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택된 선박 트랙 표시
|
|
||||||
*/
|
|
||||||
async showSelectedTracks() {
|
|
||||||
const selected = this.vesselManager.getSelectedVessels();
|
|
||||||
|
|
||||||
if (selected.size === 0) {
|
|
||||||
alert('선박을 선택하세요');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const period = $('#trackPeriod').val();
|
|
||||||
const tracks = await this.vesselManager.loadSelectedTracks(period);
|
|
||||||
|
|
||||||
$('#showTracks').prop('checked', true);
|
|
||||||
this.mapController.updateTrackLayer(tracks);
|
|
||||||
|
|
||||||
if (tracks.length > 0) {
|
|
||||||
const allPoints = tracks.flatMap(t => t.path);
|
|
||||||
this.mapController.fitBounds(allPoints);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.toggleVesselList(false);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load selected tracks:', error);
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 토글
|
|
||||||
*/
|
|
||||||
toggleTracks() {
|
|
||||||
const show = $('#showTracks').is(':checked');
|
|
||||||
|
|
||||||
if (!show) {
|
|
||||||
this.mapController.removeLayer('track-layer');
|
|
||||||
this.vesselManager.clearSelection();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 트랙 토글
|
|
||||||
*/
|
|
||||||
async toggleAllTracks() {
|
|
||||||
const show = $('#showAllTracks').is(':checked');
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
await this.loadAllTracks();
|
|
||||||
} else {
|
|
||||||
this.mapController.removeLayer('all-tracks-layer');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 전체 트랙 로드
|
|
||||||
*/
|
|
||||||
async loadAllTracks() {
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const timeRange = $('#trackPeriod').val();
|
|
||||||
const layerType = $('#gisLayerType').val();
|
|
||||||
const allTracks = [];
|
|
||||||
|
|
||||||
const loadPromises = [];
|
|
||||||
|
|
||||||
if (layerType === 'haegu' || layerType === 'both') {
|
|
||||||
Object.entries(this.dataCache.haeguStats).forEach(([haeguNo, stats]) => {
|
|
||||||
if (stats.vessel_count > 0) {
|
|
||||||
loadPromises.push(
|
|
||||||
HaeguAPI.getTracks(haeguNo, timeRange)
|
|
||||||
.then(tracks => allTracks.push(...tracks))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (layerType === 'area' || layerType === 'both') {
|
|
||||||
Object.entries(this.dataCache.areaStats).forEach(([areaId, stats]) => {
|
|
||||||
if (stats.vessel_count > 0) {
|
|
||||||
loadPromises.push(
|
|
||||||
AreaAPI.getTracks(areaId, timeRange)
|
|
||||||
.then(tracks => allTracks.push(...tracks))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(loadPromises);
|
|
||||||
|
|
||||||
const vesselTracks = processVesselTracks(allTracks);
|
|
||||||
const processedTracks = [];
|
|
||||||
|
|
||||||
Object.values(vesselTracks).forEach(vessel => {
|
|
||||||
const allPoints = mergeTrackPoints(vessel.tracks);
|
|
||||||
|
|
||||||
if (allPoints.length > 0) {
|
|
||||||
processedTracks.push({
|
|
||||||
path: allPoints,
|
|
||||||
vessel_id: vessel.vessel_id,
|
|
||||||
distance: vessel.total_distance,
|
|
||||||
avg_speed: vessel.avg_speed,
|
|
||||||
selected: false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.mapController.updateTrackLayer(processedTracks, 'all-tracks-layer');
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load all tracks:', error);
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 히트맵 토글
|
|
||||||
*/
|
|
||||||
toggleHeatmap() {
|
|
||||||
const show = $('#showHeatmap').is(':checked');
|
|
||||||
|
|
||||||
if (show) {
|
|
||||||
// 히트맵 기능 구현 예정
|
|
||||||
alert('히트맵 기능은 준비 중입니다');
|
|
||||||
$('#showHeatmap').prop('checked', false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 적용
|
|
||||||
*/
|
|
||||||
applyFilters() {
|
|
||||||
const filters = {
|
|
||||||
minSpeed: parseFloat($('#minSpeedFilter').val()) || 0,
|
|
||||||
maxSpeed: parseFloat($('#maxSpeedFilter').val()) || 50,
|
|
||||||
minDist: parseFloat($('#minDistFilter').val()) || 0,
|
|
||||||
maxDist: parseFloat($('#maxDistFilter').val()) || 200
|
|
||||||
};
|
|
||||||
|
|
||||||
this.vesselManager.setFilters(filters);
|
|
||||||
this.updateVesselList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 초기화
|
|
||||||
*/
|
|
||||||
resetFilters() {
|
|
||||||
$('#minSpeedFilter').val(0);
|
|
||||||
$('#maxSpeedFilter').val(50);
|
|
||||||
$('#minDistFilter').val(0);
|
|
||||||
$('#maxDistFilter').val(200);
|
|
||||||
|
|
||||||
this.vesselManager.resetFilters();
|
|
||||||
this.updateVesselList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sequential Passage 패널 토글
|
|
||||||
*/
|
|
||||||
toggleSequentialPanel() {
|
|
||||||
$('#sequentialPanel').toggle();
|
|
||||||
|
|
||||||
if ($('#sequentialPanel').is(':visible')) {
|
|
||||||
this.updateSelectedZones();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 추가
|
|
||||||
*/
|
|
||||||
addZone() {
|
|
||||||
const zoneId = $('#zoneInput').val().trim();
|
|
||||||
|
|
||||||
if (!zoneId) {
|
|
||||||
alert('구역 ID를 입력하세요');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = this.sequentialManager.addZone(zoneId);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
$('#zoneInput').val('');
|
|
||||||
this.updateSelectedZones();
|
|
||||||
} else {
|
|
||||||
alert(result.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선택된 구역 업데이트
|
|
||||||
*/
|
|
||||||
updateSelectedZones() {
|
|
||||||
const html = this.sequentialManager.renderZones();
|
|
||||||
$('#selectedZones').html(html);
|
|
||||||
|
|
||||||
const zones = this.sequentialManager.getZones();
|
|
||||||
const $selector = $('#zoneSelector');
|
|
||||||
|
|
||||||
if (zones.length > 0) {
|
|
||||||
$selector.addClass('has-zones');
|
|
||||||
} else {
|
|
||||||
$selector.removeClass('has-zones');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 변경 처리
|
|
||||||
*/
|
|
||||||
handleZonesChange(zones) {
|
|
||||||
this.updateSelectedZones();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통과 모드 변경 처리
|
|
||||||
*/
|
|
||||||
handlePassageModeChange() {
|
|
||||||
const mode = $('#passageMode').val();
|
|
||||||
this.sequentialManager.setMode(mode);
|
|
||||||
|
|
||||||
if (mode === 'sequential') {
|
|
||||||
this.sequentialManager.setMaxZones(3);
|
|
||||||
} else {
|
|
||||||
this.sequentialManager.setMaxZones(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sequential Passage 검색
|
|
||||||
*/
|
|
||||||
async searchSequentialPassage() {
|
|
||||||
const mode = $('#passageMode').val();
|
|
||||||
const type = $('#passageZoneType').val();
|
|
||||||
const startTime = $('#passageStartTime').val();
|
|
||||||
const endTime = $('#passageEndTime').val();
|
|
||||||
|
|
||||||
const validation = this.sequentialManager.validateSearchParams(startTime, endTime);
|
|
||||||
|
|
||||||
if (!validation.valid) {
|
|
||||||
alert(validation.errors.join('\n'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.sequentialManager.search(mode, type, startTime, endTime);
|
|
||||||
const html = this.sequentialManager.renderResults();
|
|
||||||
$('#sequentialResults').html(html);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Sequential passage search failed:', error);
|
|
||||||
alert('검색 실패: ' + error.message);
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sequential 선박 선택
|
|
||||||
*/
|
|
||||||
async selectSequentialVessel(vesselId) {
|
|
||||||
const { sigSrcCd, targetId } = parseVesselId(vesselId);
|
|
||||||
const startTime = $('#passageStartTime').val();
|
|
||||||
const endTime = $('#passageEndTime').val();
|
|
||||||
|
|
||||||
this.showLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await TrackAPI.getVesselTracks({
|
|
||||||
startTime: APIHelper.formatTimestamp(startTime),
|
|
||||||
endTime: APIHelper.formatTimestamp(endTime),
|
|
||||||
vessels: [{ sigSrcCd, targetId }]
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response && response.length > 0) {
|
|
||||||
const vesselTrack = response[0];
|
|
||||||
|
|
||||||
const trackData = [{
|
|
||||||
path: vesselTrack.geometry,
|
|
||||||
vessel_id: vesselTrack.vesselId,
|
|
||||||
distance: vesselTrack.totalDistance,
|
|
||||||
avg_speed: vesselTrack.avgSpeed,
|
|
||||||
max_speed: vesselTrack.maxSpeed,
|
|
||||||
selected: true
|
|
||||||
}];
|
|
||||||
|
|
||||||
$('#showTracks').prop('checked', true);
|
|
||||||
this.mapController.updateTrackLayer(trackData);
|
|
||||||
|
|
||||||
if (vesselTrack.geometry.length > 0) {
|
|
||||||
this.mapController.fitBounds(vesselTrack.geometry);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택 표시
|
|
||||||
$('#sequentialResults .vessel-item').removeClass('selected');
|
|
||||||
$(`#sequentialResults .vessel-item[data-vessel-id="${vesselId}"]`).addClass('selected');
|
|
||||||
} else {
|
|
||||||
alert('해당 기간에 트랙이 없습니다');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load vessel track:', error);
|
|
||||||
alert('트랙 로드 실패');
|
|
||||||
} finally {
|
|
||||||
this.showLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨텍스트 메뉴 표시
|
|
||||||
*/
|
|
||||||
showContextMenu(type, properties, event) {
|
|
||||||
this.contextMenuTarget = { type, properties };
|
|
||||||
|
|
||||||
const $menu = $('#contextMenu');
|
|
||||||
const $addItem = $('#addToSequential');
|
|
||||||
|
|
||||||
// Sequential 추가 가능 여부 확인
|
|
||||||
const zoneId = type === 'haegu' ?
|
|
||||||
properties.haegu_no.toString() :
|
|
||||||
properties.area_id;
|
|
||||||
|
|
||||||
if (!this.sequentialManager.canAddZone()) {
|
|
||||||
$addItem.addClass('disabled').html('<i class="bi bi-x-circle"></i> 최대 구역 도달');
|
|
||||||
} else if (this.sequentialManager.hasZone(zoneId)) {
|
|
||||||
$addItem.addClass('disabled').html('<i class="bi bi-check-circle"></i> 이미 추가됨');
|
|
||||||
} else {
|
|
||||||
$addItem.removeClass('disabled').html('<i class="bi bi-plus-circle"></i> Sequential에 추가');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메뉴 위치 설정
|
|
||||||
$menu.css({
|
|
||||||
left: event.clientX + 'px',
|
|
||||||
top: event.clientY + 'px',
|
|
||||||
display: 'block'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨텍스트 메뉴: Sequential에 추가
|
|
||||||
*/
|
|
||||||
addToSequentialFromContext() {
|
|
||||||
if (!this.contextMenuTarget || $('#addToSequential').hasClass('disabled')) return;
|
|
||||||
|
|
||||||
const { type, properties } = this.contextMenuTarget;
|
|
||||||
const zoneId = type === 'haegu' ?
|
|
||||||
properties.haegu_no.toString() :
|
|
||||||
properties.area_id;
|
|
||||||
|
|
||||||
const result = this.sequentialManager.addZone(zoneId);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
// Sequential 패널 표시
|
|
||||||
if (!$('#sequentialPanel').is(':visible')) {
|
|
||||||
$('#sequentialPanel').show();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 구역 타입 설정
|
|
||||||
$('#passageZoneType').val(type === 'haegu' ? 'GRID' : 'AREA');
|
|
||||||
|
|
||||||
this.updateSelectedZones();
|
|
||||||
}
|
|
||||||
|
|
||||||
$('#contextMenu').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨텍스트 메뉴: 상세 정보 보기
|
|
||||||
*/
|
|
||||||
viewAreaDetailsFromContext() {
|
|
||||||
if (!this.contextMenuTarget) return;
|
|
||||||
|
|
||||||
const { type, properties } = this.contextMenuTarget;
|
|
||||||
this.showAreaDetails(type, properties);
|
|
||||||
$('#contextMenu').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 컨텍스트 메뉴: 구역 트랙 표시
|
|
||||||
*/
|
|
||||||
showAreaTracksFromContext() {
|
|
||||||
if (!this.contextMenuTarget) return;
|
|
||||||
|
|
||||||
const { type, properties } = this.contextMenuTarget;
|
|
||||||
const id = type === 'haegu' ? properties.haegu_no : properties.area_id;
|
|
||||||
|
|
||||||
this.showAreaAllTracks(type, id);
|
|
||||||
$('#contextMenu').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 패널 토글
|
|
||||||
*/
|
|
||||||
toggleControlPanel() {
|
|
||||||
const $panel = $('#controlPanel');
|
|
||||||
const $icon = $('#panelToggle i');
|
|
||||||
|
|
||||||
$panel.toggleClass('collapsed');
|
|
||||||
|
|
||||||
if ($panel.hasClass('collapsed')) {
|
|
||||||
$icon.removeClass('bi-chevron-left').addClass('bi-chevron-right');
|
|
||||||
} else {
|
|
||||||
$icon.removeClass('bi-chevron-right').addClass('bi-chevron-left');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 목록 토글
|
|
||||||
*/
|
|
||||||
toggleVesselList(show) {
|
|
||||||
const $section = $('#vesselListSection');
|
|
||||||
|
|
||||||
if (show === undefined) {
|
|
||||||
$section.toggleClass('expanded');
|
|
||||||
} else {
|
|
||||||
$section.toggleClass('expanded', show);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 패널 숨기기
|
|
||||||
*/
|
|
||||||
hideTrackPanel() {
|
|
||||||
$('#trackInfoPanel').hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로딩 표시
|
|
||||||
*/
|
|
||||||
showLoading(show) {
|
|
||||||
this.isLoading = show;
|
|
||||||
$('#loadingOverlay').css('display', show ? 'flex' : 'none');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 표시
|
|
||||||
*/
|
|
||||||
showError(message) {
|
|
||||||
console.error(message);
|
|
||||||
// Toast 또는 Alert 구현 가능
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 새로고침 시작
|
|
||||||
*/
|
|
||||||
startAutoRefresh() {
|
|
||||||
this.stopAutoRefresh();
|
|
||||||
|
|
||||||
this.autoRefreshTimer = setInterval(() => {
|
|
||||||
if (!this.isLoading) {
|
|
||||||
this.refreshData();
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000); // 5분
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 새로고침 중지
|
|
||||||
*/
|
|
||||||
stopAutoRefresh() {
|
|
||||||
if (this.autoRefreshTimer) {
|
|
||||||
clearInterval(this.autoRefreshTimer);
|
|
||||||
this.autoRefreshTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 정리
|
|
||||||
*/
|
|
||||||
destroy() {
|
|
||||||
this.stopAutoRefresh();
|
|
||||||
$(document).off();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전역 인스턴스 생성
|
|
||||||
let GISApp;
|
|
||||||
|
|
||||||
// DOM 준비 완료 시 실행
|
|
||||||
$(document).ready(() => {
|
|
||||||
GISApp = new GISMonitoringApp();
|
|
||||||
GISApp.init().catch(error => {
|
|
||||||
console.error('Failed to start GIS Monitoring App:', error);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 전역 함수 노출 (레거시 호환성)
|
|
||||||
window.GISApp = {
|
|
||||||
showVesselList: (type, id) => GISApp.showVesselList(type, id),
|
|
||||||
showVesselPositions: (type, id) => GISApp.showVesselPositions(type, id),
|
|
||||||
showAreaAllTracks: (type, id) => GISApp.showAreaAllTracks(type, id)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@ -1,315 +0,0 @@
|
|||||||
/**
|
|
||||||
* Chunked Streaming Utility Functions
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜/시간 포맷팅
|
|
||||||
*/
|
|
||||||
export function formatDateTime(date) {
|
|
||||||
const year = date.getFullYear();
|
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
|
||||||
const hours = String(date.getHours()).padStart(2, '0');
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
||||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기본 시간 범위 설정 (현재 시간 기준)
|
|
||||||
*/
|
|
||||||
export function getDefaultTimeRange() {
|
|
||||||
const now = new Date();
|
|
||||||
const adjusted = new Date(now.getTime() - 15 * 60 * 1000); // 15분 전
|
|
||||||
const sixHoursAgo = new Date(adjusted.getTime() - 6 * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startTime: formatDateTime(sixHoursAgo),
|
|
||||||
endTime: formatDateTime(adjusted)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 지정된 시간 전의 시간 범위 생성
|
|
||||||
*/
|
|
||||||
export function createTimeRange(hours) {
|
|
||||||
const now = new Date();
|
|
||||||
const adjusted = new Date(now.getTime() - 15 * 60 * 1000);
|
|
||||||
const start = new Date(adjusted.getTime() - hours * 60 * 60 * 1000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startTime: formatDateTime(start),
|
|
||||||
endTime: formatDateTime(adjusted)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 맵 뷰포트 정보 추출
|
|
||||||
*/
|
|
||||||
export function getMapViewport(map) {
|
|
||||||
const bounds = map.getBounds();
|
|
||||||
const zoom = Math.floor(map.getZoom());
|
|
||||||
|
|
||||||
return {
|
|
||||||
viewport: {
|
|
||||||
minLon: bounds.getWest(),
|
|
||||||
maxLon: bounds.getEast(),
|
|
||||||
minLat: bounds.getSouth(),
|
|
||||||
maxLat: bounds.getNorth()
|
|
||||||
},
|
|
||||||
zoom
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 뷰포트 정보를 입력 필드에 업데이트
|
|
||||||
*/
|
|
||||||
export function updateViewportDisplay(map) {
|
|
||||||
const { viewport, zoom } = getMapViewport(map);
|
|
||||||
|
|
||||||
const elements = {
|
|
||||||
currentZoom: document.getElementById('currentZoom'),
|
|
||||||
minLon: document.getElementById('minLon'),
|
|
||||||
maxLon: document.getElementById('maxLon'),
|
|
||||||
minLat: document.getElementById('minLat'),
|
|
||||||
maxLat: document.getElementById('maxLat')
|
|
||||||
};
|
|
||||||
|
|
||||||
if (elements.currentZoom) {
|
|
||||||
elements.currentZoom.textContent = `Zoom: ${zoom}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.minLon) elements.minLon.value = viewport.minLon.toFixed(4);
|
|
||||||
if (elements.maxLon) elements.maxLon.value = viewport.maxLon.toFixed(4);
|
|
||||||
if (elements.minLat) elements.minLat.value = viewport.minLat.toFixed(4);
|
|
||||||
if (elements.maxLat) elements.maxLat.value = viewport.maxLat.toFixed(4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 메시지 추가
|
|
||||||
*/
|
|
||||||
export function addLog(message, type = 'info') {
|
|
||||||
const logContainer = document.getElementById('logContainer');
|
|
||||||
if (!logContainer) return;
|
|
||||||
|
|
||||||
const entry = document.createElement('div');
|
|
||||||
entry.className = `log-entry log-${type}`;
|
|
||||||
entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
|
||||||
|
|
||||||
logContainer.appendChild(entry);
|
|
||||||
logContainer.scrollTop = logContainer.scrollHeight;
|
|
||||||
|
|
||||||
// 로그 개수 제한 (50개)
|
|
||||||
if (logContainer.children.length > 50) {
|
|
||||||
logContainer.removeChild(logContainer.firstChild);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 로그 초기화
|
|
||||||
*/
|
|
||||||
export function clearLog() {
|
|
||||||
const logContainer = document.getElementById('logContainer');
|
|
||||||
if (logContainer) {
|
|
||||||
logContainer.innerHTML = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* UI 요소 표시/숨김
|
|
||||||
*/
|
|
||||||
export function toggleDisplay(elementId, show) {
|
|
||||||
const element = document.getElementById(elementId);
|
|
||||||
if (element) {
|
|
||||||
element.style.display = show ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 버튼 상태 업데이트
|
|
||||||
*/
|
|
||||||
export function updateButtonState(buttonId, enabled, text = null) {
|
|
||||||
const button = document.getElementById(buttonId);
|
|
||||||
if (button) {
|
|
||||||
button.disabled = !enabled;
|
|
||||||
if (text) {
|
|
||||||
button.innerHTML = text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 진행률 바 업데이트
|
|
||||||
*/
|
|
||||||
export function updateProgressBar(percentage) {
|
|
||||||
const progressBar = document.getElementById('progressBar');
|
|
||||||
const progressContainer = document.getElementById('progressContainer');
|
|
||||||
|
|
||||||
if (progressBar) {
|
|
||||||
progressBar.style.width = percentage + '%';
|
|
||||||
progressBar.textContent = Math.round(percentage) + '%';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (progressContainer) {
|
|
||||||
progressContainer.style.display = percentage > 0 ? 'block' : 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 애니메이션 속도 버튼 업데이트
|
|
||||||
*/
|
|
||||||
export function updateSpeedButtons(currentSpeed) {
|
|
||||||
document.querySelectorAll('.btn-speed').forEach(btn => {
|
|
||||||
const speed = parseFloat(btn.textContent.replace('x', ''));
|
|
||||||
if (speed === currentSpeed) {
|
|
||||||
btn.classList.remove('btn-outline-secondary');
|
|
||||||
btn.classList.add('btn-secondary');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('btn-secondary');
|
|
||||||
btn.classList.add('btn-outline-secondary');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 타임라인 UI 업데이트
|
|
||||||
*/
|
|
||||||
export function updateTimelineUI(timelineData) {
|
|
||||||
const elements = {
|
|
||||||
timeSlider: document.getElementById('timeSlider'),
|
|
||||||
currentDateDisplay: document.getElementById('currentDateDisplay'),
|
|
||||||
currentTimeDisplay: document.getElementById('currentTimeDisplay'),
|
|
||||||
startTimeDisplay: document.getElementById('startTimeDisplay'),
|
|
||||||
endTimeDisplay: document.getElementById('endTimeDisplay')
|
|
||||||
};
|
|
||||||
|
|
||||||
if (elements.timeSlider) {
|
|
||||||
elements.timeSlider.value = timelineData.progress;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.currentDateDisplay) {
|
|
||||||
elements.currentDateDisplay.textContent = timelineData.currentDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.currentTimeDisplay) {
|
|
||||||
elements.currentTimeDisplay.textContent = timelineData.currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.startTimeDisplay) {
|
|
||||||
elements.startTimeDisplay.textContent = timelineData.startTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.endTimeDisplay) {
|
|
||||||
elements.endTimeDisplay.textContent = timelineData.endTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통계 정보 업데이트
|
|
||||||
*/
|
|
||||||
export function updateStatistics(stats) {
|
|
||||||
const elements = {
|
|
||||||
chunksReceived: document.getElementById('chunksReceived'),
|
|
||||||
vesselCount: document.getElementById('vesselCount'),
|
|
||||||
pointCount: document.getElementById('pointCount'),
|
|
||||||
dataSize: document.getElementById('dataSize'),
|
|
||||||
processTime: document.getElementById('processTime')
|
|
||||||
};
|
|
||||||
|
|
||||||
if (elements.chunksReceived && stats.chunksReceived !== undefined) {
|
|
||||||
elements.chunksReceived.textContent = stats.chunksReceived;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.vesselCount && stats.vesselCount !== undefined) {
|
|
||||||
elements.vesselCount.textContent = stats.vesselCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.pointCount && stats.pointCount !== undefined) {
|
|
||||||
elements.pointCount.textContent = stats.pointCount.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.dataSize && stats.dataSize !== undefined) {
|
|
||||||
elements.dataSize.textContent = stats.dataSize.toFixed(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.processTime && stats.processTime !== undefined) {
|
|
||||||
elements.processTime.textContent = stats.processTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 렌더링 옵션 읽기
|
|
||||||
*/
|
|
||||||
export function getRenderOptions() {
|
|
||||||
return {
|
|
||||||
showTracks: document.getElementById('showTracks')?.checked ?? true,
|
|
||||||
showVessels: document.getElementById('showVessels')?.checked ?? true,
|
|
||||||
colorBySpeed: document.getElementById('colorBySpeed')?.checked ?? true,
|
|
||||||
trackOpacity: parseInt(document.getElementById('trackOpacity')?.value ?? 60)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DOM 요소 안전 접근
|
|
||||||
*/
|
|
||||||
export function safeGetElement(id) {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (!element) {
|
|
||||||
console.warn(`Element with id '${id}' not found`);
|
|
||||||
}
|
|
||||||
return element;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 안전한 이벤트 리스너 등록
|
|
||||||
*/
|
|
||||||
export function safeAddEventListener(id, event, handler) {
|
|
||||||
const element = safeGetElement(id);
|
|
||||||
if (element) {
|
|
||||||
element.addEventListener(event, handler);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 파라미터 검증
|
|
||||||
*/
|
|
||||||
export function validateQueryParams(startTime, endTime) {
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
if (!startTime || !endTime) {
|
|
||||||
errors.push('시작 시간과 종료 시간을 모두 입력하세요');
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(startTime);
|
|
||||||
const end = new Date(endTime);
|
|
||||||
|
|
||||||
if (start >= end) {
|
|
||||||
errors.push('종료 시간은 시작 시간보다 이후여야 합니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxRange = 7 * 24 * 60 * 60 * 1000; // 7일
|
|
||||||
if (end - start > maxRange) {
|
|
||||||
errors.push('최대 조회 기간은 7일입니다');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid: errors.length === 0,
|
|
||||||
errors
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 에러 메시지 표시
|
|
||||||
*/
|
|
||||||
export function showError(message) {
|
|
||||||
addLog(`에러: ${message}`, 'error');
|
|
||||||
// 추가적인 에러 표시 로직 (예: 모달, 토스트 등)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 성공 메시지 표시
|
|
||||||
*/
|
|
||||||
export function showSuccess(message) {
|
|
||||||
addLog(message, 'success');
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 공통 상수 정의
|
|
||||||
*/
|
|
||||||
|
|
||||||
// API 엔드포인트
|
|
||||||
export const API_ENDPOINTS = {
|
|
||||||
ABNORMAL_TRACKS: {
|
|
||||||
RECENT: '/api/v1/abnormal-tracks/recent',
|
|
||||||
DETECT: '/api/v1/abnormal-tracks/detect',
|
|
||||||
MOVE_TO_ABNORMAL: '/api/v1/abnormal-tracks/move-to-abnormal'
|
|
||||||
},
|
|
||||||
TILES: {
|
|
||||||
WORLD: '/api/tiles/world/{z}/{x}/{y}.webp'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비정상 유형
|
|
||||||
export const ABNORMAL_TYPES = {
|
|
||||||
EXTREME_SPEED: 'extreme_speed',
|
|
||||||
EXTREME_DISTANCE: 'extreme_distance',
|
|
||||||
EXTREME_TRANSITION: 'extreme_transition',
|
|
||||||
EXTREME_AVG_SPEED_5MIN: 'extreme_avg_speed_5min',
|
|
||||||
IMPOSSIBLE_TRANSITION: 'impossible_transition',
|
|
||||||
USER_DETECTED: 'user_detected'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비정상 유형 라벨
|
|
||||||
export const ABNORMAL_TYPE_LABELS = {
|
|
||||||
[ABNORMAL_TYPES.EXTREME_SPEED]: '극단적 비정상 속도',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_DISTANCE]: '5분간 극단적 이동',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_TRANSITION]: '극단적 bucket 전환',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_AVG_SPEED_5MIN]: '극단적 평균속도 (5분)',
|
|
||||||
[ABNORMAL_TYPES.IMPOSSIBLE_TRANSITION]: '물리적 불가능 전환',
|
|
||||||
[ABNORMAL_TYPES.USER_DETECTED]: '사용자 검출'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비정상 유형 설명
|
|
||||||
export const ABNORMAL_TYPE_DESCRIPTIONS = {
|
|
||||||
[ABNORMAL_TYPES.EXTREME_SPEED]: '평균속도가 선박 500knots, 항공기 800knots 초과',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_DISTANCE]: '5분간 이동거리가 100nm 초과',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_TRANSITION]: '버킷 전환 시 암시된 속도가 비정상적으로 높음',
|
|
||||||
[ABNORMAL_TYPES.EXTREME_AVG_SPEED_5MIN]: '5분 평균속도가 1000knots 초과 (네트워크 오류)',
|
|
||||||
[ABNORMAL_TYPES.IMPOSSIBLE_TRANSITION]: '시간대별 집계 시 물리적으로 불가능한 전환 (500knots 초과)',
|
|
||||||
[ABNORMAL_TYPES.USER_DETECTED]: '사용자가 수동으로 검출한 비정상 궤적'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 비정상 유형별 색상 (RGB)
|
|
||||||
export const ABNORMAL_TYPE_COLORS = {
|
|
||||||
[ABNORMAL_TYPES.EXTREME_SPEED]: [231, 76, 60],
|
|
||||||
[ABNORMAL_TYPES.EXTREME_DISTANCE]: [243, 156, 18],
|
|
||||||
[ABNORMAL_TYPES.EXTREME_TRANSITION]: [155, 89, 182],
|
|
||||||
[ABNORMAL_TYPES.EXTREME_AVG_SPEED_5MIN]: [52, 152, 219],
|
|
||||||
[ABNORMAL_TYPES.IMPOSSIBLE_TRANSITION]: [192, 57, 43],
|
|
||||||
[ABNORMAL_TYPES.USER_DETECTED]: [149, 165, 166]
|
|
||||||
};
|
|
||||||
|
|
||||||
// 지도 설정
|
|
||||||
export const MAP_CONFIG = {
|
|
||||||
DEFAULT_CENTER: [128, 35.5],
|
|
||||||
DEFAULT_ZOOM: 6,
|
|
||||||
TILE_SIZE: 256,
|
|
||||||
MIN_ZOOM: 0,
|
|
||||||
MAX_ZOOM: 22
|
|
||||||
};
|
|
||||||
|
|
||||||
// 정렬 옵션
|
|
||||||
export const SORT_OPTIONS = {
|
|
||||||
COUNT: 'count',
|
|
||||||
DISTANCE: 'distance',
|
|
||||||
SPEED: 'speed',
|
|
||||||
RECENT: 'recent'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테이블 타입
|
|
||||||
export const TABLE_TYPES = {
|
|
||||||
HOURLY: 'hourly',
|
|
||||||
DAILY: 'daily'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 자동 새로고침 간격 (밀리초)
|
|
||||||
export const REFRESH_INTERVAL = 300000; // 5분
|
|
||||||
|
|
||||||
// 애니메이션 설정
|
|
||||||
export const ANIMATION = {
|
|
||||||
TRANSITION_DURATION: 300,
|
|
||||||
HIGHLIGHT_DURATION: 1000
|
|
||||||
};
|
|
||||||
|
|
||||||
// 레이어 설정
|
|
||||||
export const LAYER_CONFIG = {
|
|
||||||
PATH: {
|
|
||||||
WIDTH_MIN: 2,
|
|
||||||
WIDTH_MAX: 20,
|
|
||||||
DEFAULT_WIDTH: 3,
|
|
||||||
SELECTED_WIDTH: 15,
|
|
||||||
ACTIVE_WIDTH: 4,
|
|
||||||
INACTIVE_OPACITY: 60,
|
|
||||||
ACTIVE_OPACITY: 120,
|
|
||||||
SELECTED_OPACITY: 255
|
|
||||||
},
|
|
||||||
POINT: {
|
|
||||||
RADIUS: 8,
|
|
||||||
RADIUS_MIN: 4,
|
|
||||||
RADIUS_MAX: 12,
|
|
||||||
STROKE_WIDTH: 2
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 색상 테마
|
|
||||||
export const COLORS = {
|
|
||||||
PRIMARY: '#007bff',
|
|
||||||
SUCCESS: '#28a745',
|
|
||||||
WARNING: '#ffc107',
|
|
||||||
DANGER: '#dc3545',
|
|
||||||
SECONDARY: '#6c757d',
|
|
||||||
SELECTED: [255, 215, 0], // 금색
|
|
||||||
DEFAULT_TRACK: [128, 128, 128]
|
|
||||||
};
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
/**
|
|
||||||
* GIS 유틸리티 함수
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* LineStringM WKT 파싱
|
|
||||||
* @param {string} wkt - WKT 문자열
|
|
||||||
* @returns {Array} 좌표 배열 [[lon, lat], ...]
|
|
||||||
*/
|
|
||||||
export function parseLineStringM(wkt) {
|
|
||||||
if (!wkt) return [];
|
|
||||||
|
|
||||||
const matches = wkt.match(/LINESTRING M\s*\((.*)\)/);
|
|
||||||
if (!matches) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const coords = matches[1].split(',').map(coord => {
|
|
||||||
const parts = coord.trim().split(/\s+/);
|
|
||||||
const lon = parseFloat(parts[0]);
|
|
||||||
const lat = parseFloat(parts[1]);
|
|
||||||
|
|
||||||
if (isNaN(lon) || isNaN(lat)) return null;
|
|
||||||
|
|
||||||
return [lon, lat];
|
|
||||||
}).filter(coord => coord !== null);
|
|
||||||
|
|
||||||
return coords;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing LineStringM:', error);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 수에 따른 색상 계산
|
|
||||||
* @param {number} count - 선박 수
|
|
||||||
* @returns {Array} RGBA 색상 배열
|
|
||||||
*/
|
|
||||||
export function getVesselCountColor(count) {
|
|
||||||
if (count >= 200) return [255, 69, 0, 150]; // 빨강
|
|
||||||
if (count >= 100) return [255, 165, 0, 150]; // 주황
|
|
||||||
if (count >= 50) return [255, 215, 0, 150]; // 금색
|
|
||||||
if (count >= 10) return [144, 238, 144, 150]; // 연두
|
|
||||||
if (count > 0) return [224, 224, 224, 150]; // 연회색
|
|
||||||
return [224, 224, 224, 50]; // 투명 회색
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 속도에 따른 트랙 색상 계산
|
|
||||||
* @param {number} speed - 속도 (knots)
|
|
||||||
* @param {boolean} selected - 선택 여부
|
|
||||||
* @returns {Array} RGBA 색상 배열
|
|
||||||
*/
|
|
||||||
export function getTrackColor(speed, selected = false) {
|
|
||||||
if (selected) return [0, 128, 255, 255]; // 파랑 (선택됨)
|
|
||||||
if (speed > 20) return [255, 0, 0, 200]; // 빨강 (고속)
|
|
||||||
if (speed > 10) return [255, 165, 0, 200]; // 주황 (중속)
|
|
||||||
return [0, 255, 0, 200]; // 초록 (저속)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 좌표 배열로부터 경계 계산
|
|
||||||
* @param {Array} coordinates - 좌표 배열
|
|
||||||
* @returns {Object} 경계 객체 {minLng, minLat, maxLng, maxLat}
|
|
||||||
*/
|
|
||||||
export function calculateBounds(coordinates) {
|
|
||||||
if (!coordinates || coordinates.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let minLng = Infinity, minLat = Infinity;
|
|
||||||
let maxLng = -Infinity, maxLat = -Infinity;
|
|
||||||
|
|
||||||
coordinates.forEach(coord => {
|
|
||||||
if (Array.isArray(coord) && coord.length >= 2) {
|
|
||||||
minLng = Math.min(minLng, coord[0]);
|
|
||||||
maxLng = Math.max(maxLng, coord[0]);
|
|
||||||
minLat = Math.min(minLat, coord[1]);
|
|
||||||
maxLat = Math.max(maxLat, coord[1]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
minLng,
|
|
||||||
minLat,
|
|
||||||
maxLng,
|
|
||||||
maxLat
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 데이터 처리 및 병합
|
|
||||||
* @param {Array} tracks - 원시 트랙 데이터
|
|
||||||
* @returns {Object} 선박별로 병합된 트랙 데이터
|
|
||||||
*/
|
|
||||||
export function processVesselTracks(tracks) {
|
|
||||||
const vesselTracks = {};
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const vesselId = `${track.sig_src_cd}_${track.target_id}`;
|
|
||||||
|
|
||||||
if (!vesselTracks[vesselId]) {
|
|
||||||
vesselTracks[vesselId] = {
|
|
||||||
vessel_id: vesselId,
|
|
||||||
sig_src_cd: track.sig_src_cd,
|
|
||||||
target_id: track.target_id,
|
|
||||||
tracks: [],
|
|
||||||
total_distance: 0,
|
|
||||||
speeds: [],
|
|
||||||
times: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
vesselTracks[vesselId].tracks.push(track);
|
|
||||||
vesselTracks[vesselId].total_distance += parseFloat(track.distance_nm) || 0;
|
|
||||||
vesselTracks[vesselId].speeds.push(parseFloat(track.avg_speed) || 0);
|
|
||||||
vesselTracks[vesselId].times.push(new Date(track.time_bucket));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 시간순 정렬 및 통계 계산
|
|
||||||
Object.values(vesselTracks).forEach(vessel => {
|
|
||||||
vessel.tracks.sort((a, b) => new Date(a.time_bucket) - new Date(b.time_bucket));
|
|
||||||
vessel.avg_speed = vessel.speeds.reduce((a, b) => a + b, 0) / vessel.speeds.length;
|
|
||||||
vessel.max_speed = Math.max(...vessel.speeds);
|
|
||||||
vessel.min_speed = Math.min(...vessel.speeds);
|
|
||||||
vessel.start_time = vessel.times[0];
|
|
||||||
vessel.end_time = vessel.times[vessel.times.length - 1];
|
|
||||||
});
|
|
||||||
|
|
||||||
return vesselTracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 트랙 포인트 병합
|
|
||||||
* @param {Array} tracks - 트랙 배열
|
|
||||||
* @returns {Array} 병합된 포인트 배열
|
|
||||||
*/
|
|
||||||
export function mergeTrackPoints(tracks) {
|
|
||||||
const allPoints = [];
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const points = parseLineStringM(track.track_geom);
|
|
||||||
allPoints.push(...points);
|
|
||||||
});
|
|
||||||
|
|
||||||
return allPoints;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 거리 포맷팅
|
|
||||||
* @param {number} distance - 거리 (nm)
|
|
||||||
* @param {number} decimals - 소수점 자리수
|
|
||||||
* @returns {string} 포맷된 거리
|
|
||||||
*/
|
|
||||||
export function formatDistance(distance, decimals = 2) {
|
|
||||||
if (distance === null || distance === undefined) return '-';
|
|
||||||
return `${distance.toFixed(decimals)} nm`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 속도 포맷팅
|
|
||||||
* @param {number} speed - 속도 (knots)
|
|
||||||
* @param {number} decimals - 소수점 자리수
|
|
||||||
* @returns {string} 포맷된 속도
|
|
||||||
*/
|
|
||||||
export function formatSpeed(speed, decimals = 1) {
|
|
||||||
if (speed === null || speed === undefined) return '-';
|
|
||||||
return `${speed.toFixed(decimals)} kts`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 시간 포맷팅
|
|
||||||
* @param {string|Date} time - 시간
|
|
||||||
* @returns {string} 포맷된 시간
|
|
||||||
*/
|
|
||||||
export function formatTime(time) {
|
|
||||||
if (!time) return '-';
|
|
||||||
const date = new Date(time);
|
|
||||||
return date.toLocaleString('ko-KR', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 위치 추출
|
|
||||||
* @param {Array} tracks - 트랙 배열
|
|
||||||
* @returns {Array} 최신 위치 배열
|
|
||||||
*/
|
|
||||||
export function extractLatestPositions(tracks) {
|
|
||||||
const latestPositions = {};
|
|
||||||
|
|
||||||
tracks.forEach(track => {
|
|
||||||
const vesselId = `${track.sig_src_cd}_${track.target_id}`;
|
|
||||||
const path = parseLineStringM(track.track_geom);
|
|
||||||
|
|
||||||
if (path.length > 0) {
|
|
||||||
const trackTime = new Date(track.time_bucket);
|
|
||||||
|
|
||||||
if (!latestPositions[vesselId] ||
|
|
||||||
trackTime > new Date(latestPositions[vesselId].time)) {
|
|
||||||
latestPositions[vesselId] = {
|
|
||||||
position: path[path.length - 1],
|
|
||||||
vessel_id: vesselId,
|
|
||||||
sig_src_cd: track.sig_src_cd,
|
|
||||||
target_id: track.target_id,
|
|
||||||
time: track.time_bucket,
|
|
||||||
speed: parseFloat(track.avg_speed) || 0,
|
|
||||||
distance: parseFloat(track.distance_nm) || 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(latestPositions);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 구역 ID 정규화
|
|
||||||
* @param {string} zoneId - 구역 ID
|
|
||||||
* @returns {string} 정규화된 ID
|
|
||||||
*/
|
|
||||||
export function normalizeZoneId(zoneId) {
|
|
||||||
if (!zoneId) return '';
|
|
||||||
return String(zoneId).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 선박 ID 파싱
|
|
||||||
* @param {string} vesselId - 선박 ID (형식: sigSrcCd_targetId)
|
|
||||||
* @returns {Object} {sigSrcCd, targetId}
|
|
||||||
*/
|
|
||||||
export function parseVesselId(vesselId) {
|
|
||||||
if (!vesselId) return { sigSrcCd: '', targetId: '' };
|
|
||||||
|
|
||||||
const [sigSrcCd, ...targetIdParts] = vesselId.split('_');
|
|
||||||
const targetId = targetIdParts.join('_'); // targetId에 언더스코어가 포함된 경우 처리
|
|
||||||
|
|
||||||
return { sigSrcCd, targetId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필터 조건 검증
|
|
||||||
* @param {Object} track - 트랙 데이터
|
|
||||||
* @param {Object} filters - 필터 조건
|
|
||||||
* @returns {boolean} 필터 통과 여부
|
|
||||||
*/
|
|
||||||
export function passesFilters(track, filters) {
|
|
||||||
const { minSpeed = 0, maxSpeed = Infinity, minDist = 0, maxDist = Infinity } = filters;
|
|
||||||
|
|
||||||
const speed = track.avg_speed || 0;
|
|
||||||
const distance = track.distance || 0;
|
|
||||||
|
|
||||||
return speed >= minSpeed &&
|
|
||||||
speed <= maxSpeed &&
|
|
||||||
distance >= minDist &&
|
|
||||||
distance <= maxDist;
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
/**
|
|
||||||
* 공통 헬퍼 함수
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
ABNORMAL_TYPE_LABELS,
|
|
||||||
ABNORMAL_TYPE_DESCRIPTIONS,
|
|
||||||
ABNORMAL_TYPE_COLORS
|
|
||||||
} from './constants.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜 범위 설정
|
|
||||||
* @param {number} days - 일 수
|
|
||||||
* @returns {Object} 시작일과 종료일
|
|
||||||
*/
|
|
||||||
export function getDateRange(days) {
|
|
||||||
const endDate = new Date();
|
|
||||||
const startDate = new Date();
|
|
||||||
startDate.setDate(endDate.getDate() - days);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate: startDate.toISOString().split('T')[0],
|
|
||||||
endDate: endDate.toISOString().split('T')[0]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜를 시간 차이로 변환
|
|
||||||
* @param {string} startDate - 시작 날짜
|
|
||||||
* @returns {number} 현재로부터의 시간 차이
|
|
||||||
*/
|
|
||||||
export function getHoursFromNow(startDate) {
|
|
||||||
const start = new Date(startDate);
|
|
||||||
const now = new Date();
|
|
||||||
return Math.ceil((now - start) / (1000 * 60 * 60));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 유형 라벨 가져오기
|
|
||||||
* @param {string} type - 비정상 유형
|
|
||||||
* @returns {string} 라벨
|
|
||||||
*/
|
|
||||||
export function getTypeLabel(type) {
|
|
||||||
return ABNORMAL_TYPE_LABELS[type] || type;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 유형 설명 가져오기
|
|
||||||
* @param {string} type - 비정상 유형
|
|
||||||
* @returns {string} 설명
|
|
||||||
*/
|
|
||||||
export function getTypeDescription(type) {
|
|
||||||
return ABNORMAL_TYPE_DESCRIPTIONS[type] || '비정상 궤적';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 비정상 유형별 색상 가져오기
|
|
||||||
* @param {string} type - 비정상 유형
|
|
||||||
* @returns {Array} RGB 색상 배열
|
|
||||||
*/
|
|
||||||
export function getColorByType(type) {
|
|
||||||
return [...(ABNORMAL_TYPE_COLORS[type] || [128, 128, 128]), 200];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CSS 선택자용 ID 이스케이프
|
|
||||||
* @param {string} id - 이스케이프할 ID
|
|
||||||
* @returns {string} 이스케이프된 ID
|
|
||||||
*/
|
|
||||||
export function escapeSelector(id) {
|
|
||||||
return id.replace(/:/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 조회 기간 레이블 생성
|
|
||||||
* @param {string} startDate - 시작 날짜
|
|
||||||
* @param {string} endDate - 종료 날짜
|
|
||||||
* @returns {string} 기간 레이블
|
|
||||||
*/
|
|
||||||
export function getPeriodLabel(startDate, endDate) {
|
|
||||||
if (!startDate || !endDate) return '전체';
|
|
||||||
|
|
||||||
const start = new Date(startDate);
|
|
||||||
const end = new Date(endDate);
|
|
||||||
const diffDays = Math.ceil((end - start) / (1000 * 60 * 60 * 24)) + 1;
|
|
||||||
|
|
||||||
if (startDate === endDate) {
|
|
||||||
return start.toLocaleDateString('ko-KR');
|
|
||||||
} else {
|
|
||||||
return `${start.toLocaleDateString('ko-KR')} ~ ${end.toLocaleDateString('ko-KR')} (${diffDays}일간)`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 숫자 포맷팅
|
|
||||||
* @param {number} number - 포맷할 숫자
|
|
||||||
* @param {number} decimals - 소수점 자리수
|
|
||||||
* @returns {string} 포맷된 숫자
|
|
||||||
*/
|
|
||||||
export function formatNumber(number, decimals = 0) {
|
|
||||||
if (number === null || number === undefined) return '-';
|
|
||||||
return Number(number).toFixed(decimals);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 거리 포맷팅
|
|
||||||
* @param {number} distance - 거리 (nm)
|
|
||||||
* @returns {string} 포맷된 거리
|
|
||||||
*/
|
|
||||||
export function formatDistance(distance) {
|
|
||||||
if (!distance) return '-';
|
|
||||||
return `${formatNumber(distance, 2)}nm`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 속도 포맷팅
|
|
||||||
* @param {number} speed - 속도 (knots)
|
|
||||||
* @returns {string} 포맷된 속도
|
|
||||||
*/
|
|
||||||
export function formatSpeed(speed) {
|
|
||||||
if (!speed) return '-';
|
|
||||||
return `${formatNumber(speed, 1)}kts`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 날짜/시간 포맷팅
|
|
||||||
* @param {string|Date} date - 날짜
|
|
||||||
* @returns {string} 포맷된 날짜/시간
|
|
||||||
*/
|
|
||||||
export function formatDateTime(date) {
|
|
||||||
if (!date) return '-';
|
|
||||||
return new Date(date).toLocaleString('ko-KR');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 디바운스 함수
|
|
||||||
* @param {Function} func - 실행할 함수
|
|
||||||
* @param {number} wait - 대기 시간 (ms)
|
|
||||||
* @returns {Function} 디바운스된 함수
|
|
||||||
*/
|
|
||||||
export function debounce(func, wait) {
|
|
||||||
let timeout;
|
|
||||||
return function executedFunction(...args) {
|
|
||||||
const later = () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
func(...args);
|
|
||||||
};
|
|
||||||
clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(later, wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쓰로틀 함수
|
|
||||||
* @param {Function} func - 실행할 함수
|
|
||||||
* @param {number} limit - 제한 시간 (ms)
|
|
||||||
* @returns {Function} 쓰로틀된 함수
|
|
||||||
*/
|
|
||||||
export function throttle(func, limit) {
|
|
||||||
let inThrottle;
|
|
||||||
return function(...args) {
|
|
||||||
if (!inThrottle) {
|
|
||||||
func.apply(this, args);
|
|
||||||
inThrottle = true;
|
|
||||||
setTimeout(() => inThrottle = false, limit);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 깊은 복사
|
|
||||||
* @param {*} obj - 복사할 객체
|
|
||||||
* @returns {*} 복사된 객체
|
|
||||||
*/
|
|
||||||
export function deepClone(obj) {
|
|
||||||
if (obj === null || typeof obj !== 'object') return obj;
|
|
||||||
if (obj instanceof Date) return new Date(obj.getTime());
|
|
||||||
if (obj instanceof Array) return obj.map(item => deepClone(item));
|
|
||||||
if (obj instanceof Object) {
|
|
||||||
const cloned = {};
|
|
||||||
for (const key in obj) {
|
|
||||||
if (obj.hasOwnProperty(key)) {
|
|
||||||
cloned[key] = deepClone(obj[key]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* URL 쿼리 파라미터 파싱
|
|
||||||
* @param {string} url - URL
|
|
||||||
* @returns {Object} 파라미터 객체
|
|
||||||
*/
|
|
||||||
export function parseQueryParams(url = window.location.search) {
|
|
||||||
const params = new URLSearchParams(url);
|
|
||||||
const result = {};
|
|
||||||
for (const [key, value] of params) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 객체를 쿼리 스트링으로 변환
|
|
||||||
* @param {Object} params - 파라미터 객체
|
|
||||||
* @returns {string} 쿼리 스트링
|
|
||||||
*/
|
|
||||||
export function objectToQueryString(params) {
|
|
||||||
return Object.keys(params)
|
|
||||||
.filter(key => params[key] !== null && params[key] !== undefined)
|
|
||||||
.map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
|
|
||||||
.join('&');
|
|
||||||
}
|
|
||||||
@ -1,197 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>비정상 궤적 모니터링</title>
|
|
||||||
|
|
||||||
<!-- 외부 라이브러리 CSS (폐쇄망 대응) -->
|
|
||||||
<link href="/libs/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/libs/css/maplibre-gl.css">
|
|
||||||
|
|
||||||
<!-- 커스텀 CSS -->
|
|
||||||
<link href="/v2/css/common.css" rel="stylesheet">
|
|
||||||
<link href="/v2/css/abnormal-tracks.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="main-container" id="mainContainer">
|
|
||||||
<!-- 상단 헤더 -->
|
|
||||||
<div class="header-panel">
|
|
||||||
<h4 class="mb-0">비정상 궤적 모니터링 v2.0</h4>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="main-content">
|
|
||||||
<!-- 왼쪽 패널: 필터 및 선박 목록 -->
|
|
||||||
<div class="left-panel">
|
|
||||||
<!-- 필터 섹션 -->
|
|
||||||
<div class="filter-section" id="filterSection">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label mb-2">조회 기간</label>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small text-muted">시작일</label>
|
|
||||||
<input type="date" class="form-control form-control-sm" id="startDateInput">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small text-muted">종료일</label>
|
|
||||||
<input type="date" class="form-control form-control-sm" id="endDateInput">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<div class="btn-group btn-group-sm w-100" role="group">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-days="1">1일</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-days="3">3일</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-days="7">7일</button>
|
|
||||||
<button type="button" class="btn btn-outline-secondary" data-days="30">30일</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label mb-2">비정상 유형</label>
|
|
||||||
<select class="form-select form-select-sm" id="typeSelect">
|
|
||||||
<option value="">전체</option>
|
|
||||||
<option value="extreme_speed">극단적 비정상 속도</option>
|
|
||||||
<option value="extreme_distance">5분간 극단적 이동</option>
|
|
||||||
<option value="extreme_transition">극단적 bucket 전환</option>
|
|
||||||
<option value="extreme_avg_speed_5min">극단적 평균속도 (5분)</option>
|
|
||||||
<option value="impossible_transition">물리적 불가능 전환</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label mb-2">선박 ID</label>
|
|
||||||
<input type="text" class="form-control form-control-sm" id="vesselIdInput"
|
|
||||||
placeholder="예: 000001:123456789">
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary btn-sm w-100" id="loadTracksBtn">
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 사용자 정의 검출 패널 -->
|
|
||||||
<div class="custom-detection-panel">
|
|
||||||
<h6 class="mb-3">사용자 정의 비정상 궤적 검출</h6>
|
|
||||||
<div class="text-end mb-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="backToAutoBtn" style="display: none;">
|
|
||||||
<i class="bi bi-arrow-left"></i> 자동 검출 모드로
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">테이블</label>
|
|
||||||
<select class="form-select form-select-sm" id="tableTypeSelect">
|
|
||||||
<option value="hourly">Hourly</option>
|
|
||||||
<option value="daily">Daily</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">기간</label>
|
|
||||||
<select class="form-select form-select-sm" id="customPeriodSelect">
|
|
||||||
<option value="1">최근 1일</option>
|
|
||||||
<option value="3">최근 3일</option>
|
|
||||||
<option value="7">최근 7일</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2 mt-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">최소 거리 (nm)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minDistanceInput"
|
|
||||||
value="0" min="0" step="10">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label">최소 속도 (kts)</label>
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minSpeedInput"
|
|
||||||
value="0" min="0" step="10">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-success btn-sm w-100 mt-3" id="detectCustomBtn">
|
|
||||||
<i class="bi bi-search"></i> 검출하기
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 정렬 및 액션 옵션 -->
|
|
||||||
<div class="sort-section">
|
|
||||||
<div class="d-flex gap-2 mb-2">
|
|
||||||
<button class="btn btn-outline-secondary btn-sm flex-fill" id="showAllBtn">
|
|
||||||
전체 보기
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-outline-secondary btn-sm flex-fill" id="clearSelectionBtn">
|
|
||||||
선택 해제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label class="form-label mb-2">정렬 기준</label>
|
|
||||||
<select class="form-select form-select-sm" id="sortSelect">
|
|
||||||
<option value="count">궤적 수 많은 순</option>
|
|
||||||
<option value="distance">총거리 긴 순</option>
|
|
||||||
<option value="speed">평균속도 빠른 순</option>
|
|
||||||
<option value="recent">최근 검출 순</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 액션 버튼 (사용자 정의 검출 모드에서만 표시) -->
|
|
||||||
<div class="action-buttons" id="actionButtons" style="display: none;">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="selectAllBtn">
|
|
||||||
<i class="bi bi-check-square"></i> 전체 선택
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" id="deselectAllBtn">
|
|
||||||
<i class="bi bi-square"></i> 전체 해제
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-danger" id="moveToAbnormalBtn" disabled>
|
|
||||||
<i class="bi bi-exclamation-triangle"></i> 비정상 이동 (<span id="selectedCount">0</span>)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 선박 목록 -->
|
|
||||||
<div class="vessel-list" id="vesselList">
|
|
||||||
<div class="loading">
|
|
||||||
<div class="spinner-border text-primary" role="status">
|
|
||||||
<span class="visually-hidden">Loading...</span>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">준비 중...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 오른쪽 패널: 지도 -->
|
|
||||||
<div class="right-panel">
|
|
||||||
<div id="map"></div>
|
|
||||||
|
|
||||||
<!-- 통계 카드 -->
|
|
||||||
<div class="stat-cards">
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">전체 비정상 궤적</div>
|
|
||||||
<div class="stat-number" id="totalCount">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">영향받은 선박</div>
|
|
||||||
<div class="stat-number" id="vesselCount">0</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">평균 이동거리</div>
|
|
||||||
<div class="stat-number" id="avgDistance">0</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 범례 패널 -->
|
|
||||||
<div class="legend-panel" id="legendPanel">
|
|
||||||
<div class="legend-title" id="legendTitle">비정상 유형별 현황</div>
|
|
||||||
<div id="legendContent">
|
|
||||||
<!-- 동적으로 생성됨 -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 툴팁 -->
|
|
||||||
<div class="tooltip-popup" id="tooltip" style="display: none;"></div>
|
|
||||||
|
|
||||||
<!-- 외부 라이브러리 JS (폐쇄망 대응) -->
|
|
||||||
<script src="/libs/js/bootstrap.bundle.min.js"></script>
|
|
||||||
<script src="/libs/js/maplibre-gl.js"></script>
|
|
||||||
<script src="/libs/js/deck.gl.min.js"></script>
|
|
||||||
|
|
||||||
<!-- 메인 애플리케이션 스크립트 -->
|
|
||||||
<script type="module" src="/v2/js/pages/abnormal-tracks-app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@ -1,252 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>청크 GIS 스트리밍 v2.0 - 선박 애니메이션</title>
|
|
||||||
|
|
||||||
<!-- 외부 라이브러리 CSS (폐쇄망 대응) -->
|
|
||||||
<link href="/libs/css/maplibre-gl.css" rel="stylesheet">
|
|
||||||
<link href="/libs/css/bootstrap.min.css" rel="stylesheet">
|
|
||||||
<link href="/libs/css/bootstrap-icons.css" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- 커스텀 CSS -->
|
|
||||||
<link href="/v2/css/common.css" rel="stylesheet">
|
|
||||||
<link href="/v2/css/chunked-streaming.css" rel="stylesheet">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="mapContainer"></div>
|
|
||||||
|
|
||||||
<!-- 제어 패널 -->
|
|
||||||
<div class="control-panel">
|
|
||||||
<h5 class="mb-3">
|
|
||||||
<i class="bi bi-globe2"></i> 청크 GIS v2.0 + 애니메이션
|
|
||||||
</h5>
|
|
||||||
|
|
||||||
<!-- 연결 제어 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="btn-group w-100" role="group">
|
|
||||||
<button id="connectBtn" class="btn btn-sm btn-success" onclick="connect()">
|
|
||||||
<i class="bi bi-plug"></i> 연결
|
|
||||||
</button>
|
|
||||||
<button id="disconnectBtn" class="btn btn-sm btn-danger" onclick="disconnect()" disabled>
|
|
||||||
<i class="bi bi-plug-fill"></i> 연결 해제
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 시간 설정 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">시작 시간</label>
|
|
||||||
<input type="datetime-local" class="form-control form-control-sm" id="startTime">
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<label class="form-label small">종료 시간</label>
|
|
||||||
<input type="datetime-local" class="form-control form-control-sm" id="endTime">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeRange(1)">1시간</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeRange(6)">6시간</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeRange(24)">1일</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeRange(72)">3일</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="setTimeRange(168)">7일</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 영역 설정 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<label class="form-label small mb-0">현재 뷰포트</label>
|
|
||||||
<span class="badge bg-primary" id="currentZoom">Zoom: 6</span>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minLon" value="124" placeholder="Min Lon" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="maxLon" value="132" placeholder="Max Lon" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row g-2 mt-1">
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="minLat" value="33" placeholder="Min Lat" readonly>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<input type="number" class="form-control form-control-sm" id="maxLat" value="38" placeholder="Max Lat" readonly>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="small text-muted mt-1">
|
|
||||||
<i class="bi bi-info-circle"></i> 현재 맵 뷰포트와 줌 레벨을 사용합니다
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 렌더링 옵션 -->
|
|
||||||
<div class="form-section">
|
|
||||||
<h6 class="mb-2">렌더링 옵션</h6>
|
|
||||||
<div class="render-options">
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="showTracks" checked>
|
|
||||||
<label class="form-check-label small" for="showTracks">궤적 표시</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="showVessels" checked>
|
|
||||||
<label class="form-check-label small" for="showVessels">선박 아이콘 표시</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check">
|
|
||||||
<input class="form-check-input" type="checkbox" id="colorBySpeed" checked>
|
|
||||||
<label class="form-check-label small" for="colorBySpeed">속도별 색상</label>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<label class="form-label small">궤적 투명도</label>
|
|
||||||
<input type="range" class="form-range" id="trackOpacity" min="0" max="100" value="60">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 쿼리 실행 -->
|
|
||||||
<button id="startBtn" class="btn btn-primary w-100" onclick="startQuery()" disabled>
|
|
||||||
<i class="bi bi-play-circle"></i> 스트리밍 시작
|
|
||||||
</button>
|
|
||||||
<button id="cancelBtn" class="btn btn-warning w-100 mt-2" onclick="cancelQuery()" style="display: none;">
|
|
||||||
<i class="bi bi-stop-circle"></i> 취소
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 진행률 -->
|
|
||||||
<div class="progress mt-3" style="display: none;" id="progressContainer">
|
|
||||||
<div class="progress-bar progress-bar-striped progress-bar-animated"
|
|
||||||
id="progressBar" style="width: 0%">0%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 통계 패널 -->
|
|
||||||
<div class="stats-panel">
|
|
||||||
<h6 class="mb-3"><i class="bi bi-speedometer2"></i> 실시간 통계</h6>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">청크 수신</div>
|
|
||||||
<div class="stat-value" id="chunksReceived">0</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">선박 수</div>
|
|
||||||
<div class="stat-value" id="vesselCount">0</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">포인트 수</div>
|
|
||||||
<div class="stat-value" id="pointCount">0</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">데이터 크기</div>
|
|
||||||
<div class="stat-value" id="dataSize">0</div>
|
|
||||||
<div class="stat-label">KB</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-label">처리 시간</div>
|
|
||||||
<div class="stat-value" id="processTime">0</div>
|
|
||||||
<div class="stat-label">초</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 타임라인 패널 -->
|
|
||||||
<div class="timeline-panel" style="display: none;" id="timelinePanel">
|
|
||||||
<button class="btn btn-sm btn-close position-absolute" style="top: 10px; right: 10px;" onclick="resetAll()" title="닫기"></button>
|
|
||||||
<div class="timeline-controls">
|
|
||||||
<button class="btn btn-sm btn-primary" id="playBtn" onclick="togglePlay()">
|
|
||||||
<i class="bi bi-play-fill"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="speed-control">
|
|
||||||
<label class="small mb-0">속도:</label>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(0.5)">0.5x</button>
|
|
||||||
<button class="btn btn-sm btn-secondary btn-speed" onclick="setSpeed(1)">1x</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(2)">2x</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(5)">5x</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(50)">50x</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(100)">100x</button>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary btn-speed" onclick="setSpeed(500)">500x</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="time-display">
|
|
||||||
<div id="currentDateDisplay" style="font-size: 12px; color: #6c757d;">----/--/--</div>
|
|
||||||
<div id="currentTimeDisplay">--:--:--</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="range" class="form-range timeline-slider"
|
|
||||||
id="timeSlider" min="0" max="100" value="0">
|
|
||||||
|
|
||||||
<div class="d-flex justify-content-between small text-muted">
|
|
||||||
<span id="startTimeDisplay">--:--</span>
|
|
||||||
<span id="endTimeDisplay">--:--</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 범례 -->
|
|
||||||
<div class="legend" style="display: none;" id="legend">
|
|
||||||
<h6 class="mb-2">속도 범례</h6>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #1e90ff;"></div>
|
|
||||||
<span>0-5 knots</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #00ff00;"></div>
|
|
||||||
<span>5-10 knots</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #ffff00;"></div>
|
|
||||||
<span>10-15 knots</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #ff8c00;"></div>
|
|
||||||
<span>15-20 knots</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-color" style="background: #ff0000;"></div>
|
|
||||||
<span>20+ knots</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hr class="my-2">
|
|
||||||
<h6 class="mb-2">선박 아이콘</h6>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="vessel-icon" style="background: #00ff00;"></div>
|
|
||||||
<span>이동중</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="vessel-icon" style="background: #ffff00;"></div>
|
|
||||||
<span>저속</span>
|
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
|
||||||
<div class="vessel-icon" style="background: #ff0000;"></div>
|
|
||||||
<span>정지</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 로그 패널 -->
|
|
||||||
<div class="log-panel">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<h6 class="mb-0"><i class="bi bi-terminal"></i> 로그</h6>
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" onclick="clearLog()">초기화</button>
|
|
||||||
</div>
|
|
||||||
<div id="logContainer"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 툴팁 -->
|
|
||||||
<div id="vesselTooltip"></div>
|
|
||||||
|
|
||||||
<!-- 외부 라이브러리 JS (폐쇄망 대응) -->
|
|
||||||
<script src="/libs/js/sockjs.min.js"></script>
|
|
||||||
<script src="/libs/js/stomp.min.js"></script>
|
|
||||||
<script src="/libs/js/maplibre-gl.js"></script>
|
|
||||||
<script src="/libs/js/deck.gl.min.js"></script>
|
|
||||||
|
|
||||||
<!-- 메인 애플리케이션 스크립트 -->
|
|
||||||
<script type="module" src="/v2/js/pages/chunked-streaming-app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
불러오는 중...
Reference in New Issue
Block a user