coderuday21 Cursor commited on
Commit
deaa6d8
·
1 Parent(s): 79d533c

Fix Run Detection: use /api/dda/detect/from-library and deploy compare UI.

Browse files

The 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 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=31
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: 1.5rem;
179
- min-height: 140px;
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, '&lt;');
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=3" />
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 2 GB each).</p>
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 (FR-03 placeholder — Phase 2) -->
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. Async job pipeline — Phase 2.</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
- <p class="dim">Drag from library or click to pick</p>
 
 
 
 
95
  </div>
96
  <div class="dda-slot" id="slot-t2" data-slot="t2">
97
  <span class="dda-slot-label">Comparison Image (T2)</span>
98
- <p class="dim">Drag from library or click to pick</p>
 
 
 
 
99
  </div>
100
  </div>
101
- <button type="button" class="btn btn-primary" id="btn-run-job" disabled>Run Detection (coming Phase 2)</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  </div>
103
  </section>
104
 
105
- <!-- Tab: Reports (FR-05 placeholder) -->
106
  <section id="tab-reports" class="dda-panel" role="tabpanel">
107
  <div class="card">
108
- <div class="card-header"><h3>Detection Reports</h3></div>
109
- <p class="dim">Completed detection reports and PDF export — Phase 4.</p>
110
- <div id="reports-list"></div>
 
 
 
111
  </div>
112
  </section>
113
  </div>
114
 
115
- <script src="/static/js/dda/app.js?v=5"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>