Files
geointel/frontend/src/components/quality/QualityResultsPanel.tsx
T
Codex b9674a0e42
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled
Add QA evidence map overlay
2026-06-25 03:17:49 +02:00

526 lines
23 KiB
TypeScript

import { useMemo, useState } from 'react'
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
const CORE_METRIC_ORDER = [
'precision',
'recall',
'f1',
'mean_iou',
'false_positive_count',
'false_negative_count',
]
interface QualityResultsPanelProps {
selectedProjectId: string | null
qualityChecks: QualityCheckRead[]
qualityChecksError: string | null
candidateDatasets: DatasetCreateResponse[]
referenceDatasets: DatasetCreateResponse[]
onRefresh: () => void
onOpenEvidenceMap?: (qualityCheckId: string) => void
evidenceLoading?: boolean
evidenceError?: string | null
}
function qualityMetricLabel(metricKey: string): string {
const labels: Record<string, string> = {
precision: 'Precision',
recall: 'Recall',
f1: 'F1',
mean_iou: 'Mean IoU',
false_positive_count: 'False positives',
false_negative_count: 'False negatives',
}
return labels[metricKey] ?? metricKey.replaceAll('_', ' ')
}
function qualityMetricValue(metric: MetricRead | undefined): string {
if (!metric || metric.metric_value === null || metric.metric_value === undefined) {
return 'n/a'
}
const value = Number(metric.metric_value)
if (!Number.isFinite(value)) {
return String(metric.metric_value)
}
return Number.isInteger(value) ? String(value) : value.toFixed(3)
}
function metricByKey(check: QualityCheckRead | null, metricKey: string): MetricRead | undefined {
return check?.metrics.find((metric) => metric.metric_key === metricKey)
}
function findingEvidenceList(check: QualityCheckRead | null, key: string): Record<string, unknown>[] {
const value = check?.findings_json?.[key]
return Array.isArray(value) ? value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === 'object' && !Array.isArray(item)) : []
}
function evidenceLabel(item: Record<string, unknown>): string {
const candidate = item['candidate_feature_id']
const reference = item['reference_feature_id']
const iou = item['iou']
const pairLabel = [candidate ? `Candidate ${candidate}` : null, reference ? `Reference ${reference}` : null].filter(Boolean).join(' / ')
const iouValue = Number(iou)
return iou === null || iou === undefined || !Number.isFinite(iouValue) ? pairLabel || JSON.stringify(item) : `${pairLabel || 'Match'} / IoU ${iouValue.toFixed(3)}`
}
function formatQualityTimestamp(value?: string | null): string {
return value || 'n/a'
}
function qualityMatchesSearch(check: QualityCheckRead, query: string, datasetNameById: Map<string, string>): boolean {
const normalizedQuery = query.trim().toLowerCase()
if (!normalizedQuery) {
return true
}
const candidateName = check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) : null
const referenceName = datasetNameById.get(check.reference_dataset_id)
const searchable = [
check.id,
check.check_type,
check.status,
check.candidate_dataset_id,
check.reference_dataset_id,
check.analysis_run_id,
check.job_id,
candidateName,
referenceName,
]
.filter(Boolean)
.join(' ')
.toLowerCase()
return searchable.includes(normalizedQuery)
}
export function QualityResultsPanel({
selectedProjectId,
qualityChecks,
qualityChecksError,
candidateDatasets,
referenceDatasets,
onRefresh,
onOpenEvidenceMap,
evidenceLoading = false,
evidenceError = null,
}: QualityResultsPanelProps): JSX.Element {
const [showAllQualityChecks, setShowAllQualityChecks] = useState(false)
const [qualityStatusFilter, setQualityStatusFilter] = useState('all')
const [qualityTypeFilter, setQualityTypeFilter] = useState('all')
const [qualitySearchQuery, setQualitySearchQuery] = useState('')
const [selectedQualityCheckId, setSelectedQualityCheckId] = useState<string | null>(null)
const completedChecks = qualityChecks.filter((check) => check.status === 'ok' || check.status === 'completed').length
const latestCheck = qualityChecks[0] ?? null
const datasetNameById = useMemo(() => {
const lookup = new Map<string, string>()
for (const dataset of [...candidateDatasets, ...referenceDatasets]) {
lookup.set(dataset.id, dataset.name)
}
return lookup
}, [candidateDatasets, referenceDatasets])
const latestCandidateName = latestCheck?.candidate_dataset_id ? datasetNameById.get(latestCheck.candidate_dataset_id) : null
const latestReferenceName = latestCheck?.reference_dataset_id ? datasetNameById.get(latestCheck.reference_dataset_id) : null
const selectedQualityCheck = useMemo(
() => qualityChecks.find((check) => check.id === selectedQualityCheckId) ?? latestCheck,
[latestCheck, qualityChecks, selectedQualityCheckId],
)
const selectedCandidateName = selectedQualityCheck?.candidate_dataset_id
? datasetNameById.get(selectedQualityCheck.candidate_dataset_id) ?? selectedQualityCheck.candidate_dataset_id
: 'n/a'
const selectedReferenceName = selectedQualityCheck
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? selectedQualityCheck.reference_dataset_id
: 'n/a'
const selectedMatchEvidence = findingEvidenceList(selectedQualityCheck, 'match_evidence')
const selectedFalsePositiveEvidence = findingEvidenceList(selectedQualityCheck, 'false_positive_evidence')
const selectedFalseNegativeEvidence = findingEvidenceList(selectedQualityCheck, 'false_negative_evidence')
const qualityStatuses = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.status))).sort(), [qualityChecks])
const qualityTypes = useMemo(() => Array.from(new Set(qualityChecks.map((check) => check.check_type))).sort(), [qualityChecks])
const filteredQualityChecks = useMemo(
() =>
qualityChecks.filter((check) => {
const statusMatches = qualityStatusFilter === 'all' || check.status === qualityStatusFilter
const typeMatches = qualityTypeFilter === 'all' || check.check_type === qualityTypeFilter
return statusMatches && typeMatches && qualityMatchesSearch(check, qualitySearchQuery, datasetNameById)
}),
[qualityChecks, qualityStatusFilter, qualityTypeFilter, qualitySearchQuery, datasetNameById],
)
const visibleQualityChecks = showAllQualityChecks ? filteredQualityChecks : filteredQualityChecks.slice(0, 8)
const hiddenQualityCheckCount = Math.max(filteredQualityChecks.length - visibleQualityChecks.length, 0)
const hasActiveQualityFilters = qualityStatusFilter !== 'all' || qualityTypeFilter !== 'all' || qualitySearchQuery.trim().length > 0
return (
<section className="quality-results-panel" data-testid="quality-results-panel">
<div className="quality-results-shell">
<div className="panel-title-row">
<div>
<h2>QA/QC Results</h2>
<p className="muted">Persisted quality checks from dataset, detection and segmentation comparisons.</p>
</div>
<span className="count-pill">{qualityChecks.length} checks</span>
</div>
<div className="quality-summary-surface" aria-label="QA/QC result summary">
<div className="quality-summary-grid">
<div>
<span>Completed</span>
<strong>{completedChecks}</strong>
</div>
<div>
<span>Latest score</span>
<strong>{latestCheck?.score ?? 'n/a'}</strong>
</div>
<div>
<span>Latest status</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
</div>
</div>
</div>
<div className="quality-evidence-surface" aria-label="QA/QC dataset evidence">
<div className="quality-handoff-grid" aria-label="QA/QC dataset handoff context">
<div>
<span>Candidate layers</span>
<strong>{candidateDatasets.length}</strong>
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'No candidate layer selected yet'}</p>
</div>
<div>
<span>Reference layers</span>
<strong>{referenceDatasets.length}</strong>
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'No reference layer available yet'}</p>
</div>
<div>
<span>Latest comparison</span>
<strong>{latestCheck?.status ?? 'waiting'}</strong>
<p className="quality-dataset-name">
Candidate {latestCandidateName ?? latestCheck?.candidate_dataset_id ?? 'n/a'} / Reference {latestReferenceName ?? latestCheck?.reference_dataset_id ?? 'n/a'}
</p>
</div>
</div>
</div>
<div className="quality-drilldown-surface" aria-label="QA/QC evidence drilldown">
<div className="panel-title-row">
<div>
<h3>QA/QC evidence drilldown</h3>
<p className="muted">Inspect the persisted check, metric evidence and dataset handoff context before exporting or sharing results.</p>
</div>
<button
type="button"
className="secondary-action"
onClick={() => setSelectedQualityCheckId(latestCheck?.id ?? null)}
disabled={!latestCheck}
>
Inspect latest check
</button>
<button
type="button"
className="secondary-action"
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading map evidence...' : 'Show latest on map'}
</button>
</div>
{evidenceError ? (
<div className="result-state result-state-error">
<strong>QA/QC evidence overlay could not be loaded.</strong>
<p>{evidenceError}</p>
</div>
) : null}
{selectedQualityCheck ? (
<>
<div className="quality-drilldown-grid">
<div>
<span>Selected check</span>
<strong>{selectedQualityCheck.check_type}</strong>
<p>{selectedQualityCheck.id}</p>
</div>
<div>
<span>Candidate layer</span>
<strong>{selectedCandidateName}</strong>
<p>{selectedQualityCheck.candidate_dataset_id ?? 'candidate not stored'}</p>
</div>
<div>
<span>Reference layer</span>
<strong>{selectedReferenceName}</strong>
<p>{selectedQualityCheck.reference_dataset_id}</p>
</div>
<div>
<span>Analysis run</span>
<strong>{selectedQualityCheck.analysis_run_id ?? 'n/a'}</strong>
<p>Job: {selectedQualityCheck.job_id ?? 'n/a'}</p>
</div>
<div>
<span>Status</span>
<strong>{selectedQualityCheck.status}</strong>
<p>Score: {selectedQualityCheck.score ?? 'n/a'}</p>
</div>
<div>
<span>Completed</span>
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
<p>Created: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
</div>
</div>
<div className="quality-evidence-token-grid">
<div>
<span>False positive evidence</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_positive_count'))}</strong>
<p>Candidate geometries not matched to the reference layer.</p>
</div>
<div>
<span>False negative evidence</span>
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_negative_count'))}</strong>
<p>Reference geometries not matched by the candidate layer.</p>
</div>
<div className="quality-evidence-map-handoff">
<span>Map evidence handoff</span>
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
<p>Use the candidate and reference datasets as map layers for spatial review.</p>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
{evidenceLoading ? 'Loading overlay...' : 'Show evidence overlay'}
</button>
</div>
</div>
<div className="quality-feature-evidence-grid" aria-label="Feature-level QA/QC evidence">
<div>
<span>Matched feature ids</span>
{selectedMatchEvidence.length > 0 ? (
<ul>
{selectedMatchEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>No matched feature ids persisted for this check.</p>
)}
</div>
<div>
<span>False positive feature ids</span>
{selectedFalsePositiveEvidence.length > 0 ? (
<ul>
{selectedFalsePositiveEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>No false-positive feature ids persisted for this check.</p>
)}
</div>
<div>
<span>False negative feature ids</span>
{selectedFalseNegativeEvidence.length > 0 ? (
<ul>
{selectedFalseNegativeEvidence.slice(0, 6).map((item, index) => (
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
))}
</ul>
) : (
<p>No false-negative feature ids persisted for this check.</p>
)}
</div>
</div>
<div className="quality-provenance-grid">
<div>
<span>Parameters</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.parameters_json ?? {}, null, 2)}
</pre>
</div>
<div>
<span>Findings</span>
<pre className="quality-provenance-pre">
{JSON.stringify(selectedQualityCheck.findings_json ?? {}, null, 2)}
</pre>
</div>
</div>
</>
) : (
<div className="result-state result-state-empty">
<strong>No QA/QC check selected.</strong>
<p>Run or load a persisted QA/QC check to inspect metric and map evidence.</p>
</div>
)}
</div>
<div className="quality-control-surface" aria-label="QA/QC refresh and filters">
<div className="button-row">
<button
type="button"
className="secondary-action"
onClick={onRefresh}
disabled={!selectedProjectId}
data-testid="refresh-quality-results"
>
Refresh QA/QC results
</button>
</div>
<div className="quality-result-state-stack">
{qualityChecksError ? (
<div className="result-state result-state-error">
<strong>QA/QC results could not be loaded.</strong>
<p>{qualityChecksError}</p>
</div>
) : null}
{qualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No persisted QA/QC results yet</strong>
<p>Run dataset, detection or segmentation QA against a reference layer to populate this workspace.</p>
</div>
) : null}
</div>
{qualityChecks.length > 0 ? (
<div className="quality-history-controls" aria-label="QA/QC result filters">
<label>
Search QA/QC results
<input
type="search"
placeholder="Type, id or dataset"
value={qualitySearchQuery}
onChange={(event) => setQualitySearchQuery(event.target.value)}
/>
</label>
<label>
Status
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
<option value="all">All statuses</option>
{qualityStatuses.map((status) => (
<option key={status} value={status}>
{status}
</option>
))}
</select>
</label>
<label>
Check type
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
<option value="all">All check types</option>
{qualityTypes.map((checkType) => (
<option key={checkType} value={checkType}>
{checkType}
</option>
))}
</select>
</label>
<button
type="button"
className="secondary-action"
onClick={() => {
setQualitySearchQuery('')
setQualityStatusFilter('all')
setQualityTypeFilter('all')
setShowAllQualityChecks(false)
}}
disabled={!hasActiveQualityFilters && !showAllQualityChecks}
>
Reset view
</button>
</div>
) : null}
{filteredQualityChecks.length > 8 ? (
<div className="list-limit-banner">
<span className="quality-list-count">
Showing {visibleQualityChecks.length} of {filteredQualityChecks.length} matching QA/QC results.
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} older checks hidden.` : ' All matching checks shown.'}
</span>
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
{showAllQualityChecks ? 'Show latest 8' : 'Show all QA/QC results'}
</button>
</div>
) : null}
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
<div className="result-state result-state-empty">
<strong>No QA/QC results match the current filters.</strong>
<p>Clear the search, status or check type filter to return to the latest quality results.</p>
</div>
) : null}
</div>
<div className="quality-history-surface" aria-label="QA/QC result history">
<div className="panel-title-row">
<h3>Result history</h3>
<span className="count-pill">{visibleQualityChecks.length} shown</span>
</div>
<ul className="quality-check-list">
{visibleQualityChecks.map((check) => (
<li className="quality-check-card" key={check.id}>
<div className="quality-check-header">
<div>
<strong>{check.check_type}</strong>
<div className="entity-meta">
<span className="quality-check-dataset-link">
candidate: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? check.candidate_dataset_id : 'n/a'}
</span>
<span className="quality-check-dataset-link">
reference: {datasetNameById.get(check.reference_dataset_id) ?? check.reference_dataset_id}
</span>
</div>
</div>
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
{check.status}
</span>
</div>
<div className="quality-check-actions">
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
Inspect check
</button>
<button
type="button"
className="secondary-action"
onClick={() => onOpenEvidenceMap?.(check.id)}
disabled={evidenceLoading || !onOpenEvidenceMap}
>
Show evidence on map
</button>
</div>
<div className="quality-score-row">
<div>
<span>Score</span>
<strong>{check.score ?? 'n/a'}</strong>
</div>
<div>
<span>Metrics</span>
<strong>{check.metrics.length}</strong>
</div>
<div>
<span>Quality check</span>
<strong>{check.id}</strong>
</div>
</div>
<div className="quality-metric-section">
<span>Metric evidence</span>
<div className="quality-metric-grid">
{CORE_METRIC_ORDER.map((metricKey) => {
const metric = check.metrics.find((item) => item.metric_key === metricKey)
const isCriticalCount = metricKey === 'false_positive_count' || metricKey === 'false_negative_count'
return (
<div className={isCriticalCount ? 'quality-metric-card quality-metric-card-critical' : 'quality-metric-card'} key={metricKey}>
<span>{qualityMetricLabel(metricKey)}</span>
<strong>{qualityMetricValue(metric)}</strong>
</div>
)
})}
</div>
</div>
<span className="metric-list-label">Raw metrics</span>
<ul className="metric-list">
{check.metrics.map((metric) => (
<li key={metric.id}>
<span>{qualityMetricLabel(metric.metric_key)}</span>
<strong>{qualityMetricValue(metric)}</strong>
</li>
))}
</ul>
</li>
))}
</ul>
</div>
</div>
</section>
)
}