2026-10-07 21:00:21 +02:00
"use strict" ;
const $ = id => document . getElementById ( id ) ;
const input = $ ( "image-file" ) , original = $ ( "original" ) , cornersCanvas = $ ( "corners" ) ;
const processed = $ ( "processed" ) , evidenceCanvas = $ ( "evidence" ) ;
2026-10-07 22:07:30 +02:00
const run = $ ( "run" ) , status = $ ( "status" ) , rotation = $ ( "rotation" ) , finePrint = $ ( "fine-print" ) ;
2026-10-07 21:00:21 +02:00
const initialCorners = ( ) => [ [ 0 , 0 ] , [ 1 , 0 ] , [ 1 , 1 ] , [ 0 , 1 ] ] ;
let corners = initialCorners ( ) , edited = false , busy = false , ready = false ;
let objectUrl = null , drag = null , result = null , selectedEvidence = null ;
const manual = { classic : new Map ( ) , vision : new Map ( ) } ;
const suppressed = { classic : new Set ( ) , vision : new Set ( ) } ;
const priorityCodes = [ "C.1.1" , "C.1.2" , "C.1.3" , "B" , "2.1" , "2.2" ] ;
const reviewMethods = new Set ( [ "merged_code" , "vl_table_context_code" , "caption_region_next_line" , "surname_above_forename" ] ) ;
const fieldNames = { "A" : "Kennzeichen" , "C.1.1" : "Name / Firmenname" , "C.1.2" : "Vorname" ,
"C.1.3" : "Anschrift" , "B" : "Erstzulassung" , "2.1" : "Herstellerschlüssel (HSN)" ,
"2.2" : "Typschlüssel (TSN)" , "2" : "Hersteller" , "D.1" : "Marke" , "D.2" : "Typ / Variante" ,
"D.3" : "Handelsbezeichnung" , "E" : "Fahrzeug-Identifizierungsnummer" , "P.3" : "Kraftstoffart" ,
"R" : "Farbe" , "17" : "Merkmal zur Betriebserlaubnis" } ;
function setStatus ( message , kind = "" ) {
status . textContent = message ;
status . className = ` status ${ kind } ` ;
}
function validCorners ( ) {
if ( ! ready ) return false ;
if ( ! edited ) return true ;
const w = original . naturalWidth , h = original . naturalHeight ;
const points = corners . map ( ( [ x , y ] ) => [ x * w , y * h ] ) ;
const edges = points . map ( ( p , i ) => [ points [ ( i + 1 ) % 4 ] [ 0 ] - p [ 0 ] , points [ ( i + 1 ) % 4 ] [ 1 ] - p [ 1 ] ] ) ;
const crosses = edges . map ( ( a , i ) => a [ 0 ] * edges [ ( i + 1 ) % 4 ] [ 1 ] - a [ 1 ] * edges [ ( i + 1 ) % 4 ] [ 0 ] ) ;
const area = Math . abs ( points . reduce ( ( sum , p , i ) => sum + p [ 0 ] * points [ ( i + 1 ) % 4 ] [ 1 ] - p [ 1 ] * points [ ( i + 1 ) % 4 ] [ 0 ] , 0 ) ) / 2 ;
return crosses . every ( value => value > 0 ) && area > . 02 * w * h &&
edges . every ( ( [ x , y ] ) => Math . hypot ( x , y ) >= 30 ) ;
}
function updateControls ( ) {
input . disabled = busy ;
$ ( "reset-corners" ) . disabled = ! ready || busy ;
rotation . disabled = busy ;
2026-10-07 22:07:30 +02:00
finePrint . disabled = busy ;
2026-10-07 21:00:21 +02:00
run . disabled = busy || ! validCorners ( ) ;
}
function drawCorners ( ) {
const w = original . naturalWidth , h = original . naturalHeight ;
if ( ! w || ! h ) return ;
cornersCanvas . width = w ;
cornersCanvas . height = h ;
const ctx = cornersCanvas . getContext ( "2d" ) ;
const pts = corners . map ( ( [ x , y ] ) => [ x * ( w - 1 ) , y * ( h - 1 ) ] ) ;
ctx . strokeStyle = "#1459dd" ;
ctx . lineWidth = Math . max ( 5 , w / 550 ) ;
ctx . beginPath ( ) ;
pts . forEach ( ( [ x , y ] , i ) => i ? ctx . lineTo ( x , y ) : ctx . moveTo ( x , y ) ) ;
ctx . closePath ( ) ; ctx . stroke ( ) ;
const radius = Math . max ( 25 , w / 110 ) ;
pts . forEach ( ( [ x , y ] , i ) => {
ctx . beginPath ( ) ; ctx . arc ( x , y , radius , 0 , 2 * Math . PI ) ;
ctx . fillStyle = "#fff" ; ctx . fill ( ) ; ctx . stroke ( ) ;
ctx . fillStyle = "#1547a5" ; ctx . font = ` bold ${ Math . max ( 22 , w / 125 ) } px system-ui ` ;
ctx . fillText ( String ( i + 1 ) , Math . min ( w - radius , x + radius * . 9 ) , Math . max ( radius , y - radius * . 65 ) ) ;
} ) ;
}
function pointInCanvas ( event ) {
const b = cornersCanvas . getBoundingClientRect ( ) ;
return [ ( event . clientX - b . left ) / b . width , ( event . clientY - b . top ) / b . height ] ;
}
cornersCanvas . addEventListener ( "pointerdown" , event => {
if ( ! ready || busy ) return ;
const b = cornersCanvas . getBoundingClientRect ( ) ;
const [ x , y ] = pointInCanvas ( event ) ;
const distances = corners . map ( ( [ cx , cy ] ) => Math . hypot ( ( cx - x ) * b . width , ( cy - y ) * b . height ) ) ;
const index = distances . indexOf ( Math . min ( ... distances ) ) ;
if ( distances [ index ] > 32 ) return ;
drag = index ;
cornersCanvas . setPointerCapture ( event . pointerId ) ;
event . preventDefault ( ) ;
} ) ;
cornersCanvas . addEventListener ( "pointermove" , event => {
if ( drag === null || busy ) return ;
const [ x , y ] = pointInCanvas ( event ) ;
corners [ drag ] = [ Math . max ( 0 , Math . min ( 1 , x ) ) , Math . max ( 0 , Math . min ( 1 , y ) ) ] ;
edited = true ; result = null ; $ ( "comparison" ) . hidden = true ;
drawCorners ( ) ; updateControls ( ) ;
setStatus ( validCorners ( ) ? "Ecken geändert. Bitte beide Verfahren neu starten." :
"Ecken müssen ein nicht gekreuztes Viereck um das Dokument bilden." , validCorners ( ) ? "" : "error" ) ;
} ) ;
function finishDrag ( ) { drag = null ; }
cornersCanvas . addEventListener ( "pointerup" , finishDrag ) ;
cornersCanvas . addEventListener ( "pointercancel" , finishDrag ) ;
$ ( "reset-corners" ) . addEventListener ( "click" , ( ) => {
corners = initialCorners ( ) ; edited = false ; result = null ;
$ ( "comparison" ) . hidden = true ; drawCorners ( ) ; updateControls ( ) ; setStatus ( "Ganzes Bild ausgewählt." ) ;
} ) ;
rotation . addEventListener ( "change" , ( ) => {
result = null ; $ ( "comparison" ) . hidden = true ; setStatus ( "Leserichtung geändert. Bitte erneut vergleichen." ) ;
} ) ;
2026-10-07 22:07:30 +02:00
finePrint . addEventListener ( "change" , ( ) => {
result = null ; $ ( "comparison" ) . hidden = true ;
setStatus ( "OCR-Detailgrad geändert. Bitte erneut vergleichen." ) ;
} ) ;
2026-10-07 21:00:21 +02:00
input . addEventListener ( "change" , ( ) => {
if ( objectUrl ) URL . revokeObjectURL ( objectUrl ) ;
objectUrl = null ; ready = false ; edited = false ; corners = initialCorners ( ) ;
drag = null ; result = null ; selectedEvidence = null ;
manual . classic . clear ( ) ; manual . vision . clear ( ) ;
suppressed . classic . clear ( ) ; suppressed . vision . clear ( ) ;
$ ( "comparison" ) . hidden = true ; rotation . value = "0" ;
const file = input . files ? . [ 0 ] ;
$ ( "original-wrap" ) . hidden = ! file ;
$ ( "file-name" ) . textContent = file ? ` ${ file . name } · ${ ( file . size / 1048576 ) . toFixed ( 2 ) } MiB ` : "JPEG oder PNG, maximal 16 MiB." ;
if ( file ) { objectUrl = URL . createObjectURL ( file ) ; original . src = objectUrl ; }
else original . removeAttribute ( "src" ) ;
setStatus ( "" ) ; updateControls ( ) ;
} ) ;
original . addEventListener ( "load" , ( ) => {
ready = true ; drawCorners ( ) ; updateControls ( ) ;
$ ( "file-name" ) . textContent += ` · ${ original . naturalWidth } × ${ original . naturalHeight } Pixel ` ;
} ) ;
original . addEventListener ( "error" , ( ) => {
ready = false ; updateControls ( ) ; setStatus ( "Das Bild kann nicht geöffnet werden. Bitte JPEG oder PNG verwenden." , "error" ) ;
} ) ;
window . addEventListener ( "pagehide" , ( ) => { if ( objectUrl ) URL . revokeObjectURL ( objectUrl ) ; } ) ;
const errorText = {
busy : "Ein Vergleich läuft bereits." , unsupported _media _type : "Nur JPEG und PNG sind erlaubt." ,
upload _too _large : "Das Bild ist größer als 16 MiB." , upload _timeout : "Der Upload hat zu lange gedauert." ,
invalid _image : "Die Bilddatei lässt sich nicht vollständig lesen." ,
unsupported _encoding : "Der Bildinhalt ist kein unterstütztes JPEG oder PNG." ,
image _too _large : "Die Auflösung überschreitet 24 Megapixel oder 10.000 Pixel pro Seite." ,
invalid _corners : "Die vier Ecken bilden kein gültiges Dokumentviereck." ,
invalid _rotation : "Die gewählte Drehung ist ungültig." ,
2026-10-07 22:07:30 +02:00
invalid _detail : "Der OCR-Detailgrad ist ungültig." ,
2026-10-07 21:00:21 +02:00
classic _timeout : "Die klassische OCR hat das Zeitlimit überschritten." ,
classic _models _missing : "Die lokalen OCR-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen." ,
classic _failed : "Die klassische OCR ist lokal fehlgeschlagen." ,
classic _invalid : "Die klassische OCR hat ein ungültiges Ergebnis geliefert." ,
vision _timeout : "PaddleOCR-VL hat das Zeitlimit überschritten." ,
vision _failed : "PaddleOCR-VL konnte auf der konfigurierten eigenen Hardware nicht ausgeführt werden." ,
vision _disabled : "Vision ist in dieser Installation deaktiviert." ,
vision _config _error : "Die Vision-Konfiguration ist unvollständig oder ungültig." ,
vision _models _missing : "Die lokalen Vision-Modelle fehlen. Zuerst die Modell-Einrichtung aus der README ausführen." ,
vision _invalid : "PaddleOCR-VL hat ein ungültiges Ergebnis geliefert." ,
} ;
function element ( tag , value , className = "" ) {
const node = document . createElement ( tag ) ;
node . textContent = value ;
if ( className ) node . className = className ;
return node ;
}
function drawEvidence ( ) {
if ( ! result || ! processed . naturalWidth ) return ;
const w = processed . naturalWidth , h = processed . naturalHeight ;
evidenceCanvas . width = w ; evidenceCanvas . height = h ;
const ctx = evidenceCanvas . getContext ( "2d" ) ;
if ( $ ( "all-boxes" ) . checked ) {
for ( const [ engine , branch ] of Object . entries ( result . results ) ) {
if ( branch . status !== "ok" ) continue ;
ctx . strokeStyle = engine === "classic" ? "#008778" : "#425edf" ;
ctx . lineWidth = Math . max ( 2 , w / 900 ) ;
for ( const line of branch . lines ) if ( line . box ) {
const [ x1 , y1 , x2 , y2 ] = line . box ;
ctx . strokeRect ( x1 , y1 , x2 - x1 , y2 - y1 ) ;
}
}
}
if ( selectedEvidence ? . anchor && selectedEvidence . anchor . join ( ) !== selectedEvidence . box . join ( ) ) {
const [ x1 , y1 , x2 , y2 ] = selectedEvidence . anchor ;
ctx . strokeStyle = "#d88614" ; ctx . fillStyle = "rgba(216,134,20,.13)" ;
ctx . lineWidth = Math . max ( 4 , w / 400 ) ;
ctx . fillRect ( x1 , y1 , x2 - x1 , y2 - y1 ) ;
ctx . strokeRect ( x1 , y1 , x2 - x1 , y2 - y1 ) ;
}
if ( selectedEvidence ? . box ) {
const [ x1 , y1 , x2 , y2 ] = selectedEvidence . box ;
ctx . strokeStyle = "#d72b4a" ; ctx . fillStyle = "rgba(215,43,74,.16)" ;
ctx . lineWidth = Math . max ( 4 , w / 400 ) ;
ctx . fillRect ( x1 , y1 , x2 - x1 , y2 - y1 ) ;
ctx . strokeRect ( x1 , y1 , x2 - x1 , y2 - y1 ) ;
}
}
function focusEvidence ( box , anchor = null ) {
if ( ! box ) return ;
selectedEvidence = { box , anchor } ; drawEvidence ( ) ;
$ ( "comparison" ) . scrollIntoView ( { behavior : "smooth" , block : "start" } ) ;
}
processed . addEventListener ( "load" , drawEvidence ) ;
$ ( "all-boxes" ) . addEventListener ( "change" , drawEvidence ) ;
function renderCard ( engine ) {
const card = $ ( ` ${ engine } -card ` ) , branch = result . results [ engine ] ;
card . replaceChildren ( element ( "h3" , engine === "classic" ? "A · Klassische OCR" : "B · Vision-Dokumentmodell" ) ) ;
if ( branch . status !== "ok" ) {
card . append ( element ( "p" , errorText [ branch . error ] || "Dieses Verfahren ist fehlgeschlagen." , "error" ) ,
element ( "p" , ` ${ ( branch . elapsed _ms / 1000 ) . toFixed ( 1 ) } s ` , "meta" ) ) ;
return ;
}
const automatic = branch . fields . filter ( field => ! suppressed [ engine ] . has ( field . code ) ) ;
card . append ( element ( "p" , ` ${ branch . model } · ${ ( branch . elapsed _ms / 1000 ) . toFixed ( 1 ) } s · ${ branch . lines . length } Textbereiche · ${ automatic . length } automatische Feldvorschläge ` , "meta" ) ) ;
if ( branch . layout _note ) card . append ( element ( "p" , branch . layout _note , "note" ) ) ;
card . append ( element ( "p" , "Die Zuordnung ist ein Vorschlag. Orange markiert den gelesenen Feldcode, Rot den Wert. Falsche Vorschläge lassen sich verwerfen; der Rohtext bleibt erhalten." , "assignment-note" ) ) ;
card . append ( element ( "h4" , "Strukturierte Feldvorschläge" ) ) ;
const merged = new Map ( automatic . map ( field => [ field . code , field ] ) ) ;
for ( const [ code , field ] of manual [ engine ] ) merged . set ( code , field ) ;
const priority = element ( "div" , "" , "priority-fields" ) ;
priority . append ( element ( "h5" , "Wichtige Angaben" ) ) ;
for ( const code of priorityCodes ) {
const field = merged . get ( code ) ;
priority . append ( element ( "span" , ` ${ code } ${ fieldNames [ code ] } : ${ field ? ( field . manual ? "manuell" : reviewMethods . has ( field . method ) ? "prüfen" : "Vorschlag" ) : "offen" } ` ,
` priority-item ${ field ? "" : " missing" } ` ) ) ;
}
card . append ( priority ) ;
if ( ! merged . size ) card . append ( element ( "p" , "Keine sicher belegbaren Felder. Rohtext prüfen und bei Bedarf manuell zuordnen." , "meta" ) ) ;
const fieldList = element ( "div" , "" , "field-list" ) ;
for ( const field of merged . values ( ) ) {
const row = element ( "div" , "" , ` field-item ${ reviewMethods . has ( field . method ) ? " needs-review" : "" } ` ) ;
const show = element ( "button" , "" , "field-show" ) ; show . type = "button" ;
const heading = element ( "span" , "" , "field-heading" ) ;
heading . append ( element ( "strong" , field . code ) , element ( "small" , fieldNames [ field . code ] || "Feldcode" ) ) ;
const method = field . manual ? "Manuell aus OCR-Text" :
field . method === "adjacent_code" ? "Code und Wert in Nachbarboxen" :
field . method === "below_caption" ? "Wert unter gedruckter Beschriftung" :
field . method === "caption_region_next_line" ? "Nächste Textzeile derselben Layoutregion · bitte prüfen" :
field . method === "surname_above_forename" ? "C.1.1-Code nicht gelesen · Wert oberhalb von C.1.2 · bitte prüfen" :
field . method === "vl_table_adjacent_cell" ? "Benachbarte Modellzellen · grober Tabellenbeleg" :
field . method === "vl_table_context_code" ? "Punkt im Feldcode fehlt · Tabellenfolge B/2.1/2.2 · bitte prüfen" :
field . method === "merged_code" ? "Code und Wert verschmolzen · bitte prüfen" :
"Code und Wert in einer Textzeile" ;
show . append ( heading , element ( "span" , field . value , "field-value" ) , element ( "small" , method , "field-method" ) ) ;
show . disabled = ! field . box ;
show . addEventListener ( "click" , ( ) => focusEvidence ( field . box , field . anchor _box ) ) ;
const dismiss = element ( "button" , field . manual ? "Entfernen" : "Verwerfen" , "field-dismiss" ) ;
dismiss . type = "button" ;
dismiss . setAttribute ( "aria-label" , ` ${ field . code } ${ field . manual ? "entfernen" : "verwerfen" } ` ) ;
dismiss . addEventListener ( "click" , ( ) => {
if ( field . manual ) manual [ engine ] . delete ( field . code ) ;
else suppressed [ engine ] . add ( field . code ) ;
renderCard ( engine ) ;
} ) ;
row . append ( show , dismiss ) ;
fieldList . append ( row ) ;
}
card . append ( fieldList , element ( "h4" , "Rohtext mit Bildbelegen" ) ) ;
if ( ! branch . lines . length ) card . append ( element ( "p" , "Kein Text erkannt." , "meta" ) ) ;
const raw = element ( "div" , "" , "raw-lines" ) ;
let code , lineSelect , value , form ;
branch . lines . forEach ( ( line , index ) => {
const entry = element ( "div" , "" , "raw-entry" ) ;
const row = element ( "button" , "" , "raw-line" ) ; row . type = "button" ;
row . append ( element ( "span" , ` ${ index + 1 } . ` ) , element ( "span" , line . text ) ) ;
row . disabled = ! line . box ;
row . title = line . source || "Bildbereich markieren" ;
row . addEventListener ( "click" , ( ) => {
lineSelect . value = String ( index ) ; value . value = line . text ;
focusEvidence ( line . box ) ;
} ) ;
const pick = element ( "button" , "Zuordnen" , "raw-pick" ) ; pick . type = "button" ;
pick . disabled = ! line . box ;
pick . addEventListener ( "click" , ( ) => {
lineSelect . value = String ( index ) ; value . value = line . text ;
selectedEvidence = { box : line . box , anchor : null } ; drawEvidence ( ) ;
form . scrollIntoView ( { behavior : "smooth" , block : "center" } ) ; code . focus ( ) ;
} ) ;
entry . append ( row , pick ) ; raw . append ( entry ) ;
} ) ;
card . append ( raw ) ;
if ( ! branch . lines . length ) return ;
card . append ( element ( "h4" , "Zeile manuell zuordnen" ) ) ;
form = element ( "div" , "" , "manual" ) ;
code = document . createElement ( "select" ) ; lineSelect = document . createElement ( "select" ) ;
code . setAttribute ( "aria-label" , "Feldcode" ) ; lineSelect . setAttribute ( "aria-label" , "OCR-Zeile" ) ;
code . append ( new Option ( "Feldcode" , "" ) ) ;
result . field _codes . forEach ( fieldCode => code . append ( new Option ( ` ${ fieldCode } · ${ fieldNames [ fieldCode ] || "Feldcode" } ` , fieldCode ) ) ) ;
lineSelect . append ( new Option ( "OCR-Zeile wählen" , "" ) ) ;
branch . lines . forEach ( ( row , index ) => lineSelect . append ( new Option ( ` ${ index + 1 } : ${ row . text . slice ( 0 , 55 ) } ` , String ( index ) ) ) ) ;
value = document . createElement ( "input" ) ; value . type = "text" ; value . maxLength = 500 ;
value . setAttribute ( "aria-label" , "Wörtlicher Ausschnitt aus der OCR-Zeile" ) ;
value . placeholder = "OCR-Zeile wählen, dann bei Bedarf auf den Wert kürzen" ;
lineSelect . addEventListener ( "change" , ( ) => { value . value = lineSelect . value === "" ? "" : branch . lines [ Number ( lineSelect . value ) ] . text ; } ) ;
const add = element ( "button" , "Zuordnen" ) ; add . type = "button" ;
const hint = element ( "p" , "Nur wörtlich erkannter Text mit Bildbereich ist zulässig." , "manual-hint" ) ;
add . addEventListener ( "click" , ( ) => {
const row = lineSelect . value === "" ? null : branch . lines [ Number ( lineSelect . value ) ] ;
const chosen = value . value . trim ( ) ;
if ( ! code . value || ! chosen || ! row ? . box || ! row . text . includes ( chosen ) ) {
hint . textContent = "Feldcode und wörtlichen Ausschnitt aus der gewählten Zeile angeben." ;
hint . className = "manual-hint error" ; return ;
}
manual [ engine ] . set ( code . value , { code : code . value , value : chosen , box : row . box , manual : true } ) ;
suppressed [ engine ] . delete ( code . value ) ;
renderCard ( engine ) ;
} ) ;
const clear = element ( "button" , "Manuelle Zuordnungen löschen" ) ; clear . type = "button" ;
clear . addEventListener ( "click" , ( ) => { manual [ engine ] . clear ( ) ; renderCard ( engine ) ; } ) ;
form . append ( code , lineSelect , value , add , clear , hint ) ; card . append ( form ) ;
}
run . addEventListener ( "click" , async ( ) => {
const file = input . files ? . [ 0 ] ;
if ( ! file || ! validCorners ( ) || busy ) return ;
if ( file . size > 16 * 1048576 ) { setStatus ( errorText . upload _too _large , "error" ) ; return ; }
const mime = file . type || ( /\.jpe?g$/i . test ( file . name ) ? "image/jpeg" : /\.png$/i . test ( file . name ) ? "image/png" : "" ) ;
if ( ! [ "image/jpeg" , "image/png" ] . includes ( mime ) ) { setStatus ( errorText . unsupported _media _type , "error" ) ; return ; }
busy = true ; updateControls ( ) ; result = null ; selectedEvidence = null ;
manual . classic . clear ( ) ; manual . vision . clear ( ) ; $ ( "comparison" ) . hidden = true ;
suppressed . classic . clear ( ) ; suppressed . vision . clear ( ) ;
setStatus ( "Die lokalen Verfahren verarbeiten dasselbe Bild …" ) ;
try {
const geometry = { rotation : Number ( rotation . value ) } ;
if ( edited ) geometry . corners = corners ;
const response = await fetch ( "/api/compare" , { method : "POST" , body : file ,
2026-10-07 22:07:30 +02:00
headers : { "Content-Type" : mime , "X-OCR-Geometry" : JSON . stringify ( geometry ) ,
"X-OCR-Detail" : finePrint . checked ? "fine" : "standard" } , cache : "no-store" } ) ;
2026-10-07 21:00:21 +02:00
const data = await response . json ( ) ;
if ( ! response . ok ) { setStatus ( errorText [ data . error ] || ` Vergleich fehlgeschlagen ( ${ response . status } ). ` , "error" ) ; return ; }
result = data ; processed . src = data . image ;
$ ( "image-size" ) . textContent = ` Gemeinsames OCR-Bild: ${ data . image _size . width } × ${ data . image _size . height } Pixel. Klick auf Wert oder Rohtext markiert seinen Bildbeleg. ` ;
renderCard ( "classic" ) ; renderCard ( "vision" ) ; $ ( "comparison" ) . hidden = false ;
const both = Object . values ( data . results ) . every ( branch => branch . status === "ok" ) ;
setStatus ( both ? "Beide Ergebnisse bereit. Feldvorschläge anhand der Bildbelege prüfen." :
"Ein Verfahren ist fehlgeschlagen; das andere Ergebnis und der Fehler sind sichtbar." , both ? "ok" : "error" ) ;
} catch {
setStatus ( result ? "Die Antwort des lokalen Vergleichs konnte nicht angezeigt werden. Seite neu laden." :
"Der lokale Dienst antwortet nicht. Prozess und /api/health prüfen." , "error" ) ;
} finally { busy = false ; updateControls ( ) ; }
} ) ;