// UI Controller for SandCastle class SandCastleUI { constructor() { this.settings = { dataset: 'Country', country: 'German', city: 'Hannover', region: 'Custom', point1: '52°25\'10.28"N 9°39\'10.04"E', point2: '52°25\'07.34"N 9°39\'23.65"E', tile_m: 49, grid_step: 1, height_100: true, height_125: true, height_150: true, height_100_value: 100, height_125_value: 125, height_150_value: 150, height_absolute: true, db_crop_sizes: "100,150,200,250,300,400,500,512,600,800,1000", drone_size: 512, query_fov_deg: 30, query_pitch_deg: -90, preload: true, skip_empty: false }; this.METERS_PER_DEG_LAT = 111320; this.DEFAULT_DB_CROP_SIZES = '100,150,200,250,300,400,500,512,600,800,1000'; this.DMS_VALUE_REGEX = /^\s*(\d+)\s*(?:°|В°)\s*(\d+)\s*'\s*([\d.]+)\s*"?\s*([NSEW])\s*$/i; this.DMS_TOKEN_REGEX = /\d+\s*(?:°|В°)\s*\d+\s*'\s*[\d.]+\s*"?\s*[NSEW]/gi; this.folders = []; this.initializeElements(); this.attachEventListeners(); this.loadSettingsFromServer(); this.updateInfo(); } initializeElements() { this.datasetInput = document.getElementById('dataset'); this.countryInput = document.getElementById('country'); this.cityInput = document.getElementById('city'); this.regionInput = document.getElementById('region'); this.point1Input = document.getElementById('point1'); this.point2Input = document.getElementById('point2'); this.tileMInput = document.getElementById('tile_m'); this.gridStepInput = document.getElementById('grid_step'); this.dbCropSizesInput = document.getElementById('db_crop_sizes'); this.droneSizeInput = document.getElementById('drone_size'); this.queryFovInput = document.getElementById('query_fov_deg'); this.queryPitchInput = document.getElementById('query_pitch_deg'); this.height100Input = document.getElementById('height_100'); this.height125Input = document.getElementById('height_125'); this.height150Input = document.getElementById('height_150'); this.height100ValueInput = document.getElementById('height_100_value'); this.height125ValueInput = document.getElementById('height_125_value'); this.height150ValueInput = document.getElementById('height_150_value'); this.heightAbsoluteInput = document.getElementById('height_absolute'); this.preloadInput = document.getElementById('preload'); this.skipEmptyInput = document.getElementById('skip_empty'); this.fillDbSizesBtn = document.getElementById('fillDbSizesBtn'); this.saveBtn = document.getElementById('saveBtn'); this.createFoldersBtn = document.getElementById('createFoldersBtn'); this.startCaptureBtn = document.getElementById('startCaptureBtn'); this.currentCoords = document.getElementById('currentCoords'); this.currentGrid = document.getElementById('currentGrid'); this.totalPoints = document.getElementById('totalPoints'); this.status = document.getElementById('status'); this.logOutput = document.getElementById('logOutput'); this.foldersList = document.getElementById('foldersList'); } attachEventListeners() { this.saveBtn.addEventListener('click', () => this.saveSettings()); this.createFoldersBtn.addEventListener('click', () => this.createFolders()); this.startCaptureBtn.addEventListener('click', () => this.startCapture()); this.fillDbSizesBtn.addEventListener('click', () => { this.dbCropSizesInput.value = this.DEFAULT_DB_CROP_SIZES; this.updateInfo(); this.log('Подставлены стандартные 11 размеров DB', 'info'); }); [ this.point1Input, this.point2Input, this.tileMInput, this.gridStepInput, this.height100Input, this.height125Input, this.height150Input, this.height100ValueInput, this.height125ValueInput, this.height150ValueInput, this.dbCropSizesInput, this.queryFovInput, this.queryPitchInput ].forEach((input) => { const event = input.type === 'checkbox' ? 'change' : 'input'; input.addEventListener(event, () => this.updateInfo()); }); } log(message, type = 'info') { const entry = document.createElement('div'); entry.className = `log-entry ${type}`; const timestamp = new Date().toLocaleTimeString('ru-RU'); entry.textContent = `[${timestamp}] ${message}`; this.logOutput.appendChild(entry); this.logOutput.scrollTop = this.logOutput.scrollHeight; } updateStatus(text, className = '') { this.status.textContent = text; this.status.className = 'info-value status ' + className; } dmsToDecimal(dms) { const match = String(dms).trim().match(this.DMS_VALUE_REGEX); if (!match) { throw new Error(`Неверный формат DMS: ${dms}`); } const degrees = Number.parseInt(match[1], 10); const minutes = Number.parseInt(match[2], 10); const seconds = Number.parseFloat(match[3]); const direction = match[4].toUpperCase(); let decimal = degrees + minutes / 60 + seconds / 3600; if (direction === 'S' || direction === 'W') { decimal = -decimal; } return decimal; } parseCoordinatePair(value) { const tokens = String(value || '').match(this.DMS_TOKEN_REGEX) || []; if (tokens.length !== 2) { throw new Error('Ожидалась пара DMS'); } const coords = tokens.map((t) => ({ value: this.dmsToDecimal(t), direction: t.trim().slice(-1).toUpperCase() })); const lat = coords.find((c) => c.direction === 'N' || c.direction === 'S'); const lon = coords.find((c) => c.direction === 'E' || c.direction === 'W'); if (!lat || !lon) { throw new Error('Нужны широта и долгота'); } return { lat: lat.value, lon: lon.value }; } updateInfo() { const step = Math.max(1, Number.parseInt(this.gridStepInput.value, 10) || 1); const tileMeters = Math.max(1, Number(this.tileMInput.value) || 1); const heightCount = (this.height100Input.checked ? 1 : 0) + (this.height125Input.checked ? 1 : 0) + (this.height150Input.checked ? 1 : 0); try { const p1 = this.parseCoordinatePair(this.point1Input.value); const p2 = this.parseCoordinatePair(this.point2Input.value); const minLat = Math.min(p1.lat, p2.lat); const maxLat = Math.max(p1.lat, p2.lat); const minLon = Math.min(p1.lon, p2.lon); const maxLon = Math.max(p1.lon, p2.lon); const latSpanM = (maxLat - minLat) * this.METERS_PER_DEG_LAT; const lonSpanM = (maxLon - minLon) * this.METERS_PER_DEG_LAT * Math.cos((maxLat * Math.PI) / 180); const cols = Math.max(1, Math.floor(lonSpanM / tileMeters) + 1); const rows = Math.max(1, Math.floor(latSpanM / tileMeters) + 1); const effectiveCols = Math.ceil(cols / step); const effectiveRows = Math.ceil(rows / step); const tiles = effectiveCols * effectiveRows; this.currentGrid.textContent = `${effectiveCols}x${effectiveRows}`; this.totalPoints.textContent = heightCount > 0 ? `${tiles} × ${heightCount}в = ${tiles * heightCount}` : `${tiles} (нет высот)`; this.currentCoords.textContent = `${Math.round(lonSpanM)}×${Math.round(latSpanM)}м`; } catch (error) { this.currentGrid.textContent = '—'; this.totalPoints.textContent = '—'; } } collectSettings() { const parsedDbSizes = this.parseDbCropSizes(this.dbCropSizesInput.value); if (parsedDbSizes.length !== 11) { throw new Error('DB crop sizes: укажите ровно 11 уникальных размеров'); } this.settings = { dataset: this.datasetInput.value.trim() || 'Country', country: this.countryInput.value.trim() || 'Custom', city: this.cityInput.value.trim() || 'Scene', region: this.regionInput.value.trim() || 'Region', point1: this.point1Input.value.trim(), point2: this.point2Input.value.trim(), tile_m: Math.max(1, Number(this.tileMInput.value) || 1), grid_step: Math.max(1, Number.parseInt(this.gridStepInput.value, 10) || 1), height_100: this.height100Input.checked, height_125: this.height125Input.checked, height_150: this.height150Input.checked, height_100_value: Math.max(1, Number(this.height100ValueInput.value) || 100), height_125_value: Math.max(1, Number(this.height125ValueInput.value) || 125), height_150_value: Math.max(1, Number(this.height150ValueInput.value) || 150), height_absolute: this.heightAbsoluteInput.checked, db_crop_sizes: parsedDbSizes.join(','), drone_size: Math.max(1, Number.parseInt(this.droneSizeInput.value, 10) || 512), query_fov_deg: Math.max(1, Math.min(179, Number(this.queryFovInput.value) || 30)), query_pitch_deg: -90, preload: this.preloadInput.checked, skip_empty: this.skipEmptyInput.checked }; return this.settings; } parseDbCropSizes(raw) { return Array.from(new Set( String(raw || '') .split(',') .map((v) => Number.parseInt(v.trim(), 10)) .filter((v) => Number.isInteger(v) && v > 0) )); } async loadSettingsFromServer() { try { const response = await fetch('/api/get-settings'); const result = await response.json(); if (!result.success || !result.settings) { return; } const settings = result.settings; this.datasetInput.value = settings.dataset ?? this.datasetInput.value; this.countryInput.value = settings.country ?? this.countryInput.value; this.cityInput.value = settings.city ?? this.cityInput.value; this.regionInput.value = settings.region ?? this.regionInput.value; this.point1Input.value = settings.point1 ?? this.point1Input.value; this.point2Input.value = settings.point2 ?? this.point2Input.value; this.tileMInput.value = settings.tile_m ?? this.tileMInput.value; this.gridStepInput.value = settings.grid_step ?? this.gridStepInput.value; this.dbCropSizesInput.value = settings.db_crop_sizes ?? this.dbCropSizesInput.value; this.droneSizeInput.value = settings.drone_size ?? this.droneSizeInput.value; this.queryFovInput.value = settings.query_fov_deg ?? this.queryFovInput.value; this.queryPitchInput.value = -90; this.height100Input.checked = settings.height_100 !== undefined ? !!settings.height_100 : true; this.height125Input.checked = settings.height_125 !== undefined ? !!settings.height_125 : true; this.height150Input.checked = settings.height_150 !== undefined ? !!settings.height_150 : true; this.height100ValueInput.value = settings.height_100_value ?? this.height100ValueInput.value; this.height125ValueInput.value = settings.height_125_value ?? this.height125ValueInput.value; this.height150ValueInput.value = settings.height_150_value ?? this.height150ValueInput.value; this.heightAbsoluteInput.checked = settings.height_absolute !== undefined ? !!settings.height_absolute : true; this.preloadInput.checked = settings.preload !== undefined ? !!settings.preload : true; this.skipEmptyInput.checked = !!settings.skip_empty; this.collectSettings(); this.updateInfo(); this.log('Настройки загружены', 'info'); } catch (error) { this.log('Не удалось загрузить сохраненные настройки', 'error'); } } async saveSettings() { try { this.collectSettings(); this.updateInfo(); const response = await fetch('/api/save-settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.settings) }); const result = await response.json(); if (result.success) { this.log('Настройки сохранены', 'success'); this.updateStatus('Настройки сохранены', ''); } else { throw new Error(result.error); } } catch (error) { this.log(`Ошибка сохранения: ${error.message}`, 'error'); this.updateStatus('Ошибка', 'error'); } } async createFolders() { try { this.collectSettings(); this.updateStatus('Создание папок...', 'working'); this.log('Создание папок...', 'info'); const response = await fetch('/api/create-folders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.settings) }); const result = await response.json(); if (result.success) { this.folders = result.folders; this.renderFolders(); if (result.gridRows && result.gridCols) { this.log(`Сетка: ${result.gridCols}x${result.gridRows} (cols×rows)`, 'info'); } this.log(`Создано папок: ${result.folders.length}`, 'success'); this.updateStatus('Папки созданы', ''); } else { throw new Error(result.error); } } catch (error) { this.log(`Ошибка создания папок: ${error.message}`, 'error'); this.updateStatus('Ошибка', 'error'); } } renderFolders() { if (this.folders.length === 0) { this.foldersList.innerHTML = '
Папки еще не созданы
'; return; } this.foldersList.innerHTML = this.folders.map(folder => `