526 lines
23 KiB
TypeScript
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>
|
|
)
|
|
}
|