Initial public release
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { qaApi } from '../../services/api'
|
||||
import type {
|
||||
DetectionEvidenceRole,
|
||||
DetectionReviewDecision,
|
||||
DetectionReviewList,
|
||||
DetectionReviewRead,
|
||||
} from '../../types'
|
||||
import { formatError } from '../../lib/formatError'
|
||||
|
||||
interface DetectionReviewPanelProps {
|
||||
projectId: string
|
||||
qualityCheckId: string
|
||||
reviewLocked?: boolean
|
||||
onOpenEvidenceMap?: (qualityCheckId: string) => void
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<DetectionEvidenceRole, string> = {
|
||||
false_positive: 'Onterecht gevonden',
|
||||
false_negative: 'Gemist gebouw',
|
||||
}
|
||||
|
||||
const DECISION_LABELS: Record<DetectionReviewDecision, string> = {
|
||||
confirmed_model_false_positive: 'Bevestigde foutdetectie',
|
||||
confirmed_model_false_negative: 'Bevestigd gemist gebouw',
|
||||
reference_gap_or_change: 'Referentie ontbreekt of is verouderd',
|
||||
qa_alignment_mismatch: 'Vormvergelijking is te streng',
|
||||
imagery_obscured_or_uncertain: 'Luchtbeeld is onduidelijk',
|
||||
uncertain: 'Verder onderzoek nodig',
|
||||
unreviewed: 'Nog niet beoordeeld',
|
||||
}
|
||||
|
||||
const ROLE_DECISIONS: Record<DetectionEvidenceRole, DetectionReviewDecision[]> = {
|
||||
false_positive: [
|
||||
'unreviewed',
|
||||
'confirmed_model_false_positive',
|
||||
'reference_gap_or_change',
|
||||
'qa_alignment_mismatch',
|
||||
'uncertain',
|
||||
],
|
||||
false_negative: [
|
||||
'unreviewed',
|
||||
'confirmed_model_false_negative',
|
||||
'reference_gap_or_change',
|
||||
'qa_alignment_mismatch',
|
||||
'imagery_obscured_or_uncertain',
|
||||
'uncertain',
|
||||
],
|
||||
}
|
||||
|
||||
function shortId(value: string): string {
|
||||
return value.length > 18 ? `${value.slice(0, 8)}...${value.slice(-6)}` : value
|
||||
}
|
||||
|
||||
function formatScore(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
|
||||
}
|
||||
|
||||
export function DetectionReviewPanel({
|
||||
projectId,
|
||||
qualityCheckId,
|
||||
reviewLocked = false,
|
||||
onOpenEvidenceMap,
|
||||
}: DetectionReviewPanelProps): JSX.Element {
|
||||
const [queue, setQueue] = useState<DetectionReviewList | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [roleFilter, setRoleFilter] = useState<'all' | DetectionEvidenceRole>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'reviewed' | 'unreviewed'>('unreviewed')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [draftDecisions, setDraftDecisions] = useState<Record<string, DetectionReviewDecision>>({})
|
||||
const [draftNotes, setDraftNotes] = useState<Record<string, string>>({})
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await qaApi.listDetectionReviews(projectId, qualityCheckId, {
|
||||
evidenceRole: roleFilter === 'all' ? undefined : roleFilter,
|
||||
reviewed: statusFilter === 'all' ? undefined : statusFilter === 'reviewed',
|
||||
limit: 50,
|
||||
offset,
|
||||
})
|
||||
setQueue(result)
|
||||
setDraftDecisions(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.decision])))
|
||||
setDraftNotes(Object.fromEntries(result.items.map((item) => [reviewKey(item), item.notes ?? ''])))
|
||||
} catch (caught) {
|
||||
setError(formatError(caught, 'De controlelijst kon niet worden geladen'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [projectId, qualityCheckId, roleFilter, statusFilter, offset])
|
||||
|
||||
const save = async (item: DetectionReviewRead) => {
|
||||
const key = reviewKey(item)
|
||||
setSavingKey(key)
|
||||
setError(null)
|
||||
try {
|
||||
await qaApi.upsertDetectionReview(projectId, qualityCheckId, {
|
||||
evidence_role: item.evidence_role,
|
||||
evidence_feature_id: item.evidence_feature_id,
|
||||
decision: draftDecisions[key] ?? item.decision,
|
||||
notes: draftNotes[key]?.trim() || null,
|
||||
reviewed_by: 'operator',
|
||||
})
|
||||
await load()
|
||||
} catch (caught) {
|
||||
setError(formatError(caught, 'De beoordeling kon niet worden bewaard'))
|
||||
} finally {
|
||||
setSavingKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="detection-review-panel" aria-label="Handmatige controle van beeldanalyse">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>{reviewLocked ? 'Beoordelingen bekijken' : 'Fouten controleren'}</h3>
|
||||
<p className="muted">
|
||||
{reviewLocked
|
||||
? 'De demo toont het bestaande kwaliteitsbewijs zonder beoordelingen te wijzigen.'
|
||||
: 'Beoordeel alleen twijfelgevallen. Bevestigde fouten kunnen later veilig als trainingsfeedback worden gebruikt.'}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="secondary-action" onClick={() => onOpenEvidenceMap?.(qualityCheckId)}>
|
||||
Op kaart bekijken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{queue ? (
|
||||
<div className="detection-review-summary">
|
||||
<div><span>Te beoordelen</span><strong>{queue.summary.total}</strong></div>
|
||||
<div><span>Afgerond</span><strong>{queue.summary.reviewed}</strong></div>
|
||||
<div><span>Resterend</span><strong>{queue.summary.remaining}</strong></div>
|
||||
<div><span>Fout gevonden</span><strong>{queue.summary.false_positive_total}</strong></div>
|
||||
<div><span>Gemist</span><strong>{queue.summary.false_negative_total}</strong></div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{queue?.summary.reviewed_metrics ? (
|
||||
<div className="detection-review-adjudicated" aria-label="Score na beoordeling">
|
||||
<div className="summary-grid">
|
||||
<div>
|
||||
<span>Precisie zoals gemeten</span>
|
||||
<strong>{formatScore(queue.summary.reviewed_metrics.raw.precision)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Precisie na uw beoordeling</span>
|
||||
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.precision)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Herkenningsgraad na beoordeling</span>
|
||||
<strong>{formatScore(queue.summary.reviewed_metrics.adjudicated.recall)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p className="geo-data-notice">
|
||||
{queue.summary.reviewed_metrics.review_complete
|
||||
? `Alle bevindingen zijn beoordeeld. ${queue.summary.reviewed_metrics.false_positive_breakdown.exonerated} onterecht gevonden objecten en ${queue.summary.reviewed_metrics.false_negative_breakdown.exonerated} gemiste objecten bleken een hiaat in de referentielaag, niet een modelfout.`
|
||||
: `Nog ${queue.summary.reviewed_metrics.false_positive_breakdown.unreviewed + queue.summary.reviewed_metrics.false_negative_breakdown.unreviewed} bevindingen onbeoordeeld. De precisie ligt tussen ${formatScore(queue.summary.reviewed_metrics.pessimistic.precision)} en ${formatScore(queue.summary.reviewed_metrics.optimistic.precision)}; de getoonde waarde rekent elke onbeoordeelde bevinding nog als modelfout.`}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="detection-review-filters">
|
||||
<label>
|
||||
Soort
|
||||
<select value={roleFilter} onChange={(event) => {
|
||||
setRoleFilter(event.target.value as typeof roleFilter)
|
||||
setOffset(0)
|
||||
}}>
|
||||
<option value="all">Alles</option>
|
||||
<option value="false_positive">Onterecht gevonden</option>
|
||||
<option value="false_negative">Gemist gebouw</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select value={statusFilter} onChange={(event) => {
|
||||
setStatusFilter(event.target.value as typeof statusFilter)
|
||||
setOffset(0)
|
||||
}}>
|
||||
<option value="unreviewed">Nog te beoordelen</option>
|
||||
<option value="reviewed">Beoordeeld</option>
|
||||
<option value="all">Alles</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="secondary-action" disabled={loading} onClick={() => void load()}>
|
||||
{loading ? 'Laden...' : 'Vernieuwen'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error ? <p className="error" role="alert">{error}</p> : null}
|
||||
{!loading && queue && queue.items.length === 0 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen objecten in deze selectie</strong>
|
||||
<p>Pas de filters aan of open de bewijslaag op de kaart.</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ol className="detection-review-list">
|
||||
{queue?.items?.map((item) => {
|
||||
const key = reviewKey(item)
|
||||
const decision = draftDecisions[key] ?? item.decision
|
||||
return (
|
||||
<li key={key} className="detection-review-item">
|
||||
<div className="detection-review-item-heading">
|
||||
<div>
|
||||
<span>{ROLE_LABELS[item.evidence_role]}</span>
|
||||
<strong>{item.class_name ?? 'gebouw'} / {shortId(item.evidence_feature_id)}</strong>
|
||||
</div>
|
||||
{typeof item.confidence === 'number' ? <span className="count-pill">{Math.round(item.confidence * 100)}% vertrouwen</span> : null}
|
||||
</div>
|
||||
<div className="detection-review-editor">
|
||||
<label>
|
||||
Beoordeling
|
||||
<select
|
||||
value={decision}
|
||||
disabled={reviewLocked}
|
||||
onChange={(event) => setDraftDecisions((current) => ({
|
||||
...current,
|
||||
[key]: event.target.value as DetectionReviewDecision,
|
||||
}))}
|
||||
>
|
||||
{ROLE_DECISIONS[item.evidence_role].map((option) => (
|
||||
<option key={option} value={option}>{DECISION_LABELS[option]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Notitie
|
||||
<input
|
||||
type="text"
|
||||
maxLength={2000}
|
||||
placeholder="Waarom is dit correct, fout of onzeker?"
|
||||
value={draftNotes[key] ?? ''}
|
||||
readOnly={reviewLocked}
|
||||
onChange={(event) => setDraftNotes((current) => ({ ...current, [key]: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="primary-action" disabled={reviewLocked || savingKey === key} onClick={() => void save(item)}>
|
||||
{savingKey === key ? 'Bewaren...' : 'Beoordeling bewaren'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{queue && queue.total > queue.limit ? (
|
||||
<div className="detection-review-pagination" aria-label="Pagina's van de controlelijst">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
disabled={offset === 0 || loading}
|
||||
onClick={() => setOffset((current) => Math.max(current - queue.limit, 0))}
|
||||
>
|
||||
Vorige
|
||||
</button>
|
||||
<span>{offset + 1}-{Math.min(offset + queue.limit, queue.total)} van {queue.total}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
disabled={offset + queue.limit >= queue.total || loading}
|
||||
onClick={() => setOffset((current) => current + queue.limit)}
|
||||
>
|
||||
Volgende
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function reviewKey(item: Pick<DetectionReviewRead, 'evidence_role' | 'evidence_feature_id'>): string {
|
||||
return `${item.evidence_role}:${item.evidence_feature_id}`
|
||||
}
|
||||
@@ -0,0 +1,643 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { DatasetCreateResponse, MetricRead, QualityCheckRead } from '../../types'
|
||||
import { DetectionReviewPanel } from './DetectionReviewPanel'
|
||||
|
||||
const CORE_METRIC_ORDER = [
|
||||
'precision',
|
||||
'recall',
|
||||
'f1',
|
||||
'mean_iou',
|
||||
'false_positive_count',
|
||||
'false_negative_count',
|
||||
]
|
||||
|
||||
interface QualityResultsPanelProps {
|
||||
reviewLocked?: boolean
|
||||
selectedProjectId: string | null
|
||||
qualityChecks: QualityCheckRead[]
|
||||
qualityChecksError: string | null
|
||||
candidateDatasets: DatasetCreateResponse[]
|
||||
referenceDatasets: DatasetCreateResponse[]
|
||||
onRefresh: () => void
|
||||
onOpenEvidenceMap?: (qualityCheckId: string) => void
|
||||
evidenceLoading?: boolean
|
||||
evidenceError?: string | null
|
||||
onOpenMapWorkspace: () => void
|
||||
onOpenAnalysisWorkspace: () => void
|
||||
}
|
||||
|
||||
function qualityMetricLabel(metricKey: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
precision: 'Precisie',
|
||||
recall: 'Teruggevonden aandeel',
|
||||
f1: 'F1',
|
||||
mean_iou: 'Gemiddelde overlap',
|
||||
false_positive_count: 'Onterecht gevonden',
|
||||
false_negative_count: 'Gemiste objecten',
|
||||
}
|
||||
return labels[metricKey] ?? metricKey.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
function qualityMetricValue(metric: MetricRead | undefined): string {
|
||||
if (!metric || metric.metric_value === null || metric.metric_value === undefined) {
|
||||
return 'n.v.t.'
|
||||
}
|
||||
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 qualityScoreValue(value: number | null | undefined): string {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value.toFixed(3) : 'n.v.t.'
|
||||
}
|
||||
|
||||
function qualityScoreInterpretation(value: number | null | undefined): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return 'Nog geen score'
|
||||
if (value >= 0.85) return 'Sterk resultaat'
|
||||
if (value >= 0.70) return 'Bruikbaar na controle'
|
||||
if (value >= 0.50) return 'Verkennend, controle vereist'
|
||||
return 'Onvoldoende betrouwbaar'
|
||||
}
|
||||
|
||||
function qualityStatusLabel(status: string | null | undefined): string {
|
||||
if (!status || status === 'waiting') return 'nog niet uitgevoerd'
|
||||
if (status === 'completed' || status === 'ok') return 'afgerond'
|
||||
if (status === 'failed') return 'mislukt'
|
||||
return status
|
||||
}
|
||||
|
||||
function qualityCheckTypeLabel(checkType: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
vector_vs_reference: 'Kaartlaag tegenover referentie',
|
||||
detections_vs_reference: 'Gebouwdetectie tegenover referentie',
|
||||
segmentations_vs_reference: 'Segmentatie tegenover referentie',
|
||||
detection_vs_reference: 'Gebouwdetectie tegenover referentie',
|
||||
segmentation_vs_reference: 'Segmentatie tegenover referentie',
|
||||
}
|
||||
if (!checkType) return 'Kwaliteitscontrole'
|
||||
return labels[checkType] ?? checkType.replaceAll('_', ' ')
|
||||
}
|
||||
|
||||
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 ? `Te controleren ${candidate}` : null, reference ? `Referentie ${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.v.t.'
|
||||
}
|
||||
|
||||
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({
|
||||
reviewLocked = false,
|
||||
selectedProjectId,
|
||||
qualityChecks,
|
||||
qualityChecksError,
|
||||
candidateDatasets,
|
||||
referenceDatasets,
|
||||
onRefresh,
|
||||
onOpenEvidenceMap,
|
||||
evidenceLoading = false,
|
||||
evidenceError = null,
|
||||
onOpenMapWorkspace,
|
||||
onOpenAnalysisWorkspace,
|
||||
}: 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) ?? 'Laag niet meer in de gegevenslijst'
|
||||
: 'Niet bewaard'
|
||||
const selectedReferenceName = selectedQualityCheck
|
||||
? datasetNameById.get(selectedQualityCheck.reference_dataset_id) ?? 'Referentielaag niet meer in de gegevenslijst'
|
||||
: 'Niet beschikbaar'
|
||||
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={qualityChecks.length === 0 ? 'quality-results-panel quality-results-panel-empty' : 'quality-results-panel'} data-testid="quality-results-panel">
|
||||
<div className="quality-results-shell">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h2>Kwaliteitsresultaten</h2>
|
||||
<p className="muted">Bewaarde controles van kaartlagen en beeldanalyses.</p>
|
||||
</div>
|
||||
<span className="count-pill">{qualityChecks.length} controles</span>
|
||||
</div>
|
||||
|
||||
<div className="quality-summary-surface" aria-label="Samenvatting kwaliteitsresultaten">
|
||||
<div className="quality-summary-grid">
|
||||
<div>
|
||||
<span>Afgerond</span>
|
||||
<strong>{completedChecks}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Laatste score (0-1)</span>
|
||||
<strong>{qualityScoreValue(latestCheck?.score)}</strong>
|
||||
<small>{qualityScoreInterpretation(latestCheck?.score)}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Laatste status</span>
|
||||
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{qualityChecks.length === 0 ? (
|
||||
<div className="quality-empty-workbench" aria-label="Lege kwaliteitswerkruimte">
|
||||
<aside className="quality-empty-context">
|
||||
<span className="eyebrow">Controles</span>
|
||||
<strong>Nog geen resultaten</strong>
|
||||
<p>Kwaliteitscontroles verschijnen hier nadat een resultaat met een officiële referentielaag is vergeleken.</p>
|
||||
<div className="quality-empty-context-stat">
|
||||
<span>Te controleren lagen</span>
|
||||
<strong>{candidateDatasets.length}</strong>
|
||||
</div>
|
||||
<div className="quality-empty-context-stat">
|
||||
<span>Referentielagen</span>
|
||||
<strong>{referenceDatasets.length}</strong>
|
||||
</div>
|
||||
<button type="button" className="secondary-action" onClick={onRefresh} disabled={!selectedProjectId}>
|
||||
Resultaten vernieuwen
|
||||
</button>
|
||||
</aside>
|
||||
<div className="quality-empty-results">
|
||||
<div className="quality-empty-column-heading">
|
||||
<strong>Resultaten</strong>
|
||||
<span>0 controles</span>
|
||||
</div>
|
||||
<div className="result-state result-state-empty quality-user-empty-state">
|
||||
<strong>Nog geen kwaliteitscontrole uitgevoerd</strong>
|
||||
<p>Start op de kaart met een gemeente en maak daar een begrensd resultaat. Vergelijk beeldanalyse vervolgens met een officiële referentielaag.</p>
|
||||
<div className="button-row">
|
||||
<button type="button" className="primary-action" onClick={onOpenMapWorkspace}>Open kaart en kies gemeente</button>
|
||||
<button type="button" className="secondary-action" onClick={onOpenAnalysisWorkspace}>Open beeldanalyse</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<aside className="quality-empty-inspector">
|
||||
<div className="quality-empty-column-heading">
|
||||
<strong>Controle-inspector</strong>
|
||||
<span>Geen selectie</span>
|
||||
</div>
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen bewijs geselecteerd</strong>
|
||||
<p>Scores, databronnen en kaartbewijs worden hier samen getoond zodra een controle beschikbaar is.</p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{qualityChecks.length > 0 ? (
|
||||
<details className="quality-advanced-disclosure" open>
|
||||
<summary>
|
||||
<span>Details, kaartbewijs en historiek</span>
|
||||
<strong>{qualityChecks.length} bewaarde controles</strong>
|
||||
</summary>
|
||||
<div className="quality-evidence-surface" aria-label="Databronnen van de kwaliteitscontrole">
|
||||
<div className="quality-handoff-grid" aria-label="Context van de vergeleken databronnen">
|
||||
<div>
|
||||
<span>Te controleren lagen</span>
|
||||
<strong>{candidateDatasets.length}</strong>
|
||||
<p className="quality-dataset-name">{candidateDatasets[0]?.name ?? 'Nog geen te controleren laag gekozen'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Referentielagen</span>
|
||||
<strong>{referenceDatasets.length}</strong>
|
||||
<p className="quality-dataset-name">{referenceDatasets[0]?.name ?? 'Nog geen referentielaag beschikbaar'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Laatste vergelijking</span>
|
||||
<strong>{qualityStatusLabel(latestCheck?.status)}</strong>
|
||||
<p className="quality-dataset-name">
|
||||
Te controleren: {latestCandidateName ?? 'laag niet meer beschikbaar'} / referentie: {latestReferenceName ?? 'laag niet meer beschikbaar'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="quality-drilldown-surface" aria-label="Details van het kaartbewijs">
|
||||
<div className="panel-title-row">
|
||||
<div>
|
||||
<h3>Bewijs van de kwaliteitscontrole</h3>
|
||||
<p className="muted">Bekijk de bewaarde vergelijking, scores en kaartlagen voordat je resultaten deelt.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => setSelectedQualityCheckId(latestCheck?.id ?? null)}
|
||||
disabled={!latestCheck}
|
||||
>
|
||||
Laatste controle bekijken
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => latestCheck?.id && onOpenEvidenceMap?.(latestCheck.id)}
|
||||
disabled={!latestCheck || evidenceLoading || !onOpenEvidenceMap}
|
||||
>
|
||||
{evidenceLoading ? 'Kaartbewijs laden...' : 'Laatste op kaart tonen'}
|
||||
</button>
|
||||
</div>
|
||||
{evidenceError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>Het bewijs kon niet op de kaart worden geladen.</strong>
|
||||
<p>{evidenceError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{selectedQualityCheck ? (
|
||||
<>
|
||||
<div className="quality-drilldown-grid">
|
||||
<div>
|
||||
<span>Geselecteerde controle</span>
|
||||
<strong>{qualityCheckTypeLabel(selectedQualityCheck.check_type)}</strong>
|
||||
<p>{qualityScoreInterpretation(selectedQualityCheck.score)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Te controleren laag</span>
|
||||
<strong>{selectedCandidateName}</strong>
|
||||
<p>De laag waarvan de kwaliteit wordt beoordeeld.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Referentielaag</span>
|
||||
<strong>{selectedReferenceName}</strong>
|
||||
<p>De bewaarde bron waarmee wordt vergeleken.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Beoordeling</span>
|
||||
<strong>{qualityScoreInterpretation(selectedQualityCheck.score)}</strong>
|
||||
<p>Score: {qualityScoreValue(selectedQualityCheck.score)} op een schaal van 0 tot 1</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>{qualityStatusLabel(selectedQualityCheck.status)}</strong>
|
||||
<p>Resultaat is bewaard in de werkruimte.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Afgerond</span>
|
||||
<strong>{formatQualityTimestamp(selectedQualityCheck.completed_at)}</strong>
|
||||
<p>Gemaakt: {formatQualityTimestamp(selectedQualityCheck.created_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische identificatie en verwerking</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Controle-ID: {selectedQualityCheck.id}</span>
|
||||
<span>Type: {selectedQualityCheck.check_type}</span>
|
||||
<span>Te controleren dataset-ID: {selectedQualityCheck.candidate_dataset_id ?? 'niet bewaard'}</span>
|
||||
<span>Referentie-dataset-ID: {selectedQualityCheck.reference_dataset_id}</span>
|
||||
<span>Analyserun-ID: {selectedQualityCheck.analysis_run_id ?? 'n.v.t.'}</span>
|
||||
<span>Taak-ID: {selectedQualityCheck.job_id ?? 'n.v.t.'}</span>
|
||||
</div>
|
||||
</details>
|
||||
<div className="quality-evidence-token-grid">
|
||||
<div>
|
||||
<span>Onterecht gevonden</span>
|
||||
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_positive_count'))}</strong>
|
||||
<p>Gevonden vormen zonder overeenkomst in de referentielaag.</p>
|
||||
</div>
|
||||
<div>
|
||||
<span>Gemiste objecten</span>
|
||||
<strong>{qualityMetricValue(metricByKey(selectedQualityCheck, 'false_negative_count'))}</strong>
|
||||
<p>Objecten uit de referentielaag die niet werden gevonden.</p>
|
||||
</div>
|
||||
<div className="quality-evidence-map-handoff">
|
||||
<span>Controle op de kaart</span>
|
||||
<strong>{selectedCandidateName} / {selectedReferenceName}</strong>
|
||||
<p>Toon beide lagen samen op de kaart voor een ruimtelijke controle.</p>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => onOpenEvidenceMap?.(selectedQualityCheck.id)}
|
||||
disabled={evidenceLoading || !onOpenEvidenceMap}
|
||||
>
|
||||
{evidenceLoading ? 'Lagen laden...' : 'Bewijs op kaart tonen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{selectedQualityCheck.check_type === 'detections_vs_reference' && selectedProjectId ? (
|
||||
<DetectionReviewPanel
|
||||
projectId={selectedProjectId}
|
||||
qualityCheckId={selectedQualityCheck.id}
|
||||
reviewLocked={reviewLocked}
|
||||
onOpenEvidenceMap={onOpenEvidenceMap}
|
||||
/>
|
||||
) : null}
|
||||
<div className="quality-feature-evidence-grid" aria-label="Kaartbewijs per object">
|
||||
<div>
|
||||
<span>Overeenkomende object-ID's</span>
|
||||
{selectedMatchEvidence.length > 0 ? (
|
||||
<ul>
|
||||
{selectedMatchEvidence.slice(0, 6).map((item, index) => (
|
||||
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Voor deze controle zijn geen overeenkomende object-ID's bewaard.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>ID's van onterecht gevonden objecten</span>
|
||||
{selectedFalsePositiveEvidence.length > 0 ? (
|
||||
<ul>
|
||||
{selectedFalsePositiveEvidence.slice(0, 6).map((item, index) => (
|
||||
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Er zijn geen ID's van onterecht gevonden objecten bewaard.</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<span>ID's van gemiste objecten</span>
|
||||
{selectedFalseNegativeEvidence.length > 0 ? (
|
||||
<ul>
|
||||
{selectedFalseNegativeEvidence.slice(0, 6).map((item, index) => (
|
||||
<li key={`${evidenceLabel(item)}-${index}`}>{evidenceLabel(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Er zijn geen ID's van gemiste objecten bewaard.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="quality-provenance-grid">
|
||||
<div>
|
||||
<span>Instellingen</span>
|
||||
<pre className="quality-provenance-pre">
|
||||
{JSON.stringify(selectedQualityCheck.parameters_json ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<span>Bevindingen</span>
|
||||
<pre className="quality-provenance-pre">
|
||||
{JSON.stringify(selectedQualityCheck.findings_json ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen kwaliteitscontrole geselecteerd.</strong>
|
||||
<p>Kies een bewaarde controle om scores en kaartbewijs te bekijken.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="quality-control-surface" aria-label="Kwaliteitsresultaten vernieuwen en filteren">
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={onRefresh}
|
||||
disabled={!selectedProjectId}
|
||||
data-testid="refresh-quality-results"
|
||||
>
|
||||
Kwaliteitsresultaten vernieuwen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="quality-result-state-stack">
|
||||
{qualityChecksError ? (
|
||||
<div className="result-state result-state-error">
|
||||
<strong>De kwaliteitsresultaten konden niet worden geladen.</strong>
|
||||
<p>{qualityChecksError}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{qualityChecks.length === 0 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Nog geen bewaarde kwaliteitsresultaten</strong>
|
||||
<p>Vergelijk een kaartlaag of beeldanalyse met een referentielaag om deze werkruimte te vullen.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{qualityChecks.length > 0 ? (
|
||||
<div className="quality-history-controls" aria-label="Filters voor kwaliteitsresultaten">
|
||||
<label>
|
||||
Kwaliteitsresultaten zoeken
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Type of naam van een kaartlaag"
|
||||
value={qualitySearchQuery}
|
||||
onChange={(event) => setQualitySearchQuery(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Status
|
||||
<select value={qualityStatusFilter} onChange={(event) => setQualityStatusFilter(event.target.value)}>
|
||||
<option value="all">Alle statussen</option>
|
||||
{qualityStatuses.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type controle
|
||||
<select value={qualityTypeFilter} onChange={(event) => setQualityTypeFilter(event.target.value)}>
|
||||
<option value="all">Alle 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}
|
||||
>
|
||||
Filters wissen
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{filteredQualityChecks.length > 8 ? (
|
||||
<div className="list-limit-banner">
|
||||
<span className="quality-list-count">
|
||||
{visibleQualityChecks.length} van {filteredQualityChecks.length} kwaliteitsresultaten getoond.
|
||||
{hiddenQualityCheckCount > 0 ? ` ${hiddenQualityCheckCount} oudere controles verborgen.` : ' Alle resultaten zijn zichtbaar.'}
|
||||
</span>
|
||||
<button type="button" className="secondary-action" onClick={() => setShowAllQualityChecks((value) => !value)}>
|
||||
{showAllQualityChecks ? 'Alleen laatste 8 tonen' : 'Alle kwaliteitsresultaten tonen'}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{qualityChecks.length > 0 && filteredQualityChecks.length === 0 ? (
|
||||
<div className="result-state result-state-empty">
|
||||
<strong>Geen kwaliteitsresultaten voldoen aan de filters.</strong>
|
||||
<p>Wis de zoek-, status- of typefilter om de laatste resultaten opnieuw te tonen.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="quality-history-surface" aria-label="Geschiedenis van kwaliteitsresultaten">
|
||||
<div className="panel-title-row">
|
||||
<h3>Historiek</h3>
|
||||
<span className="count-pill">{visibleQualityChecks.length} getoond</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>{qualityCheckTypeLabel(check.check_type)}</strong>
|
||||
<div className="entity-meta">
|
||||
<span className="quality-check-dataset-link">
|
||||
Te controleren: {check.candidate_dataset_id ? datasetNameById.get(check.candidate_dataset_id) ?? 'laag niet meer beschikbaar' : 'n.v.t.'}
|
||||
</span>
|
||||
<span className="quality-check-dataset-link">
|
||||
Referentie: {datasetNameById.get(check.reference_dataset_id) ?? 'laag niet meer beschikbaar'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className={check.status === 'ok' || check.status === 'completed' ? 'status-badge status-badge-ready' : 'status-badge'}>
|
||||
{qualityStatusLabel(check.status)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="quality-check-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => setSelectedQualityCheckId(check.id)}>
|
||||
Controle bekijken
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="secondary-action"
|
||||
onClick={() => onOpenEvidenceMap?.(check.id)}
|
||||
disabled={evidenceLoading || !onOpenEvidenceMap}
|
||||
>
|
||||
Bewijs op kaart tonen
|
||||
</button>
|
||||
</div>
|
||||
<div className="quality-score-row">
|
||||
<div>
|
||||
<span>Score</span>
|
||||
<strong>{qualityScoreValue(check.score)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Meetwaarden</span>
|
||||
<strong>{check.metrics.length}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Beoordeling</span>
|
||||
<strong>{qualityScoreInterpretation(check.score)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<details className="technical-inline-details">
|
||||
<summary>Technische referentie</summary>
|
||||
<div className="entity-meta">
|
||||
<span>Controle-ID: {check.id}</span>
|
||||
<span>Type: {check.check_type}</span>
|
||||
</div>
|
||||
</details>
|
||||
<div className="quality-metric-section">
|
||||
<span>Gemeten kwaliteit</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">Alle meetwaarden</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>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user