374 lines
16 KiB
JavaScript
374 lines
16 KiB
JavaScript
// 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');
|
||
});
|