542 lines
16 KiB
JavaScript
542 lines
16 KiB
JavaScript
/**
|
|
* 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; |