Spaces:
Runtime error
Runtime error
feat(demo): add AI Postprocessing toggle for vision-only mode
Browse filesWhen toggled off, the pipeline runs strictly stage 1 (detection/tracking)
with no GPT assessment, no verdict fetching, and label-based colors.
Topbar reflects mode: VISION ONLY / DETECTING / COMPLETE.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- demo/index.html +1 -0
- demo/js/init.js +2 -1
- demo/js/real-backend.js +65 -102
- demo/js/state-machine.js +3 -3
demo/index.html
CHANGED
|
@@ -126,6 +126,7 @@
|
|
| 126 |
<input type="text" id="queriesInput" class="config-input" value="person,car,truck" placeholder="e.g., person,car or Monitor unauthorized personnel...">
|
| 127 |
</div>
|
| 128 |
<div class="config-row">
|
|
|
|
| 129 |
<label class="config-checkbox"><input type="checkbox" id="depthToggle"> Enable Depth</label>
|
| 130 |
</div>
|
| 131 |
</div>
|
|
|
|
| 126 |
<input type="text" id="queriesInput" class="config-input" value="person,car,truck" placeholder="e.g., person,car or Monitor unauthorized personnel...">
|
| 127 |
</div>
|
| 128 |
<div class="config-row">
|
| 129 |
+
<label class="config-checkbox"><input type="checkbox" id="aiToggle" checked> AI Postprocessing</label>
|
| 130 |
<label class="config-checkbox"><input type="checkbox" id="depthToggle"> Enable Depth</label>
|
| 131 |
</div>
|
| 132 |
</div>
|
demo/js/init.js
CHANGED
|
@@ -52,13 +52,14 @@ document.addEventListener('DOMContentLoaded', () => {
|
|
| 52 |
// Gather config from controls
|
| 53 |
const activeMode = document.querySelector('#modeToggle .config-toggle-btn.active');
|
| 54 |
const mode = activeMode ? activeMode.dataset.mode : 'object_detection';
|
|
|
|
| 55 |
const config = {
|
| 56 |
mode: mode,
|
| 57 |
queries: document.getElementById('queriesInput').value || 'person,car,truck',
|
| 58 |
detector: document.getElementById('detectorSelect').value,
|
| 59 |
segmenter: document.getElementById('segmenterSelect').value,
|
| 60 |
enableDepth: document.getElementById('depthToggle').checked,
|
| 61 |
-
mission: document.getElementById('queriesInput').value || null,
|
| 62 |
};
|
| 63 |
|
| 64 |
STATE.jobConfig = config;
|
|
|
|
| 52 |
// Gather config from controls
|
| 53 |
const activeMode = document.querySelector('#modeToggle .config-toggle-btn.active');
|
| 54 |
const mode = activeMode ? activeMode.dataset.mode : 'object_detection';
|
| 55 |
+
const aiEnabled = document.getElementById('aiToggle').checked;
|
| 56 |
const config = {
|
| 57 |
mode: mode,
|
| 58 |
queries: document.getElementById('queriesInput').value || 'person,car,truck',
|
| 59 |
detector: document.getElementById('detectorSelect').value,
|
| 60 |
segmenter: document.getElementById('segmenterSelect').value,
|
| 61 |
enableDepth: document.getElementById('depthToggle').checked,
|
| 62 |
+
mission: aiEnabled ? (document.getElementById('queriesInput').value || null) : null,
|
| 63 |
};
|
| 64 |
|
| 65 |
STATE.jobConfig = config;
|
demo/js/real-backend.js
CHANGED
|
@@ -277,8 +277,7 @@ async function enterRealAnalysis(jobId, status) {
|
|
| 277 |
if (res.status === 410) { ISR.showToast('Job was cancelled.', 6000); resetToReady(); return; }
|
| 278 |
if (res.status === 202) { await new Promise(r => setTimeout(r, 1000)); continue; }
|
| 279 |
if (res.ok) {
|
| 280 |
-
const
|
| 281 |
-
const blob = new Blob([rawBlob], { type: 'video/mp4' });
|
| 282 |
videoBlobUrl = URL.createObjectURL(blob);
|
| 283 |
ISR.STATE._videoBlobUrl = videoBlobUrl;
|
| 284 |
break;
|
|
@@ -301,7 +300,7 @@ async function enterRealAnalysis(jobId, status) {
|
|
| 301 |
processedVideo.classList.remove('hidden');
|
| 302 |
processedVideo.style.display = 'block';
|
| 303 |
await new Promise(resolve => {
|
| 304 |
-
const timeout = setTimeout(resolve,
|
| 305 |
processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
|
| 306 |
processedVideo.load();
|
| 307 |
});
|
|
@@ -385,6 +384,7 @@ async function enterRealAnalysis(jobId, status) {
|
|
| 385 |
// Background work — runs after analysis state is active and interactive
|
| 386 |
async function deferredAnalysisWork(jobId, status) {
|
| 387 |
// Build full track list from sampled keyframes (reuses cache)
|
|
|
|
| 388 |
try {
|
| 389 |
await renderRealTrackList(jobId);
|
| 390 |
} catch (err) { console.warn('[ISR] Deferred track list failed:', err); }
|
|
@@ -614,6 +614,11 @@ async function renderRealTrackList(jobId) {
|
|
| 614 |
}
|
| 615 |
}
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
ISR.STATE._realTracks = Array.from(trackMap.values());
|
| 618 |
ISR.STATE._realTrackIndex = null; // force rebuild on next merge
|
| 619 |
renderTrackListFromData(ISR.STATE._realTracks);
|
|
@@ -694,88 +699,54 @@ function _deduplicateTracksByBbox(tracks) {
|
|
| 694 |
return kept;
|
| 695 |
}
|
| 696 |
|
| 697 |
-
/**
|
| 698 |
-
* Merge fragmented track IDs that represent the same physical object.
|
| 699 |
-
*
|
| 700 |
-
* When the tracker loses and re-acquires an object (common in aerial/SAR footage
|
| 701 |
-
* with low-confidence detections), multiple track IDs get created for the same
|
| 702 |
-
* physical entity. This groups tracks by label + satisfies verdict and keeps
|
| 703 |
-
* only the best representative per group, merging subsidiary track IDs so
|
| 704 |
-
* inspect/selection still works.
|
| 705 |
-
*
|
| 706 |
-
* Grouping key: (label, satisfies). Within each group, keep the track with the
|
| 707 |
-
* highest score as the representative. Attach merged track IDs so the SVG overlay
|
| 708 |
-
* and click handlers can still resolve any constituent ID to the representative.
|
| 709 |
-
*/
|
| 710 |
-
function _mergeFragmentedTracks(tracks) {
|
| 711 |
-
if (tracks.length <= 1) return tracks;
|
| 712 |
-
|
| 713 |
-
// Group by (label, satisfies) — only merge assessed tracks with same verdict
|
| 714 |
-
const groups = new Map(); // key -> [tracks]
|
| 715 |
-
const ungrouped = [];
|
| 716 |
-
|
| 717 |
-
for (const t of tracks) {
|
| 718 |
-
// Only merge tracks that have been assessed with a definite verdict
|
| 719 |
-
if (t.assessment_status === 'ASSESSED' && t.satisfies !== undefined && t.satisfies !== null) {
|
| 720 |
-
const key = `${(t.label || '').toLowerCase()}::${t.satisfies}`;
|
| 721 |
-
if (!groups.has(key)) groups.set(key, []);
|
| 722 |
-
groups.get(key).push(t);
|
| 723 |
-
} else {
|
| 724 |
-
ungrouped.push(t);
|
| 725 |
-
}
|
| 726 |
-
}
|
| 727 |
-
|
| 728 |
-
const merged = [];
|
| 729 |
-
for (const [, group] of groups) {
|
| 730 |
-
// Sort by score descending — best representative first
|
| 731 |
-
group.sort((a, b) => (b.score || 0) - (a.score || 0));
|
| 732 |
-
const best = { ...group[0] };
|
| 733 |
-
// Collect all subsidiary track IDs
|
| 734 |
-
if (group.length > 1) {
|
| 735 |
-
best._merged_track_ids = group.map(t => t.track_id);
|
| 736 |
-
}
|
| 737 |
-
merged.push(best);
|
| 738 |
-
}
|
| 739 |
-
|
| 740 |
-
return merged.concat(ungrouped);
|
| 741 |
-
}
|
| 742 |
-
|
| 743 |
function renderTrackListFromData(tracks) {
|
| 744 |
const panel = document.getElementById('tracksPanel');
|
| 745 |
panel.innerHTML = '';
|
| 746 |
|
| 747 |
-
// Apply assessment cache to all tracks
|
| 748 |
-
const enriched =
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
|
|
|
| 767 |
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
|
| 778 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 779 |
|
| 780 |
// If there's a filter, add a clear filter button
|
| 781 |
if (ISR.STATE.trackFilter) {
|
|
@@ -801,8 +772,7 @@ function renderTrackListFromData(tracks) {
|
|
| 801 |
|
| 802 |
const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
|
| 803 |
const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
|
| 804 |
-
const
|
| 805 |
-
const idStr = mergedIds ? `IDs:${mergedIds.join(',')}` : `ID:${t.track_id}`;
|
| 806 |
|
| 807 |
// Status badge
|
| 808 |
const badge = ISR.getStatusBadgeHTML(t);
|
|
@@ -823,7 +793,7 @@ function renderTrackListFromData(tracks) {
|
|
| 823 |
'</div>';
|
| 824 |
}
|
| 825 |
|
| 826 |
-
const mc = ISR.getMissionColor(t);
|
| 827 |
card.innerHTML = `
|
| 828 |
<div class="track-dot" style="background: ${mc.solid}; box-shadow: 0 0 4px ${mc.solid};"></div>
|
| 829 |
<div class="track-info">
|
|
@@ -993,9 +963,9 @@ function renderSvgOverlay(tracks) {
|
|
| 993 |
const nw = nx2 - nx1;
|
| 994 |
const nh = ny2 - ny1;
|
| 995 |
|
| 996 |
-
// Determine color
|
| 997 |
-
const _mc = ISR.getMissionColor(t);
|
| 998 |
-
const strokeColor = _mc.stroke;
|
| 999 |
|
| 1000 |
const isInspected = ISR.STATE.selectedTrackId === t.track_id;
|
| 1001 |
const isInSelection = ISR.STATE.selectedTrackIds.includes(t.track_id);
|
|
@@ -1195,24 +1165,17 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
|
|
| 1195 |
const label = t.label_name || t.label || 'object';
|
| 1196 |
const time = ISR.formatTime(frameIdx / fps);
|
| 1197 |
|
| 1198 |
-
//
|
| 1199 |
-
// Objects like "dog", "cat", "mouse" clutter the timeline when they
|
| 1200 |
-
// have no bearing on the mission objective.
|
| 1201 |
-
const isMissionRelevant = t.mission_relevant !== false;
|
| 1202 |
-
|
| 1203 |
-
// First detection of this track ID (emit once, only for mission-relevant)
|
| 1204 |
if (!seenIds.has(tid)) {
|
| 1205 |
seenIds.add(tid);
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
});
|
| 1212 |
-
}
|
| 1213 |
}
|
| 1214 |
|
| 1215 |
-
// Track reappeared after being lost
|
| 1216 |
if (lostTracks.has(tid)) {
|
| 1217 |
lostTracks.delete(tid);
|
| 1218 |
events.push({
|
|
@@ -1233,8 +1196,8 @@ async function deriveTimelineEvents(jobId, summary, frameCounts) {
|
|
| 1233 |
});
|
| 1234 |
}
|
| 1235 |
|
| 1236 |
-
// Assessed as
|
| 1237 |
-
if (t.satisfies === false &&
|
| 1238 |
assessedIds.add(tid);
|
| 1239 |
events.push({
|
| 1240 |
frame: frameIdx, time, type: 'system',
|
|
|
|
| 277 |
if (res.status === 410) { ISR.showToast('Job was cancelled.', 6000); resetToReady(); return; }
|
| 278 |
if (res.status === 202) { await new Promise(r => setTimeout(r, 1000)); continue; }
|
| 279 |
if (res.ok) {
|
| 280 |
+
const blob = await res.blob();
|
|
|
|
| 281 |
videoBlobUrl = URL.createObjectURL(blob);
|
| 282 |
ISR.STATE._videoBlobUrl = videoBlobUrl;
|
| 283 |
break;
|
|
|
|
| 300 |
processedVideo.classList.remove('hidden');
|
| 301 |
processedVideo.style.display = 'block';
|
| 302 |
await new Promise(resolve => {
|
| 303 |
+
const timeout = setTimeout(resolve, 5000); // safety timeout
|
| 304 |
processedVideo.addEventListener('canplay', () => { clearTimeout(timeout); resolve(); }, { once: true });
|
| 305 |
processedVideo.load();
|
| 306 |
});
|
|
|
|
| 384 |
// Background work — runs after analysis state is active and interactive
|
| 385 |
async function deferredAnalysisWork(jobId, status) {
|
| 386 |
// Build full track list from sampled keyframes (reuses cache)
|
| 387 |
+
// (renderRealTrackList calls fetchVerdicts internally)
|
| 388 |
try {
|
| 389 |
await renderRealTrackList(jobId);
|
| 390 |
} catch (err) { console.warn('[ISR] Deferred track list failed:', err); }
|
|
|
|
| 614 |
}
|
| 615 |
}
|
| 616 |
|
| 617 |
+
// Bulk-load all verdicts into assessment cache (skip if no mission)
|
| 618 |
+
if (ISR.STATE.mission) {
|
| 619 |
+
await ISR.fetchVerdicts(jobId);
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
ISR.STATE._realTracks = Array.from(trackMap.values());
|
| 623 |
ISR.STATE._realTrackIndex = null; // force rebuild on next merge
|
| 624 |
renderTrackListFromData(ISR.STATE._realTracks);
|
|
|
|
| 699 |
return kept;
|
| 700 |
}
|
| 701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
function renderTrackListFromData(tracks) {
|
| 703 |
const panel = document.getElementById('tracksPanel');
|
| 704 |
panel.innerHTML = '';
|
| 705 |
|
| 706 |
+
// Apply assessment cache to all tracks (only when AI postprocessing is active)
|
| 707 |
+
const enriched = ISR.STATE.mission
|
| 708 |
+
? tracks.map(t => ISR.applyAssessmentCache({...t}))
|
| 709 |
+
: tracks.map(t => ({...t}));
|
| 710 |
+
|
| 711 |
+
let filtered;
|
| 712 |
+
if (ISR.STATE.mission) {
|
| 713 |
+
// Verdict-based filtering: show only matches if verdicts exist
|
| 714 |
+
const hasVerdicts = enriched.some(t => t.satisfies === true || t.satisfies === false);
|
| 715 |
+
const relevant = hasVerdicts
|
| 716 |
+
? enriched.filter(t => t.satisfies === true)
|
| 717 |
+
: enriched;
|
| 718 |
+
const deduped = _deduplicateTracksByBbox(relevant);
|
| 719 |
+
filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
|
| 720 |
+
|
| 721 |
+
if (filtered.length === 0) {
|
| 722 |
+
panel.innerHTML = hasVerdicts
|
| 723 |
+
? '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No objects match the mission criteria.</div>'
|
| 724 |
+
: '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">Awaiting mission assessment...</div>';
|
| 725 |
+
return;
|
| 726 |
+
}
|
| 727 |
|
| 728 |
+
// Sort: mission-relevant first, then assessed before unassessed, then by confidence
|
| 729 |
+
const _statusOrder = { 'ASSESSED': 0, 'UNASSESSED': 1, 'STALE': 2 };
|
| 730 |
+
filtered.sort((a, b) => {
|
| 731 |
+
const aMR = a.mission_relevant === true ? 0 : 1;
|
| 732 |
+
const bMR = b.mission_relevant === true ? 0 : 1;
|
| 733 |
+
if (aMR !== bMR) return aMR - bMR;
|
| 734 |
+
const aStatus = _statusOrder[a.assessment_status] ?? 1;
|
| 735 |
+
const bStatus = _statusOrder[b.assessment_status] ?? 1;
|
| 736 |
+
if (aStatus !== bStatus) return aStatus - bStatus;
|
| 737 |
+
return (b.score || 0) - (a.score || 0);
|
| 738 |
+
});
|
| 739 |
+
} else {
|
| 740 |
+
// Vision-only mode: show all tracks sorted by confidence
|
| 741 |
+
const deduped = _deduplicateTracksByBbox(enriched);
|
| 742 |
+
filtered = ISR.STATE.trackFilter ? deduped.filter(t => t.type === ISR.STATE.trackFilter) : deduped;
|
| 743 |
+
filtered.sort((a, b) => (b.score || 0) - (a.score || 0));
|
| 744 |
+
|
| 745 |
+
if (filtered.length === 0) {
|
| 746 |
+
panel.innerHTML = '<div style="color: var(--text-tertiary); font-size: 10px; text-align: center; padding: 20px;">No detections found.</div>';
|
| 747 |
+
return;
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
|
| 751 |
// If there's a filter, add a clear filter button
|
| 752 |
if (ISR.STATE.trackFilter) {
|
|
|
|
| 772 |
|
| 773 |
const speedStr = t.speed_kph ? `${t.speed_kph.toFixed(1)} kph` : '--';
|
| 774 |
const dirStr = t.direction_clock ? `${t.direction_clock}h` : '';
|
| 775 |
+
const idStr = `ID:${t.track_id}`;
|
|
|
|
| 776 |
|
| 777 |
// Status badge
|
| 778 |
const badge = ISR.getStatusBadgeHTML(t);
|
|
|
|
| 793 |
'</div>';
|
| 794 |
}
|
| 795 |
|
| 796 |
+
const mc = ISR.STATE.mission ? ISR.getMissionColor(t) : { solid: t.color || '#64748b' };
|
| 797 |
card.innerHTML = `
|
| 798 |
<div class="track-dot" style="background: ${mc.solid}; box-shadow: 0 0 4px ${mc.solid};"></div>
|
| 799 |
<div class="track-info">
|
|
|
|
| 963 |
const nw = nx2 - nx1;
|
| 964 |
const nh = ny2 - ny1;
|
| 965 |
|
| 966 |
+
// Determine color: mission-based if AI postprocessing enabled, label-based otherwise
|
| 967 |
+
const _mc = ISR.STATE.mission ? ISR.getMissionColor(t) : null;
|
| 968 |
+
const strokeColor = _mc ? _mc.stroke : (t.color || 'rgba(100, 116, 139, 0.5)');
|
| 969 |
|
| 970 |
const isInspected = ISR.STATE.selectedTrackId === t.track_id;
|
| 971 |
const isInSelection = ISR.STATE.selectedTrackIds.includes(t.track_id);
|
|
|
|
| 1165 |
const label = t.label_name || t.label || 'object';
|
| 1166 |
const time = ISR.formatTime(frameIdx / fps);
|
| 1167 |
|
| 1168 |
+
// First detection of this track ID (emit once)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1169 |
if (!seenIds.has(tid)) {
|
| 1170 |
seenIds.add(tid);
|
| 1171 |
+
events.push({
|
| 1172 |
+
frame: frameIdx, time, type: 'detection',
|
| 1173 |
+
label: `New ${label}`, description: `Track ${tid} first detected.`,
|
| 1174 |
+
priority: 'normal',
|
| 1175 |
+
});
|
|
|
|
|
|
|
| 1176 |
}
|
| 1177 |
|
| 1178 |
+
// Track reappeared after being lost
|
| 1179 |
if (lostTracks.has(tid)) {
|
| 1180 |
lostTracks.delete(tid);
|
| 1181 |
events.push({
|
|
|
|
| 1196 |
});
|
| 1197 |
}
|
| 1198 |
|
| 1199 |
+
// Assessed as not relevant — emit ONCE per track
|
| 1200 |
+
if (t.satisfies === false && !assessedIds.has(tid)) {
|
| 1201 |
assessedIds.add(tid);
|
| 1202 |
events.push({
|
| 1203 |
frame: frameIdx, time, type: 'system',
|
demo/js/state-machine.js
CHANGED
|
@@ -47,7 +47,7 @@ function onStateChange(prev, newState) {
|
|
| 47 |
topDot.style.background = 'var(--accent)';
|
| 48 |
topDot.classList.remove('topbar-dot-processing');
|
| 49 |
}
|
| 50 |
-
if (missionLabel) missionLabel.textContent = 'MISSION ACTIVE';
|
| 51 |
} else if (newState === 'processing') {
|
| 52 |
if (topDot) {
|
| 53 |
topDot.style.color = 'var(--warning)';
|
|
@@ -56,7 +56,7 @@ function onStateChange(prev, newState) {
|
|
| 56 |
}
|
| 57 |
if (missionLabel) {
|
| 58 |
missionLabel.style.color = 'var(--warning)';
|
| 59 |
-
missionLabel.textContent = 'PROCESSING';
|
| 60 |
}
|
| 61 |
} else if (newState === 'analysis' || newState === 'playing') {
|
| 62 |
if (topDot) {
|
|
@@ -66,7 +66,7 @@ function onStateChange(prev, newState) {
|
|
| 66 |
}
|
| 67 |
if (missionLabel) {
|
| 68 |
missionLabel.style.color = 'var(--success)';
|
| 69 |
-
missionLabel.textContent = 'ANALYSIS';
|
| 70 |
}
|
| 71 |
} else if (newState === 'inspect') {
|
| 72 |
if (topDot) {
|
|
|
|
| 47 |
topDot.style.background = 'var(--accent)';
|
| 48 |
topDot.classList.remove('topbar-dot-processing');
|
| 49 |
}
|
| 50 |
+
if (missionLabel) missionLabel.textContent = ISR.STATE.mission ? 'MISSION ACTIVE' : 'VISION ONLY';
|
| 51 |
} else if (newState === 'processing') {
|
| 52 |
if (topDot) {
|
| 53 |
topDot.style.color = 'var(--warning)';
|
|
|
|
| 56 |
}
|
| 57 |
if (missionLabel) {
|
| 58 |
missionLabel.style.color = 'var(--warning)';
|
| 59 |
+
missionLabel.textContent = ISR.STATE.mission ? 'PROCESSING' : 'DETECTING';
|
| 60 |
}
|
| 61 |
} else if (newState === 'analysis' || newState === 'playing') {
|
| 62 |
if (topDot) {
|
|
|
|
| 66 |
}
|
| 67 |
if (missionLabel) {
|
| 68 |
missionLabel.style.color = 'var(--success)';
|
| 69 |
+
missionLabel.textContent = ISR.STATE.mission ? 'ANALYSIS' : 'COMPLETE';
|
| 70 |
}
|
| 71 |
} else if (newState === 'inspect') {
|
| 72 |
if (topDot) {
|