Files
LisCastle/ui.js
2026-04-16 15:26:59 +03:00

374 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 = '<p class="empty-state">Папки еще не созданы</p>';
return;
}
this.foldersList.innerHTML = this.folders.map(folder => `
<div class="folder-item" title="${folder.path}">
<div class="folder-icon">📁</div>
<div class="folder-name">${folder.name}</div>
</div>
`).join('');
}
async startCapture() {
try {
this.collectSettings();
this.updateStatus('Захват изображений...', 'working');
this.log('Запуск захвата...', 'info');
const response = await fetch('/api/start-capture', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(this.settings)
});
const result = await response.json();
if (result.success) {
this.log(`Захват запущен. URL: ${result.url}`, 'success');
this.updateStatus('Захват запущен', '');
window.open(result.url, '_blank', 'width=900,height=900');
} else {
throw new Error(result.error);
}
} catch (error) {
this.log(`Ошибка запуска захвата: ${error.message}`, 'error');
this.updateStatus('Ошибка', 'error');
}
}
}
document.addEventListener('DOMContentLoaded', () => {
window.sandCastleUI = new SandCastleUI();
console.log('SandCastle UI initialized');
});