Spaces:
Sleeping
Sleeping
Commit ·
deaa6d8
1
Parent(s): 79d533c
Fix Run Detection: use /api/dda/detect/from-library and deploy compare UI.
Browse filesThe jobs API was not on dev Space (404). Wire compare tab to the existing
sync library detection endpoint and ship compare/result/reports JS + template.
Co-authored-by: Cursor <cursoragent@cursor.com>
- Dockerfile +1 -1
- static/css/dda.css +120 -2
- static/js/dda/compare.js +363 -0
- static/js/dda/reports.js +119 -0
- static/js/dda/result.js +282 -0
- templates/index_dda.html +131 -12
Dockerfile
CHANGED
|
@@ -21,7 +21,7 @@ WORKDIR /app
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
-
ARG APP_BUILD=
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
|
|
|
| 21 |
|
| 22 |
# Build-time info + cache-bust:
|
| 23 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 24 |
+
ARG APP_BUILD=32
|
| 25 |
ENV MAX_GEOTIFF_MB=5120
|
| 26 |
ENV APP_BUILD=${APP_BUILD}
|
| 27 |
ENV GDAL_CONFIG=/usr/bin/gdal-config
|
static/css/dda.css
CHANGED
|
@@ -175,12 +175,130 @@
|
|
| 175 |
.dda-slot {
|
| 176 |
border: 2px dashed var(--border);
|
| 177 |
border-radius: 10px;
|
| 178 |
-
padding:
|
| 179 |
-
min-height:
|
| 180 |
text-align: center;
|
| 181 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
.dda-slot.filled { border-style: solid; border-color: var(--grad-start); }
|
|
|
|
| 183 |
.dda-slot-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
.dda-upload-form .location-row { margin-bottom: 0.5rem; }
|
| 186 |
|
|
|
|
| 175 |
.dda-slot {
|
| 176 |
border: 2px dashed var(--border);
|
| 177 |
border-radius: 10px;
|
| 178 |
+
padding: 1rem;
|
| 179 |
+
min-height: 120px;
|
| 180 |
text-align: center;
|
| 181 |
}
|
| 182 |
+
.dda-image-select {
|
| 183 |
+
width: 100%;
|
| 184 |
+
margin: 0.5rem 0;
|
| 185 |
+
padding: 0.45rem 0.55rem;
|
| 186 |
+
border: 1px solid var(--border);
|
| 187 |
+
border-radius: 6px;
|
| 188 |
+
background: var(--bg);
|
| 189 |
+
color: var(--text);
|
| 190 |
+
font-size: 0.88rem;
|
| 191 |
+
}
|
| 192 |
+
.dda-slot-preview-wrap { min-height: 48px; margin: 0.35rem 0; }
|
| 193 |
+
.dda-slot .dda-slot-pick { margin-top: 0.35rem; }
|
| 194 |
.dda-slot.filled { border-style: solid; border-color: var(--grad-start); }
|
| 195 |
+
.dda-slot.drag-over { border-color: var(--grad-start); background: rgba(45,212,191,0.06); }
|
| 196 |
.dda-slot-label { display: block; font-weight: 600; margin-bottom: 0.5rem; }
|
| 197 |
+
.dda-compare-library { margin: 1rem 0; }
|
| 198 |
+
.dda-compare-assign .btn {
|
| 199 |
+
pointer-events: auto;
|
| 200 |
+
position: relative;
|
| 201 |
+
z-index: 2;
|
| 202 |
+
}
|
| 203 |
+
.dda-compare-grid { min-height: 120px; }
|
| 204 |
+
.dda-compare-card { position: relative; }
|
| 205 |
+
.dda-compare-card.selected-t1 { border-color: #2dd4bf; box-shadow: 0 0 0 2px rgba(45,212,191,0.35); }
|
| 206 |
+
.dda-compare-card.selected-t2 { border-color: #60a5fa; box-shadow: 0 0 0 2px rgba(96,165,250,0.35); }
|
| 207 |
+
.dda-compare-assign {
|
| 208 |
+
display: flex;
|
| 209 |
+
gap: 0.35rem;
|
| 210 |
+
padding: 0.45rem 0.5rem 0.55rem;
|
| 211 |
+
border-top: 1px solid var(--border);
|
| 212 |
+
}
|
| 213 |
+
.dda-compare-assign .btn { flex: 1; font-size: 0.75rem; padding: 0.3rem 0.4rem; }
|
| 214 |
+
|
| 215 |
+
.dda-reports-table {
|
| 216 |
+
width: 100%;
|
| 217 |
+
border-collapse: collapse;
|
| 218 |
+
font-size: 0.85rem;
|
| 219 |
+
margin-top: 0.5rem;
|
| 220 |
+
}
|
| 221 |
+
.dda-reports-table th, .dda-reports-table td {
|
| 222 |
+
border: 1px solid var(--border);
|
| 223 |
+
padding: 0.4rem 0.55rem;
|
| 224 |
+
text-align: left;
|
| 225 |
+
}
|
| 226 |
+
.dda-status { font-size: 0.78rem; font-weight: 600; text-transform: capitalize; }
|
| 227 |
+
.dda-status-completed { color: var(--success, #16a34a); }
|
| 228 |
+
.dda-status-running, .dda-status-queued { color: #ca8a04; }
|
| 229 |
+
.dda-status-failed { color: var(--danger, #dc2626); }
|
| 230 |
+
.dda-slot-preview {
|
| 231 |
+
width: 100%;
|
| 232 |
+
max-height: 160px;
|
| 233 |
+
object-fit: contain;
|
| 234 |
+
border-radius: 6px;
|
| 235 |
+
margin: 0.5rem 0;
|
| 236 |
+
background: var(--bg);
|
| 237 |
+
}
|
| 238 |
+
.dda-slot-meta { font-size: 0.82rem; color: var(--text-muted); margin-bottom: 0.5rem; }
|
| 239 |
+
|
| 240 |
+
.dda-detect-options {
|
| 241 |
+
display: flex;
|
| 242 |
+
flex-wrap: wrap;
|
| 243 |
+
gap: 1rem;
|
| 244 |
+
align-items: flex-end;
|
| 245 |
+
margin-bottom: 1rem;
|
| 246 |
+
}
|
| 247 |
+
.dda-detect-options .form-group { margin: 0; min-width: 160px; }
|
| 248 |
+
.dda-check { display: flex; align-items: center; gap: 0.35rem; font-size: 0.88rem; color: var(--text-muted); }
|
| 249 |
+
|
| 250 |
+
.dda-modal {
|
| 251 |
+
position: fixed;
|
| 252 |
+
inset: 0;
|
| 253 |
+
z-index: 1300;
|
| 254 |
+
background: rgba(0, 0, 0, 0.55);
|
| 255 |
+
display: flex;
|
| 256 |
+
align-items: center;
|
| 257 |
+
justify-content: center;
|
| 258 |
+
padding: 1rem;
|
| 259 |
+
}
|
| 260 |
+
.dda-modal.hidden { display: none; }
|
| 261 |
+
.dda-modal-panel {
|
| 262 |
+
width: min(720px, 100%);
|
| 263 |
+
max-height: 80vh;
|
| 264 |
+
overflow: auto;
|
| 265 |
+
}
|
| 266 |
+
.dda-picker-grid {
|
| 267 |
+
display: grid;
|
| 268 |
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
| 269 |
+
gap: 0.65rem;
|
| 270 |
+
padding: 0.5rem 0;
|
| 271 |
+
}
|
| 272 |
+
.dda-picker-item {
|
| 273 |
+
display: flex;
|
| 274 |
+
flex-direction: column;
|
| 275 |
+
align-items: stretch;
|
| 276 |
+
border: 1px solid var(--border);
|
| 277 |
+
border-radius: 8px;
|
| 278 |
+
background: var(--bg-elevated);
|
| 279 |
+
cursor: pointer;
|
| 280 |
+
padding: 0;
|
| 281 |
+
overflow: hidden;
|
| 282 |
+
text-align: left;
|
| 283 |
+
color: var(--text);
|
| 284 |
+
}
|
| 285 |
+
.dda-picker-item:hover { border-color: var(--grad-start); }
|
| 286 |
+
.dda-picker-item img { width: 100%; aspect-ratio: 1; object-fit: cover; }
|
| 287 |
+
.dda-picker-item span { padding: 0.4rem 0.5rem; font-size: 0.75rem; }
|
| 288 |
+
|
| 289 |
+
.dda-result-overlay { margin: 1rem 0; }
|
| 290 |
+
.dda-result-img { max-width: 100%; border-radius: 8px; border: 1px solid var(--border); }
|
| 291 |
+
.dda-regions-table {
|
| 292 |
+
width: 100%;
|
| 293 |
+
border-collapse: collapse;
|
| 294 |
+
font-size: 0.85rem;
|
| 295 |
+
margin-top: 0.75rem;
|
| 296 |
+
}
|
| 297 |
+
.dda-regions-table th, .dda-regions-table td {
|
| 298 |
+
border: 1px solid var(--border);
|
| 299 |
+
padding: 0.35rem 0.5rem;
|
| 300 |
+
text-align: left;
|
| 301 |
+
}
|
| 302 |
|
| 303 |
.dda-upload-form .location-row { margin-bottom: 0.5rem; }
|
| 304 |
|
static/js/dda/compare.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Change Detection tab — pick library images and run comparison. */
|
| 2 |
+
|
| 3 |
+
const compareState = { t1: null, t2: null, pickingSlot: null };
|
| 4 |
+
|
| 5 |
+
function compareFormatBytes(n) {
|
| 6 |
+
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
|
| 7 |
+
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
|
| 8 |
+
return (n / 1024).toFixed(0) + ' KB';
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
function thumbUrlFor(path) {
|
| 12 |
+
return `/api/dda/local/thumb?path=${encodeURIComponent(path)}`;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function encodePath(path) {
|
| 16 |
+
return encodeURIComponent(path || '');
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function decodePath(encoded) {
|
| 20 |
+
try {
|
| 21 |
+
return decodeURIComponent(encoded || '');
|
| 22 |
+
} catch (_) {
|
| 23 |
+
return encoded || '';
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function ensureDdaState() {
|
| 28 |
+
window.ddaState = window.ddaState || {};
|
| 29 |
+
return window.ddaState;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function updateRunButton() {
|
| 33 |
+
const btn = document.getElementById('btn-run-job');
|
| 34 |
+
if (btn) btn.disabled = !(compareState.t1 && compareState.t2);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function renderSlotPreview(slotKey, selection) {
|
| 38 |
+
const wrap = document.getElementById(slotKey === 't1' ? 'slot-t1-preview' : 'slot-t2-preview');
|
| 39 |
+
const slot = document.getElementById(slotKey === 't1' ? 'slot-t1' : 'slot-t2');
|
| 40 |
+
if (!wrap || !slot) return;
|
| 41 |
+
|
| 42 |
+
if (!selection) {
|
| 43 |
+
wrap.innerHTML = '<p class="dim">No image selected</p>';
|
| 44 |
+
slot.classList.remove('filled');
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
slot.classList.add('filled');
|
| 49 |
+
const thumb = selection.thumbUrl || thumbUrlFor(selection.path);
|
| 50 |
+
wrap.innerHTML = `
|
| 51 |
+
<img class="dda-slot-preview" src="${thumb}" alt="" />
|
| 52 |
+
<div class="dda-slot-meta"><strong>${selection.year}</strong> — ${selection.filename}</div>`;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function populateSelects(items) {
|
| 56 |
+
['select-t1', 'select-t2'].forEach((id) => {
|
| 57 |
+
const sel = document.getElementById(id);
|
| 58 |
+
if (!sel) return;
|
| 59 |
+
const current = sel.value;
|
| 60 |
+
const label = id === 'select-t1' ? '— Choose base image —' : '— Choose comparison image —';
|
| 61 |
+
sel.innerHTML = `<option value="">${label}</option>` +
|
| 62 |
+
items.map((img) => `<option value="${encodePath(img.path)}">${img.year} — ${img.filename}</option>`).join('');
|
| 63 |
+
if (current) sel.value = current;
|
| 64 |
+
});
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function setSlot(slotKey, img) {
|
| 68 |
+
if (!img || !img.path) return;
|
| 69 |
+
const item = {
|
| 70 |
+
path: img.path,
|
| 71 |
+
year: img.year,
|
| 72 |
+
filename: img.filename,
|
| 73 |
+
thumbUrl: img.thumbUrl || thumbUrlFor(img.path),
|
| 74 |
+
};
|
| 75 |
+
if (slotKey === 't1') {
|
| 76 |
+
compareState.t1 = item;
|
| 77 |
+
const sel = document.getElementById('select-t1');
|
| 78 |
+
if (sel) sel.value = encodePath(item.path);
|
| 79 |
+
renderSlotPreview('t1', item);
|
| 80 |
+
} else {
|
| 81 |
+
compareState.t2 = item;
|
| 82 |
+
const sel = document.getElementById('select-t2');
|
| 83 |
+
if (sel) sel.value = encodePath(item.path);
|
| 84 |
+
renderSlotPreview('t2', item);
|
| 85 |
+
}
|
| 86 |
+
updateRunButton();
|
| 87 |
+
refreshCompareLibrarySelection();
|
| 88 |
+
if (typeof showDdaSuccess === 'function') {
|
| 89 |
+
showDdaSuccess(`${slotKey.toUpperCase()} set: ${item.filename}`);
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
function findLibraryItem(path) {
|
| 94 |
+
const norm = decodePath(path);
|
| 95 |
+
const items = ensureDdaState().libraryItems || [];
|
| 96 |
+
return items.find((i) => i.path === norm) || {
|
| 97 |
+
path: norm,
|
| 98 |
+
year: parseInt(norm.split('/')[0], 10) || '',
|
| 99 |
+
filename: norm.split('/').pop(),
|
| 100 |
+
thumbUrl: thumbUrlFor(norm),
|
| 101 |
+
};
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function refreshCompareLibrarySelection() {
|
| 105 |
+
const grid = document.getElementById('compare-lib-grid');
|
| 106 |
+
if (!grid) return;
|
| 107 |
+
grid.querySelectorAll('.dda-compare-card').forEach((card) => {
|
| 108 |
+
const path = decodePath(card.dataset.imagePath || '');
|
| 109 |
+
card.classList.toggle('selected-t1', !!(compareState.t1 && compareState.t1.path === path));
|
| 110 |
+
card.classList.toggle('selected-t2', !!(compareState.t2 && compareState.t2.path === path));
|
| 111 |
+
});
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function loadCompareLibraryGrid() {
|
| 115 |
+
const grid = document.getElementById('compare-lib-grid');
|
| 116 |
+
if (!grid) return;
|
| 117 |
+
|
| 118 |
+
grid.innerHTML = '<p class="dim">Loading library images…</p>';
|
| 119 |
+
try {
|
| 120 |
+
const items = await ddaApi('GET', '/api/dda/local/images');
|
| 121 |
+
ensureDdaState().libraryItems = items;
|
| 122 |
+
populateSelects(items);
|
| 123 |
+
|
| 124 |
+
if (!items.length) {
|
| 125 |
+
grid.innerHTML = '<p class="dim">No images found. Copy .tif files into <code>library_sources/YEAR/</code> in your project folder, then click <strong>Refresh list</strong>.</p>';
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
grid.innerHTML = items.map((img) => {
|
| 130 |
+
const thumb = img.thumbUrl || thumbUrlFor(img.path);
|
| 131 |
+
const enc = encodePath(img.path);
|
| 132 |
+
const t1Sel = compareState.t1?.path === img.path ? ' selected-t1' : '';
|
| 133 |
+
const t2Sel = compareState.t2?.path === img.path ? ' selected-t2' : '';
|
| 134 |
+
const safeName = img.filename.replace(/</g, '<');
|
| 135 |
+
return `
|
| 136 |
+
<div class="dda-compare-card dda-card-img${t1Sel}${t2Sel}" data-image-path="${enc}" draggable="true">
|
| 137 |
+
${thumb ? `<img src="${thumb}" alt="" loading="lazy" draggable="false" />` : '<div class="meta">No preview</div>'}
|
| 138 |
+
<div class="meta">
|
| 139 |
+
<strong>${img.year}</strong><br/>
|
| 140 |
+
${safeName}<br/>
|
| 141 |
+
<span class="dim">${compareFormatBytes(img.fileSizeBytes)}</span>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="dda-compare-assign">
|
| 144 |
+
<button type="button" class="btn btn-secondary btn-sm" data-assign="t1" data-path="${enc}">T1</button>
|
| 145 |
+
<button type="button" class="btn btn-secondary btn-sm" data-assign="t2" data-path="${enc}">T2</button>
|
| 146 |
+
</div>
|
| 147 |
+
</div>`;
|
| 148 |
+
}).join('');
|
| 149 |
+
|
| 150 |
+
grid.querySelectorAll('.dda-compare-card').forEach((card) => {
|
| 151 |
+
card.addEventListener('dragstart', (e) => {
|
| 152 |
+
const path = decodePath(card.dataset.imagePath);
|
| 153 |
+
e.dataTransfer.setData('application/x-dda-image-path', path);
|
| 154 |
+
e.dataTransfer.setData('text/plain', path);
|
| 155 |
+
});
|
| 156 |
+
});
|
| 157 |
+
} catch (err) {
|
| 158 |
+
grid.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 159 |
+
if (typeof showDdaError === 'function') showDdaError(err.message);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
async function openPicker(slotKey) {
|
| 164 |
+
compareState.pickingSlot = slotKey;
|
| 165 |
+
const modal = document.getElementById('dda-picker-modal');
|
| 166 |
+
const list = document.getElementById('dda-picker-list');
|
| 167 |
+
const title = document.getElementById('dda-picker-title');
|
| 168 |
+
if (!modal || !list) return;
|
| 169 |
+
|
| 170 |
+
if (title) {
|
| 171 |
+
title.textContent = slotKey === 't1' ? 'Select Base Image (T1)' : 'Select Comparison Image (T2)';
|
| 172 |
+
}
|
| 173 |
+
list.innerHTML = '<p class="dim">Loading library…</p>';
|
| 174 |
+
modal.classList.remove('hidden');
|
| 175 |
+
|
| 176 |
+
let items = ensureDdaState().libraryItems || [];
|
| 177 |
+
try {
|
| 178 |
+
items = await ddaApi('GET', '/api/dda/local/images');
|
| 179 |
+
ensureDdaState().libraryItems = items;
|
| 180 |
+
populateSelects(items);
|
| 181 |
+
} catch (err) {
|
| 182 |
+
list.innerHTML = `<p class="dim">Could not load images: ${err.message}</p>`;
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
if (!items.length) {
|
| 187 |
+
list.innerHTML = '<p class="dim">No images in library. Add files under library_sources/YEAR/ and refresh.</p>';
|
| 188 |
+
} else {
|
| 189 |
+
list.innerHTML = items.map((img) => {
|
| 190 |
+
const enc = encodePath(img.path);
|
| 191 |
+
return `
|
| 192 |
+
<button type="button" class="dda-picker-item" data-path="${enc}">
|
| 193 |
+
<img src="${img.thumbUrl || thumbUrlFor(img.path)}" alt="" loading="lazy" />
|
| 194 |
+
<span><strong>${img.year}</strong><br/>${img.filename}</span>
|
| 195 |
+
</button>`;
|
| 196 |
+
}).join('');
|
| 197 |
+
list.querySelectorAll('.dda-picker-item').forEach((btn) => {
|
| 198 |
+
btn.addEventListener('click', () => {
|
| 199 |
+
const slot = compareState.pickingSlot;
|
| 200 |
+
if (!slot) return;
|
| 201 |
+
setSlot(slot, findLibraryItem(btn.dataset.path));
|
| 202 |
+
closePicker();
|
| 203 |
+
});
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function closePicker() {
|
| 209 |
+
document.getElementById('dda-picker-modal')?.classList.add('hidden');
|
| 210 |
+
compareState.pickingSlot = null;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
function setupCompareInteractions() {
|
| 214 |
+
const slotsWrap = document.querySelector('.dda-compare-slots');
|
| 215 |
+
if (slotsWrap) {
|
| 216 |
+
slotsWrap.addEventListener('click', (e) => {
|
| 217 |
+
const pickBtn = e.target.closest('.dda-slot-pick');
|
| 218 |
+
if (pickBtn) {
|
| 219 |
+
e.preventDefault();
|
| 220 |
+
openPicker(pickBtn.dataset.pick);
|
| 221 |
+
}
|
| 222 |
+
});
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
document.getElementById('select-t1')?.addEventListener('change', (e) => {
|
| 226 |
+
const path = e.target.value;
|
| 227 |
+
if (!path) {
|
| 228 |
+
compareState.t1 = null;
|
| 229 |
+
renderSlotPreview('t1', null);
|
| 230 |
+
updateRunButton();
|
| 231 |
+
refreshCompareLibrarySelection();
|
| 232 |
+
return;
|
| 233 |
+
}
|
| 234 |
+
setSlot('t1', findLibraryItem(path));
|
| 235 |
+
});
|
| 236 |
+
|
| 237 |
+
document.getElementById('select-t2')?.addEventListener('change', (e) => {
|
| 238 |
+
const path = e.target.value;
|
| 239 |
+
if (!path) {
|
| 240 |
+
compareState.t2 = null;
|
| 241 |
+
renderSlotPreview('t2', null);
|
| 242 |
+
updateRunButton();
|
| 243 |
+
refreshCompareLibrarySelection();
|
| 244 |
+
return;
|
| 245 |
+
}
|
| 246 |
+
setSlot('t2', findLibraryItem(path));
|
| 247 |
+
});
|
| 248 |
+
|
| 249 |
+
const grid = document.getElementById('compare-lib-grid');
|
| 250 |
+
if (grid) {
|
| 251 |
+
grid.addEventListener('click', (e) => {
|
| 252 |
+
const btn = e.target.closest('[data-assign]');
|
| 253 |
+
if (!btn) return;
|
| 254 |
+
e.preventDefault();
|
| 255 |
+
e.stopPropagation();
|
| 256 |
+
setSlot(btn.dataset.assign, findLibraryItem(btn.dataset.path));
|
| 257 |
+
});
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
['slot-t1', 'slot-t2'].forEach((id) => {
|
| 261 |
+
const el = document.getElementById(id);
|
| 262 |
+
if (!el) return;
|
| 263 |
+
el.addEventListener('dragover', (e) => { e.preventDefault(); el.classList.add('drag-over'); });
|
| 264 |
+
el.addEventListener('dragleave', () => el.classList.remove('drag-over'));
|
| 265 |
+
el.addEventListener('drop', (e) => {
|
| 266 |
+
e.preventDefault();
|
| 267 |
+
el.classList.remove('drag-over');
|
| 268 |
+
const path = e.dataTransfer.getData('application/x-dda-image-path')
|
| 269 |
+
|| e.dataTransfer.getData('text/plain');
|
| 270 |
+
if (!path) return;
|
| 271 |
+
setSlot(id === 'slot-t1' ? 't1' : 't2', findLibraryItem(path));
|
| 272 |
+
});
|
| 273 |
+
});
|
| 274 |
+
|
| 275 |
+
document.getElementById('btn-compare-refresh')?.addEventListener('click', () => loadCompareLibraryGrid());
|
| 276 |
+
document.getElementById('dda-picker-close')?.addEventListener('click', closePicker);
|
| 277 |
+
document.getElementById('dda-picker-modal')?.addEventListener('click', (e) => {
|
| 278 |
+
if (e.target.id === 'dda-picker-modal') closePicker();
|
| 279 |
+
});
|
| 280 |
+
document.getElementById('btn-run-job')?.addEventListener('click', runLibraryDetection);
|
| 281 |
+
|
| 282 |
+
document.addEventListener('keydown', (e) => {
|
| 283 |
+
if (e.key !== 'Escape') return;
|
| 284 |
+
const picker = document.getElementById('dda-picker-modal');
|
| 285 |
+
if (picker && !picker.classList.contains('hidden')) closePicker();
|
| 286 |
+
});
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function showDetectResult(data) {
|
| 290 |
+
if (typeof showDdaResult === 'function') showDdaResult(data);
|
| 291 |
+
else if (typeof showDdaError === 'function') showDdaError('Result viewer failed to load.');
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
async function pollJobUntilDone(jobId, loadingEl) {
|
| 295 |
+
const maxAttempts = 600;
|
| 296 |
+
for (let i = 0; i < maxAttempts; i++) {
|
| 297 |
+
const job = await ddaApi('GET', `/api/dda/jobs/${jobId}`);
|
| 298 |
+
const status = job.status;
|
| 299 |
+
if (loadingEl) {
|
| 300 |
+
loadingEl.textContent = `Detection job #${jobId} — ${status}… (${i + 1})`;
|
| 301 |
+
}
|
| 302 |
+
if (status === 'completed' && job.result) return job.result;
|
| 303 |
+
if (status === 'failed') throw new Error(job.errorMessage || 'Detection job failed');
|
| 304 |
+
await new Promise((r) => setTimeout(r, 2000));
|
| 305 |
+
}
|
| 306 |
+
throw new Error('Detection timed out. Check Reports tab for job status.');
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
async function runLibraryDetection() {
|
| 310 |
+
if (!compareState.t1 || !compareState.t2) {
|
| 311 |
+
if (typeof showDdaError === 'function') showDdaError('Select both T1 (base) and T2 (comparison) images first.');
|
| 312 |
+
return;
|
| 313 |
+
}
|
| 314 |
+
const btn = document.getElementById('btn-run-job');
|
| 315 |
+
const loading = document.getElementById('dda-detect-loading');
|
| 316 |
+
if (typeof hideDdaError === 'function') hideDdaError();
|
| 317 |
+
btn.disabled = true;
|
| 318 |
+
loading?.classList.remove('hidden');
|
| 319 |
+
loading.textContent = 'Running detection…';
|
| 320 |
+
|
| 321 |
+
const form = new FormData();
|
| 322 |
+
form.append('base_path', compareState.t1.path);
|
| 323 |
+
form.append('comparison_path', compareState.t2.path);
|
| 324 |
+
form.append('title', `${compareState.t1.filename} vs ${compareState.t2.filename}`);
|
| 325 |
+
form.append('method', document.getElementById('dda-detect-method')?.value || 'AI-Based Deep Learning');
|
| 326 |
+
form.append('enable_registration', String(document.getElementById('dda-detect-registration')?.checked !== false));
|
| 327 |
+
form.append('enable_normalization', String(document.getElementById('dda-detect-normalization')?.checked !== false));
|
| 328 |
+
form.append('detection_sensitivity', String(Math.max(0, Math.min(1, Number(document.getElementById('dda-detect-sensitivity')?.value ?? 0.45)))));
|
| 329 |
+
const minArea = Number(document.getElementById('dda-detect-min-area')?.value ?? 150);
|
| 330 |
+
if (!Number.isNaN(minArea) && minArea >= 50) form.append('min_region_area', String(Math.round(minArea)));
|
| 331 |
+
|
| 332 |
+
try {
|
| 333 |
+
const data = await ddaApi('POST', '/api/dda/detect/from-library', { body: form });
|
| 334 |
+
showDetectResult(data);
|
| 335 |
+
if (typeof showDdaSuccess === 'function') showDdaSuccess('Detection complete.');
|
| 336 |
+
if (typeof loadReportsList === 'function') loadReportsList();
|
| 337 |
+
} catch (err) {
|
| 338 |
+
if (typeof showDdaError === 'function') showDdaError(err.message || 'Detection failed');
|
| 339 |
+
} finally {
|
| 340 |
+
btn.disabled = !(compareState.t1 && compareState.t2);
|
| 341 |
+
loading?.classList.add('hidden');
|
| 342 |
+
}
|
| 343 |
+
}
|
| 344 |
+
|
| 345 |
+
let compareInitialized = false;
|
| 346 |
+
|
| 347 |
+
function initCompareTab() {
|
| 348 |
+
if (!compareInitialized) {
|
| 349 |
+
setupCompareInteractions();
|
| 350 |
+
compareInitialized = true;
|
| 351 |
+
}
|
| 352 |
+
updateRunButton();
|
| 353 |
+
loadCompareLibraryGrid();
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
window.loadCompareLibraryGrid = loadCompareLibraryGrid;
|
| 357 |
+
window.initCompareTab = initCompareTab;
|
| 358 |
+
|
| 359 |
+
if (document.readyState === 'loading') {
|
| 360 |
+
document.addEventListener('DOMContentLoaded', initCompareTab);
|
| 361 |
+
} else {
|
| 362 |
+
initCompareTab();
|
| 363 |
+
}
|
static/js/dda/reports.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** Reports tab — detection jobs and history (Phase 4 foundation). */
|
| 2 |
+
|
| 3 |
+
function formatReportDate(iso) {
|
| 4 |
+
if (!iso) return '—';
|
| 5 |
+
try {
|
| 6 |
+
return new Date(iso).toLocaleString();
|
| 7 |
+
} catch (_) {
|
| 8 |
+
return iso;
|
| 9 |
+
}
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
async function loadReportsList() {
|
| 13 |
+
const el = document.getElementById('reports-list');
|
| 14 |
+
if (!el) return;
|
| 15 |
+
el.innerHTML = '<p class="dim">Loading reports…</p>';
|
| 16 |
+
try {
|
| 17 |
+
let jobs = [];
|
| 18 |
+
try {
|
| 19 |
+
const jobsData = await ddaApi('GET', '/api/dda/jobs?limit=30');
|
| 20 |
+
jobs = jobsData.jobs || [];
|
| 21 |
+
} catch (_) {
|
| 22 |
+
/* jobs API optional — history still works */
|
| 23 |
+
}
|
| 24 |
+
const history = await ddaApi('GET', '/api/history');
|
| 25 |
+
const rows = [];
|
| 26 |
+
|
| 27 |
+
jobs.forEach((j) => {
|
| 28 |
+
rows.push({
|
| 29 |
+
kind: 'job',
|
| 30 |
+
id: j.id,
|
| 31 |
+
runId: j.runId,
|
| 32 |
+
title: j.title || `${j.basePath} vs ${j.comparisonPath}`,
|
| 33 |
+
status: j.status,
|
| 34 |
+
changePct: j.report?.changePercentage,
|
| 35 |
+
regions: j.report?.regionsCount,
|
| 36 |
+
createdAt: j.createdAt,
|
| 37 |
+
error: j.errorMessage,
|
| 38 |
+
});
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
(history || []).forEach((r) => {
|
| 42 |
+
if (rows.some((x) => x.runId === r.id)) return;
|
| 43 |
+
rows.push({
|
| 44 |
+
kind: 'run',
|
| 45 |
+
id: r.id,
|
| 46 |
+
runId: r.id,
|
| 47 |
+
title: r.title,
|
| 48 |
+
status: 'completed',
|
| 49 |
+
changePct: r.changePercentage,
|
| 50 |
+
regions: r.regionsCount,
|
| 51 |
+
createdAt: r.createdAt,
|
| 52 |
+
});
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
rows.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
| 56 |
+
|
| 57 |
+
if (!rows.length) {
|
| 58 |
+
el.innerHTML = '<p class="dim">No detection reports yet. Run a comparison on the Change Detection tab.</p>';
|
| 59 |
+
return;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
el.innerHTML = `
|
| 63 |
+
<table class="dda-reports-table">
|
| 64 |
+
<thead>
|
| 65 |
+
<tr>
|
| 66 |
+
<th>Date</th>
|
| 67 |
+
<th>Title</th>
|
| 68 |
+
<th>Status</th>
|
| 69 |
+
<th>Change %</th>
|
| 70 |
+
<th>Regions</th>
|
| 71 |
+
<th></th>
|
| 72 |
+
</tr>
|
| 73 |
+
</thead>
|
| 74 |
+
<tbody>
|
| 75 |
+
${rows.map((r) => `
|
| 76 |
+
<tr>
|
| 77 |
+
<td>${formatReportDate(r.createdAt)}</td>
|
| 78 |
+
<td>${r.title}</td>
|
| 79 |
+
<td><span class="dda-status dda-status-${r.status}">${r.status}</span></td>
|
| 80 |
+
<td>${r.changePct != null ? r.changePct.toFixed(2) + '%' : '—'}</td>
|
| 81 |
+
<td>${r.regions ?? '—'}</td>
|
| 82 |
+
<td>
|
| 83 |
+
${r.status === 'completed' && r.runId
|
| 84 |
+
? `<button type="button" class="btn btn-secondary btn-sm" data-view-run="${r.runId}">View</button>`
|
| 85 |
+
: (r.error ? `<span class="dim" title="${r.error.replace(/"/g, '')}">Error</span>` : '—')}
|
| 86 |
+
</td>
|
| 87 |
+
</tr>`).join('')}
|
| 88 |
+
</tbody>
|
| 89 |
+
</table>`;
|
| 90 |
+
|
| 91 |
+
el.querySelectorAll('[data-view-run]').forEach((btn) => {
|
| 92 |
+
btn.addEventListener('click', async () => {
|
| 93 |
+
const runId = btn.dataset.viewRun;
|
| 94 |
+
try {
|
| 95 |
+
const data = await ddaApi('GET', `/api/history/${runId}`);
|
| 96 |
+
if (typeof showDdaResult === 'function') showDdaResult(data);
|
| 97 |
+
} catch (err) {
|
| 98 |
+
if (typeof showDdaError === 'function') showDdaError(err.message);
|
| 99 |
+
}
|
| 100 |
+
});
|
| 101 |
+
});
|
| 102 |
+
} catch (err) {
|
| 103 |
+
el.innerHTML = `<p class="dim">Could not load reports: ${err.message}</p>`;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
window.loadReportsList = loadReportsList;
|
| 108 |
+
|
| 109 |
+
document.getElementById('btn-reports-refresh')?.addEventListener('click', loadReportsList);
|
| 110 |
+
|
| 111 |
+
document.querySelectorAll('.dda-tab').forEach((btn) => {
|
| 112 |
+
if (btn.dataset.tab === 'reports') {
|
| 113 |
+
btn.addEventListener('click', () => loadReportsList());
|
| 114 |
+
}
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
if (document.getElementById('tab-reports')?.classList.contains('active')) {
|
| 118 |
+
loadReportsList();
|
| 119 |
+
}
|
static/js/dda/result.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** DDA result modal — before/after compare slider (same as production). */
|
| 2 |
+
|
| 3 |
+
let ddaCurrentResult = null;
|
| 4 |
+
let ddaRegionRows = [];
|
| 5 |
+
let ddaRegionList = [];
|
| 6 |
+
let ddaRegionPage = 0;
|
| 7 |
+
const DDA_REGIONS_PER_PAGE = 10;
|
| 8 |
+
let ddaZoom = 1;
|
| 9 |
+
const DDA_ZOOM_MIN = 0.5;
|
| 10 |
+
const DDA_ZOOM_MAX = 3;
|
| 11 |
+
const DDA_ZOOM_STEP = 0.25;
|
| 12 |
+
|
| 13 |
+
function formatCompact(n) {
|
| 14 |
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1).replace(/\.0$/, '') + 'M';
|
| 15 |
+
if (n >= 10_000) return (n / 1_000).toFixed(1).replace(/\.0$/, '') + 'K';
|
| 16 |
+
return n.toLocaleString();
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function showDdaResult(data) {
|
| 20 |
+
const modal = document.getElementById('result-modal');
|
| 21 |
+
const statsEl = document.getElementById('result-stats');
|
| 22 |
+
const titleEl = document.getElementById('result-modal-title');
|
| 23 |
+
if (!modal || !statsEl) return;
|
| 24 |
+
|
| 25 |
+
ddaCurrentResult = data;
|
| 26 |
+
if (titleEl) titleEl.textContent = data.title || 'Detection Result';
|
| 27 |
+
|
| 28 |
+
const stats = data.statistics || {};
|
| 29 |
+
const pct = (stats.changePercentage ?? 0).toFixed(2);
|
| 30 |
+
const chPx = stats.changedPixels ?? 0;
|
| 31 |
+
const totPx = stats.totalPixels ?? 0;
|
| 32 |
+
const regOk = stats.registrationOk;
|
| 33 |
+
const alignWarn = stats.alignmentWarning;
|
| 34 |
+
const thrDbg = stats.thresholdDebug || {};
|
| 35 |
+
const fusionPx = thrDbg.fused_changed_px != null
|
| 36 |
+
? `DL ${thrDbg.dl_changed_px ?? '—'} / fused ${thrDbg.fused_changed_px}`
|
| 37 |
+
: '';
|
| 38 |
+
|
| 39 |
+
let warnHtml = '';
|
| 40 |
+
if (alignWarn) {
|
| 41 |
+
warnHtml = `<div class="result-warning" role="alert">${alignWarn}</div>`;
|
| 42 |
+
} else if (regOk === false) {
|
| 43 |
+
warnHtml = '<div class="result-warning" role="alert">Image alignment was weak — results may include false detections.</div>';
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const resHint = data.detectionMaxSide
|
| 47 |
+
? `<div class="stat-box"><div class="value value-sm">${data.detectionMaxSide}px</div><div class="label">Detection res</div></div>`
|
| 48 |
+
: '';
|
| 49 |
+
|
| 50 |
+
statsEl.innerHTML = warnHtml + `
|
| 51 |
+
<div class="stat-box"><div class="value">${pct}%</div><div class="label">Changed</div></div>
|
| 52 |
+
<div class="stat-box"><div class="value" title="${chPx.toLocaleString()}">${formatCompact(chPx)}</div><div class="label">Changed px</div></div>
|
| 53 |
+
<div class="stat-box"><div class="value" title="${totPx.toLocaleString()}">${formatCompact(totPx)}</div><div class="label">Total px</div></div>
|
| 54 |
+
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 55 |
+
${resHint}
|
| 56 |
+
${fusionPx ? `<div class="stat-box stat-box-wide"><div class="value value-sm">${fusionPx}</div><div class="label">Fusion px</div></div>` : ''}
|
| 57 |
+
<div class="stat-box"><div class="value value-sm">${regOk === true ? 'OK' : regOk === false ? 'Weak' : '—'}</div><div class="label">Alignment</div></div>
|
| 58 |
+
`;
|
| 59 |
+
|
| 60 |
+
const beforeImg = document.getElementById('compare-before-img');
|
| 61 |
+
const afterImg = document.getElementById('compare-after-img');
|
| 62 |
+
if (!beforeImg || !afterImg) return;
|
| 63 |
+
|
| 64 |
+
if (data.overlayBase64Png) {
|
| 65 |
+
afterImg.src = 'data:image/png;base64,' + data.overlayBase64Png;
|
| 66 |
+
} else {
|
| 67 |
+
afterImg.src = data.overlayUrl || '';
|
| 68 |
+
}
|
| 69 |
+
beforeImg.src = data.beforeFullUrl || data.beforeThumbUrl || '';
|
| 70 |
+
|
| 71 |
+
let loaded = 0;
|
| 72 |
+
const onReady = () => {
|
| 73 |
+
if (++loaded >= 2) {
|
| 74 |
+
resetDdaCompareSlider();
|
| 75 |
+
resetDdaZoom();
|
| 76 |
+
}
|
| 77 |
+
};
|
| 78 |
+
afterImg.onload = onReady;
|
| 79 |
+
beforeImg.onload = onReady;
|
| 80 |
+
setTimeout(() => { resetDdaCompareSlider(); resetDdaZoom(); }, 500);
|
| 81 |
+
|
| 82 |
+
const regions = (data.regions || []).slice(0, 60);
|
| 83 |
+
ddaRegionList = regions;
|
| 84 |
+
ddaRegionRows = regions.map((r) => {
|
| 85 |
+
const tr = document.createElement('tr');
|
| 86 |
+
tr.dataset.regionId = r.id;
|
| 87 |
+
const subType = r.subType || '—';
|
| 88 |
+
const ddaType = r.ddaChangeType || '—';
|
| 89 |
+
const latLng = r.latLng ? `${r.latLng.lat}, ${r.latLng.lng}` : '—';
|
| 90 |
+
const severity = (r.severity || 'minor').toLowerCase();
|
| 91 |
+
const stories = r.estimatedStories != null ? r.estimatedStories : '—';
|
| 92 |
+
const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : '—';
|
| 93 |
+
const stage = r.constructionStage && r.constructionStage !== 'Unknown' ? r.constructionStage : '—';
|
| 94 |
+
tr.innerHTML = `
|
| 95 |
+
<td>${r.id}</td>
|
| 96 |
+
<td>${r.objectType}</td>
|
| 97 |
+
<td>${ddaType}</td>
|
| 98 |
+
<td>${latLng}</td>
|
| 99 |
+
<td>${subType}</td>
|
| 100 |
+
<td><span class="severity-badge ${severity}">${severity}</span></td>
|
| 101 |
+
<td>${(r.confidence * 100).toFixed(1)}%</td>
|
| 102 |
+
<td>${r.area.toLocaleString()}</td>
|
| 103 |
+
<td>(${r.center.x}, ${r.center.y})</td>
|
| 104 |
+
<td>${stories}</td>
|
| 105 |
+
<td>${height}</td>
|
| 106 |
+
<td>${stage}</td>
|
| 107 |
+
`;
|
| 108 |
+
return tr;
|
| 109 |
+
});
|
| 110 |
+
|
| 111 |
+
ddaRegionPage = 0;
|
| 112 |
+
renderDdaRegionPage();
|
| 113 |
+
openDdaResultModal();
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function renderDdaRegionPage() {
|
| 117 |
+
const tbody = document.getElementById('regions-tbody');
|
| 118 |
+
const pag = document.getElementById('regions-pagination');
|
| 119 |
+
if (!tbody) return;
|
| 120 |
+
|
| 121 |
+
const totalPages = Math.max(1, Math.ceil(ddaRegionRows.length / DDA_REGIONS_PER_PAGE));
|
| 122 |
+
ddaRegionPage = Math.max(0, Math.min(ddaRegionPage, totalPages - 1));
|
| 123 |
+
const start = ddaRegionPage * DDA_REGIONS_PER_PAGE;
|
| 124 |
+
const pageRows = ddaRegionRows.slice(start, start + DDA_REGIONS_PER_PAGE);
|
| 125 |
+
const pageData = ddaRegionList.slice(start, start + DDA_REGIONS_PER_PAGE);
|
| 126 |
+
|
| 127 |
+
tbody.innerHTML = '';
|
| 128 |
+
pageRows.forEach((tr) => tbody.appendChild(tr));
|
| 129 |
+
setupDdaRegionHover(tbody, pageData);
|
| 130 |
+
|
| 131 |
+
if (!pag) return;
|
| 132 |
+
pag.innerHTML = '';
|
| 133 |
+
if (totalPages <= 1) return;
|
| 134 |
+
|
| 135 |
+
const prev = document.createElement('button');
|
| 136 |
+
prev.textContent = '‹';
|
| 137 |
+
prev.disabled = ddaRegionPage === 0;
|
| 138 |
+
prev.addEventListener('click', () => { ddaRegionPage--; renderDdaRegionPage(); });
|
| 139 |
+
pag.appendChild(prev);
|
| 140 |
+
|
| 141 |
+
for (let i = 0; i < totalPages; i++) {
|
| 142 |
+
const btn = document.createElement('button');
|
| 143 |
+
btn.textContent = i + 1;
|
| 144 |
+
if (i === ddaRegionPage) btn.classList.add('active');
|
| 145 |
+
btn.addEventListener('click', () => { ddaRegionPage = i; renderDdaRegionPage(); });
|
| 146 |
+
pag.appendChild(btn);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
const next = document.createElement('button');
|
| 150 |
+
next.textContent = '›';
|
| 151 |
+
next.disabled = ddaRegionPage >= totalPages - 1;
|
| 152 |
+
next.addEventListener('click', () => { ddaRegionPage++; renderDdaRegionPage(); });
|
| 153 |
+
pag.appendChild(next);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
function setupDdaRegionHover(tbody, regions) {
|
| 157 |
+
const overlay = document.getElementById('region-highlight-overlay');
|
| 158 |
+
if (!overlay) return;
|
| 159 |
+
overlay.innerHTML = '';
|
| 160 |
+
tbody.querySelectorAll('tr[data-region-id]').forEach((tr) => {
|
| 161 |
+
tr.addEventListener('mouseenter', () => {
|
| 162 |
+
const id = parseInt(tr.dataset.regionId, 10);
|
| 163 |
+
const r = regions.find((x) => x.id === id);
|
| 164 |
+
if (!r || !r.bbox) return;
|
| 165 |
+
tbody.querySelectorAll('tr').forEach((row) => row.classList.remove('region-hover'));
|
| 166 |
+
tr.classList.add('region-hover');
|
| 167 |
+
const box = document.createElement('div');
|
| 168 |
+
box.className = 'highlight-box';
|
| 169 |
+
const imgEl = document.getElementById('compare-after-img');
|
| 170 |
+
const slider = document.getElementById('compare-slider');
|
| 171 |
+
if (!imgEl || !slider || !imgEl.naturalWidth) return;
|
| 172 |
+
const rw = slider.offsetWidth;
|
| 173 |
+
const rh = slider.offsetHeight;
|
| 174 |
+
const imgW = imgEl.naturalWidth || 1;
|
| 175 |
+
const imgH = imgEl.naturalHeight || 1;
|
| 176 |
+
const scale = Math.min(rw / imgW, rh / imgH);
|
| 177 |
+
const drawW = imgW * scale;
|
| 178 |
+
const drawH = imgH * scale;
|
| 179 |
+
const offsetX = (rw - drawW) / 2;
|
| 180 |
+
const offsetY = (rh - drawH) / 2;
|
| 181 |
+
box.style.left = (offsetX + r.bbox.x * scale) + 'px';
|
| 182 |
+
box.style.top = (offsetY + r.bbox.y * scale) + 'px';
|
| 183 |
+
box.style.width = (r.bbox.w * scale) + 'px';
|
| 184 |
+
box.style.height = (r.bbox.h * scale) + 'px';
|
| 185 |
+
overlay.appendChild(box);
|
| 186 |
+
});
|
| 187 |
+
tr.addEventListener('mouseleave', () => {
|
| 188 |
+
tr.classList.remove('region-hover');
|
| 189 |
+
overlay.innerHTML = '';
|
| 190 |
+
});
|
| 191 |
+
});
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function openDdaResultModal() {
|
| 195 |
+
const modal = document.getElementById('result-modal');
|
| 196 |
+
if (!modal) return;
|
| 197 |
+
modal.classList.remove('hidden');
|
| 198 |
+
document.body.style.overflow = 'hidden';
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function closeDdaResultModal() {
|
| 202 |
+
const modal = document.getElementById('result-modal');
|
| 203 |
+
if (!modal) return;
|
| 204 |
+
modal.classList.add('hidden');
|
| 205 |
+
const picker = document.getElementById('dda-picker-modal');
|
| 206 |
+
if (!picker || picker.classList.contains('hidden')) {
|
| 207 |
+
document.body.style.overflow = '';
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
function resetDdaCompareSlider() {
|
| 212 |
+
const ac = document.getElementById('compare-after-clip');
|
| 213 |
+
const h = document.getElementById('compare-handle');
|
| 214 |
+
if (ac) ac.style.clipPath = 'inset(0 0 0 50%)';
|
| 215 |
+
if (h) h.style.left = '50%';
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function applyDdaZoom() {
|
| 219 |
+
const slider = document.getElementById('compare-slider');
|
| 220 |
+
const levelEl = document.getElementById('zoom-level');
|
| 221 |
+
if (!slider) return;
|
| 222 |
+
slider.style.transform = `scale(${ddaZoom})`;
|
| 223 |
+
slider.style.transformOrigin = 'center top';
|
| 224 |
+
if (levelEl) levelEl.textContent = Math.round(ddaZoom * 100) + '%';
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function resetDdaZoom() {
|
| 228 |
+
ddaZoom = 1;
|
| 229 |
+
applyDdaZoom();
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function initDdaCompareSlider() {
|
| 233 |
+
const slider = document.getElementById('compare-slider');
|
| 234 |
+
if (!slider) return;
|
| 235 |
+
let isDragging = false;
|
| 236 |
+
|
| 237 |
+
function updatePosition(clientX) {
|
| 238 |
+
const rect = slider.getBoundingClientRect();
|
| 239 |
+
const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
|
| 240 |
+
document.getElementById('compare-after-clip').style.clipPath = `inset(0 0 0 ${pct}%)`;
|
| 241 |
+
document.getElementById('compare-handle').style.left = pct + '%';
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
slider.addEventListener('mousedown', (e) => { e.preventDefault(); isDragging = true; updatePosition(e.clientX); });
|
| 245 |
+
document.addEventListener('mousemove', (e) => { if (isDragging) updatePosition(e.clientX); });
|
| 246 |
+
document.addEventListener('mouseup', () => { isDragging = false; });
|
| 247 |
+
slider.addEventListener('touchstart', (e) => { isDragging = true; updatePosition(e.touches[0].clientX); }, { passive: true });
|
| 248 |
+
document.addEventListener('touchmove', (e) => { if (isDragging) updatePosition(e.touches[0].clientX); }, { passive: true });
|
| 249 |
+
document.addEventListener('touchend', () => { isDragging = false; });
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function initDdaZoom() {
|
| 253 |
+
document.getElementById('zoom-in')?.addEventListener('click', () => {
|
| 254 |
+
ddaZoom = Math.min(DDA_ZOOM_MAX, ddaZoom + DDA_ZOOM_STEP);
|
| 255 |
+
applyDdaZoom();
|
| 256 |
+
});
|
| 257 |
+
document.getElementById('zoom-out')?.addEventListener('click', () => {
|
| 258 |
+
ddaZoom = Math.max(DDA_ZOOM_MIN, ddaZoom - DDA_ZOOM_STEP);
|
| 259 |
+
applyDdaZoom();
|
| 260 |
+
});
|
| 261 |
+
document.getElementById('zoom-wrapper')?.addEventListener('wheel', (e) => {
|
| 262 |
+
if (!e.ctrlKey && !e.metaKey) return;
|
| 263 |
+
e.preventDefault();
|
| 264 |
+
if (e.deltaY < 0) ddaZoom = Math.min(DDA_ZOOM_MAX, ddaZoom + DDA_ZOOM_STEP);
|
| 265 |
+
else ddaZoom = Math.max(DDA_ZOOM_MIN, ddaZoom - DDA_ZOOM_STEP);
|
| 266 |
+
applyDdaZoom();
|
| 267 |
+
}, { passive: false });
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
document.getElementById('result-modal-close')?.addEventListener('click', closeDdaResultModal);
|
| 271 |
+
document.getElementById('result-modal')?.addEventListener('click', (e) => {
|
| 272 |
+
if (e.target === e.currentTarget) closeDdaResultModal();
|
| 273 |
+
});
|
| 274 |
+
document.addEventListener('keydown', (e) => {
|
| 275 |
+
if (e.key === 'Escape') {
|
| 276 |
+
const modal = document.getElementById('result-modal');
|
| 277 |
+
if (modal && !modal.classList.contains('hidden')) closeDdaResultModal();
|
| 278 |
+
}
|
| 279 |
+
});
|
| 280 |
+
|
| 281 |
+
initDdaCompareSlider();
|
| 282 |
+
initDdaZoom();
|
templates/index_dda.html
CHANGED
|
@@ -5,7 +5,7 @@
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
-
<link rel="stylesheet" href="/static/css/dda.css?v=
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
@@ -53,7 +53,7 @@
|
|
| 53 |
<h3>Upload to Space storage</h3>
|
| 54 |
<span class="dim">Required on Hugging Face</span>
|
| 55 |
</div>
|
| 56 |
-
<p class="sub">Files on your computer are not on the server. Upload .tif images here (up to
|
| 57 |
<form id="form-hf-upload" class="dda-upload-form">
|
| 58 |
<div class="location-row">
|
| 59 |
<div class="form-group">
|
|
@@ -83,36 +83,155 @@
|
|
| 83 |
</div>
|
| 84 |
</section>
|
| 85 |
|
| 86 |
-
<!-- Tab: Change Detection
|
| 87 |
<section id="tab-detect" class="dda-panel" role="tabpanel">
|
| 88 |
<div class="card">
|
| 89 |
<div class="card-header"><h3>Compare Images</h3></div>
|
| 90 |
-
<p class="sub">Select <strong>Base (T1)</strong> and <strong>Comparison (T2)</strong> from the library
|
| 91 |
<div class="dda-compare-slots">
|
| 92 |
<div class="dda-slot" id="slot-t1" data-slot="t1">
|
| 93 |
<span class="dda-slot-label">Base Image (T1)</span>
|
| 94 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
</div>
|
| 96 |
<div class="dda-slot" id="slot-t2" data-slot="t2">
|
| 97 |
<span class="dda-slot-label">Comparison Image (T2)</span>
|
| 98 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
</div>
|
| 103 |
</section>
|
| 104 |
|
| 105 |
-
<!-- Tab: Reports (FR-05
|
| 106 |
<section id="tab-reports" class="dda-panel" role="tabpanel">
|
| 107 |
<div class="card">
|
| 108 |
-
<div class="card-header">
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
| 111 |
</div>
|
| 112 |
</section>
|
| 113 |
</div>
|
| 114 |
|
| 115 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
<script src="/static/js/dda/library.js?v=5"></script>
|
|
|
|
|
|
|
|
|
|
| 117 |
</body>
|
| 118 |
</html>
|
|
|
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>DDA Change Detection</title>
|
| 7 |
<link rel="stylesheet" href="/static/css/style.css?v=30" />
|
| 8 |
+
<link rel="stylesheet" href="/static/css/dda.css?v=7" />
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div class="app dda-app">
|
|
|
|
| 53 |
<h3>Upload to Space storage</h3>
|
| 54 |
<span class="dim">Required on Hugging Face</span>
|
| 55 |
</div>
|
| 56 |
+
<p class="sub" id="hf-upload-limit">Files on your computer are not on the server. Upload .tif images here (up to 5 GB each).</p>
|
| 57 |
<form id="form-hf-upload" class="dda-upload-form">
|
| 58 |
<div class="location-row">
|
| 59 |
<div class="form-group">
|
|
|
|
| 83 |
</div>
|
| 84 |
</section>
|
| 85 |
|
| 86 |
+
<!-- Tab: Change Detection -->
|
| 87 |
<section id="tab-detect" class="dda-panel" role="tabpanel">
|
| 88 |
<div class="card">
|
| 89 |
<div class="card-header"><h3>Compare Images</h3></div>
|
| 90 |
+
<p class="sub">Select <strong>Base (T1)</strong> and <strong>Comparison (T2)</strong> from the library, then run detection.</p>
|
| 91 |
<div class="dda-compare-slots">
|
| 92 |
<div class="dda-slot" id="slot-t1" data-slot="t1">
|
| 93 |
<span class="dda-slot-label">Base Image (T1)</span>
|
| 94 |
+
<div id="slot-t1-preview" class="dda-slot-preview-wrap"></div>
|
| 95 |
+
<select id="select-t1" class="dda-image-select" aria-label="Select base image">
|
| 96 |
+
<option value="">— Choose base image —</option>
|
| 97 |
+
</select>
|
| 98 |
+
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t1">Browse library</button>
|
| 99 |
</div>
|
| 100 |
<div class="dda-slot" id="slot-t2" data-slot="t2">
|
| 101 |
<span class="dda-slot-label">Comparison Image (T2)</span>
|
| 102 |
+
<div id="slot-t2-preview" class="dda-slot-preview-wrap"></div>
|
| 103 |
+
<select id="select-t2" class="dda-image-select" aria-label="Select comparison image">
|
| 104 |
+
<option value="">— Choose comparison image —</option>
|
| 105 |
+
</select>
|
| 106 |
+
<button type="button" class="btn btn-secondary btn-sm dda-slot-pick" data-pick="t2">Browse library</button>
|
| 107 |
</div>
|
| 108 |
</div>
|
| 109 |
+
<div class="dda-compare-library card">
|
| 110 |
+
<div class="card-header">
|
| 111 |
+
<h3>Pick from library</h3>
|
| 112 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-compare-refresh">Refresh list</button>
|
| 113 |
+
</div>
|
| 114 |
+
<p class="sub dim">Click <strong>T1</strong> or <strong>T2</strong> on an image, or use the dropdowns above.</p>
|
| 115 |
+
<div id="compare-lib-grid" class="dda-grid dda-compare-grid">
|
| 116 |
+
<p class="dim">Loading library images…</p>
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
<div class="dda-detect-options">
|
| 120 |
+
<div class="form-group">
|
| 121 |
+
<label for="dda-detect-method">Method</label>
|
| 122 |
+
<select id="dda-detect-method">
|
| 123 |
+
<option value="AI-Based Deep Learning">AI-Based Deep Learning</option>
|
| 124 |
+
<option value="Hybrid AI">Hybrid AI</option>
|
| 125 |
+
<option value="Hybrid Approach">Hybrid Approach</option>
|
| 126 |
+
<option value="Image Difference">Image Difference</option>
|
| 127 |
+
</select>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="form-group">
|
| 130 |
+
<label for="dda-detect-sensitivity">Sensitivity (0–1)</label>
|
| 131 |
+
<input type="number" id="dda-detect-sensitivity" min="0" max="1" step="0.05" value="0.45" />
|
| 132 |
+
</div>
|
| 133 |
+
<div class="form-group">
|
| 134 |
+
<label for="dda-detect-min-area">Min region area (px)</label>
|
| 135 |
+
<input type="number" id="dda-detect-min-area" min="50" max="10000" step="10" value="150" />
|
| 136 |
+
</div>
|
| 137 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-registration" checked /> Image registration</label>
|
| 138 |
+
<label class="dda-check"><input type="checkbox" id="dda-detect-normalization" checked /> Normalization</label>
|
| 139 |
+
</div>
|
| 140 |
+
<p class="dim" id="dda-detect-res-hint"></p>
|
| 141 |
+
<button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection</button>
|
| 142 |
+
<p id="dda-detect-loading" class="dim hidden" style="margin-top:0.75rem">Detection runs as a background job — large GeoTIFFs may take several minutes…</p>
|
| 143 |
</div>
|
| 144 |
</section>
|
| 145 |
|
| 146 |
+
<!-- Tab: Reports (FR-05) -->
|
| 147 |
<section id="tab-reports" class="dda-panel" role="tabpanel">
|
| 148 |
<div class="card">
|
| 149 |
+
<div class="card-header">
|
| 150 |
+
<h3>Detection Reports</h3>
|
| 151 |
+
<button type="button" class="btn btn-secondary btn-sm" id="btn-reports-refresh">Refresh</button>
|
| 152 |
+
</div>
|
| 153 |
+
<p class="sub dim">Completed jobs and detection history. PDF export — coming in Phase 4.</p>
|
| 154 |
+
<div id="reports-list"><p class="dim">Open this tab to load reports.</p></div>
|
| 155 |
</div>
|
| 156 |
</section>
|
| 157 |
</div>
|
| 158 |
|
| 159 |
+
<div id="dda-picker-modal" class="dda-modal hidden" role="dialog" aria-modal="true">
|
| 160 |
+
<div class="dda-modal-panel card">
|
| 161 |
+
<div class="card-header">
|
| 162 |
+
<h3 id="dda-picker-title">Select image</h3>
|
| 163 |
+
<button type="button" class="btn btn-secondary btn-sm" id="dda-picker-close">Close</button>
|
| 164 |
+
</div>
|
| 165 |
+
<div id="dda-picker-list" class="dda-picker-grid"></div>
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
+
|
| 169 |
+
<!-- Result modal — same before/changes slider as production -->
|
| 170 |
+
<div class="result-modal-backdrop hidden" id="result-modal">
|
| 171 |
+
<div class="result-modal">
|
| 172 |
+
<div class="result-modal-header">
|
| 173 |
+
<h3 id="result-modal-title">Detection Result</h3>
|
| 174 |
+
<button type="button" class="btn-close-modal" id="result-modal-close" aria-label="Close">
|
| 175 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 176 |
+
</button>
|
| 177 |
+
</div>
|
| 178 |
+
<div class="result-stats" id="result-stats"></div>
|
| 179 |
+
<div class="zoom-slider-section">
|
| 180 |
+
<div class="zoom-controls">
|
| 181 |
+
<button type="button" class="btn btn-zoom" id="zoom-out" title="Zoom out" aria-label="Zoom out">−</button>
|
| 182 |
+
<span class="zoom-level" id="zoom-level">100%</span>
|
| 183 |
+
<button type="button" class="btn btn-zoom" id="zoom-in" title="Zoom in" aria-label="Zoom in">+</button>
|
| 184 |
+
</div>
|
| 185 |
+
<div class="zoom-wrapper" id="zoom-wrapper">
|
| 186 |
+
<div class="compare-slider compare-slider-zoomed" id="compare-slider">
|
| 187 |
+
<div class="compare-before">
|
| 188 |
+
<img id="compare-before-img" alt="Before" draggable="false" />
|
| 189 |
+
<span class="compare-label compare-label-left">Before</span>
|
| 190 |
+
</div>
|
| 191 |
+
<div class="compare-after" id="compare-after-clip">
|
| 192 |
+
<img id="compare-after-img" alt="Changes detected" draggable="false" />
|
| 193 |
+
<span class="compare-label compare-label-right">Changes</span>
|
| 194 |
+
</div>
|
| 195 |
+
<div class="compare-handle" id="compare-handle">
|
| 196 |
+
<div class="compare-handle-line"></div>
|
| 197 |
+
<div class="compare-handle-knob">
|
| 198 |
+
<svg width="20" height="20" viewBox="0 0 20 20"><path d="M6 10l4-5v10z" fill="currentColor"/><path d="M14 10l-4-5v10z" fill="currentColor"/></svg>
|
| 199 |
+
</div>
|
| 200 |
+
<div class="compare-handle-line"></div>
|
| 201 |
+
</div>
|
| 202 |
+
<div class="region-highlight-overlay" id="region-highlight-overlay"></div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
<div class="regions-table-wrap">
|
| 207 |
+
<table class="regions-table" id="regions-table">
|
| 208 |
+
<thead>
|
| 209 |
+
<tr>
|
| 210 |
+
<th>#</th>
|
| 211 |
+
<th>Change Type</th>
|
| 212 |
+
<th>DDA Type</th>
|
| 213 |
+
<th>Lat/Lng</th>
|
| 214 |
+
<th>Sub-Type</th>
|
| 215 |
+
<th>Severity</th>
|
| 216 |
+
<th>Confidence</th>
|
| 217 |
+
<th>Area (px)</th>
|
| 218 |
+
<th>Coordinates</th>
|
| 219 |
+
<th>Stories</th>
|
| 220 |
+
<th>Height</th>
|
| 221 |
+
<th>Stage</th>
|
| 222 |
+
</tr>
|
| 223 |
+
</thead>
|
| 224 |
+
<tbody id="regions-tbody"></tbody>
|
| 225 |
+
</table>
|
| 226 |
+
<div class="pagination" id="regions-pagination"></div>
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
</div>
|
| 230 |
+
|
| 231 |
+
<script src="/static/js/dda/app.js?v=9"></script>
|
| 232 |
<script src="/static/js/dda/library.js?v=5"></script>
|
| 233 |
+
<script src="/static/js/dda/result.js?v=3"></script>
|
| 234 |
+
<script src="/static/js/dda/compare.js?v=6"></script>
|
| 235 |
+
<script src="/static/js/dda/reports.js?v=1"></script>
|
| 236 |
</body>
|
| 237 |
</html>
|