Initial commit: v1.0

This commit is contained in:
Azidaan
2026-04-16 15:26:59 +03:00
commit 5c5404b122
23 changed files with 7564 additions and 0 deletions

373
ui.js Normal file
View File

@@ -0,0 +1,373 @@
// 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');
});