diff --git a/server/static/app.js b/server/static/app.js
index fd21922..1c96fc3 100644
--- a/server/static/app.js
+++ b/server/static/app.js
@@ -1705,9 +1705,10 @@ function apiDebugReadForm() {
txHeight: apiDebugNumber('apiDebugTxH', 30),
rxHeight: apiDebugNumber('apiDebugRxH', 2),
frequency: apiDebugNumber('apiDebugFreq', 433),
- samples: Math.max(2, Math.min(2048, Math.round(apiDebugNumber('apiDebugSamples', 128)))),
- model: document.getElementById('apiDebugModel')?.value || 'manual',
- includeCanopy: !!document.getElementById('apiDebugIncludeCanopy')?.checked
+ samples: Math.max(2, Math.min(2048, Math.round(apiDebugNumber('apiDebugSamples', 128)))),
+ model: document.getElementById('apiDebugModel')?.value || 'manual',
+ coverageModel: document.getElementById('apiDebugCoverageModel')?.value || 'itm',
+ includeCanopy: !!document.getElementById('apiDebugIncludeCanopy')?.checked
};
}
@@ -1800,14 +1801,18 @@ function renderApiDebugGeoJsonLayer(geojson, options = {}) {
const feature = geojson?.type === 'FeatureCollection' ? geojson : (geojson?.geojson || geojson);
if (!feature?.features && !feature?.geometry) return false;
clearApiDebugCoverage();
- apiDebugCoverageLayer = L.geoJSON(feature, {
- style: options.style || {
+ const styleFn = typeof options.style === 'function'
+ ? options.style
+ : () => (options.style || {
color: '#4fc3f7',
weight: 2,
opacity: 0.9,
fillColor: '#4fc3f7',
fillOpacity: 0.12
- }
+ });
+ apiDebugCoverageLayer = L.geoJSON(feature, {
+ style: styleFn,
+ onEachFeature: options.onEachFeature
}).addTo(map);
const bounds = apiDebugCoverageLayer.getBounds();
if (bounds.isValid()) {
@@ -1816,6 +1821,74 @@ function renderApiDebugGeoJsonLayer(geojson, options = {}) {
return true;
}
+const API_DEBUG_COVERAGE_COLORS = {
+ '-90': '#4fc3f7',
+ '-100': '#29b6f6',
+ '-110': '#0288d1',
+ '-120': '#01579b'
+};
+
+function coverageLevelStyle(feature) {
+ const level = feature?.properties?.level_dbm;
+ const color = API_DEBUG_COVERAGE_COLORS[String(level)] || '#4fc3f7';
+ return {
+ color,
+ weight: 2,
+ opacity: 0.95,
+ fillColor: color,
+ fillOpacity: 0.14
+ };
+}
+
+function renderApiDebugCoverageLayer(geojson, model) {
+ const levels = [...new Set((geojson?.features || []).map(f => f.properties?.level_dbm))].sort((a, b) => b - a);
+ const rendered = renderApiDebugGeoJsonLayer(geojson, {
+ style: coverageLevelStyle,
+ onEachFeature: (feature, layer) => {
+ const p = feature.properties || {};
+ const parts = [];
+ if (p.level_dbm != null) parts.push(`${p.level_dbm} dBm`);
+ if (p.model || model) parts.push(p.model || model);
+ if (p.frequency_mhz != null) parts.push(`${p.frequency_mhz} MHz`);
+ if (parts.length) layer.bindPopup(parts.join(' · '));
+ }
+ });
+ return { rendered, levels };
+}
+
+function renderApiDebugViewshedLayer(geojson) {
+ return renderApiDebugGeoJsonLayer(geojson, {
+ style: {
+ color: '#00ff88',
+ weight: 2,
+ opacity: 0.95,
+ fillColor: '#00ff88',
+ fillOpacity: 0.22
+ },
+ onEachFeature: (feature, layer) => {
+ if (feature.properties?.visible != null) {
+ layer.bindPopup('Видимая зона (viewshed)');
+ }
+ }
+ });
+}
+
+async function fetchApiDebugJobArtifact(jobId) {
+ const f = apiDebugReadForm();
+ const url = `${f.base}/api/v1/jobs/${encodeURIComponent(jobId)}/artifact`;
+ apiDebugSetStatus(`GET ${url}`);
+ const res = await fetch(url, { cache: 'no-store' });
+ const text = await res.text();
+ let data = text;
+ try { data = text ? JSON.parse(text) : null; } catch (e) {}
+ if (!res.ok) {
+ const err = new Error(`HTTP ${res.status}`);
+ err.response = data || res.statusText;
+ throw err;
+ }
+ return data;
+}
+
function renderApiDebugBeam(data) {
const feature = data?.geojson || data;
if (!feature?.geometry) {
@@ -2208,7 +2281,7 @@ function buildApiDebugRequest(kind) {
gain_dbi: 8
},
rx: { height_agl: f.rxHeight, sensitivity_dbm: -110, gain_dbi: 2 },
- model: 'fspl',
+ model: f.coverageModel,
environment: 'urban',
radius_m: 12000,
azimuth_step_deg: 2,
@@ -2304,22 +2377,42 @@ async function runApiDebugDemo(kind) {
}
if (kind === 'coverage') {
const geojson = job.result?.data;
+ const model = job.result?.model || apiDebugReadForm().coverageModel;
+ const { rendered, levels } = renderApiDebugCoverageLayer(geojson, model);
const features = geojson?.features?.length ?? 0;
- if (renderApiDebugGeoJsonLayer(geojson)) {
+ const fsplHint = model === 'fspl'
+ ? '
FSPL + omni = идеальный круг (рельеф не учитывается). Для формы по местности — ITM.'
+ : '';
+ if (rendered) {
apiDebugSetSummary(
- `Coverage job: ${escapeHtml(jobId)} · model ${escapeHtml(job.result?.model || '—')} · ` +
- `${features} контуров на карте (голубая заливка).`
+ `Coverage job: ${escapeHtml(jobId)} · model ${escapeHtml(model)} · ` +
+ `${features} контур(ов): ${levels.map(l => `${l} dBm`).join(', ') || '—'}${fsplHint}`
);
} else {
apiDebugSetSummary(`Coverage job ${escapeHtml(jobId)} завершён, GeoJSON не получен.`);
}
} else {
const meta = job.result?.metadata || {};
+ let viewshedRendered = false;
+ if (meta.format === 'geojson' && job.result?.uri) {
+ try {
+ apiDebugSetStatus(`Job ${jobId}: загрузка GeoJSON…`);
+ const geojson = await fetchApiDebugJobArtifact(jobId);
+ apiDebugSetOutput({ job, artifact: geojson });
+ viewshedRendered = renderApiDebugViewshedLayer(geojson);
+ } catch (e) {
+ apiDebugSetOutput({ job, artifact_error: e.response || String(e) });
+ }
+ }
apiDebugSetSummary(
`Viewshed job: ${escapeHtml(jobId)} · ` +
`format ${escapeHtml(meta.format || '—')} · radius ${fmtNum(meta.radius_m, 0, ' m')} · ` +
- `features ${meta.feature_count ?? '—'}
` +
- `Файл: ${escapeHtml(job.result?.uri || '—')}`
+ `features ${meta.feature_count ?? '—'}` +
+ (viewshedRendered ? '
Зелёная заливка на карте — видимая зона.' : '') +
+ `
Файл: ${escapeHtml(job.result?.uri || '—')}` +
+ (!viewshedRendered && meta.format === 'geojson'
+ ? '
GeoJSON на сервере — нужен endpoint /jobs/{id}/artifact (обновите RadioApi).'
+ : '')
);
}
return;
diff --git a/server/static/index.html b/server/static/index.html
index b1cb397..d313763 100644
--- a/server/static/index.html
+++ b/server/static/index.html
@@ -78,7 +78,16 @@
/api/v1/jobs/{id} до статуса done/error./api/v1/jobs/{id} и подгружает GeoJSON артефакт. Coverage с FSPL даёт круг (без рельефа) — для реалистичного контура выберите ITM.