| Server IP : 217.154.3.148 / Your IP : 216.73.216.90 Web Server : Apache System : Linux blissful-wright.217-154-3-148.plesk.page 6.8.0-124-generic #124-Ubuntu SMP PREEMPT_DYNAMIC Tue May 26 13:00:45 UTC 2026 x86_64 User : gracious-boyd_hfhh1h8vo9n ( 10000) PHP Version : 8.3.31 Disable Function : opcache_get_status MySQL : OFF | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : /var/www/vhosts/gracious-boyd.217-154-3-148.plesk.page/nextjs/app/calibrate/ |
Upload File : |
'use client';
/* eslint-disable @next/next/no-img-element -- calibration draws raw <img> for canvas interactions */
import React, { useMemo, useRef, useState } from 'react';
type P = { x: number; y: number };
function dist(a: P, b: P) {
const dx = a.x - b.x, dy = a.y - b.y;
return Math.hypot(dx, dy);
}
export default function CalibratePage() {
const [file, setFile] = useState<File | null>(null);
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [imgNatural, setImgNatural] = useState<{ w: number; h: number } | null>(null);
const [points, setPoints] = useState<P[]>([]);
const [distanceMm, setDistanceMm] = useState<string>('300'); // z.B. 300 mm = 30 cm
const [result, setResult] = useState<any>(null);
const [busy, setBusy] = useState(false);
const imgRef = useRef<HTMLImageElement | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const pxDistance = useMemo(() => {
return points.length === 2 ? dist(points[0], points[1]) : undefined;
}, [points]);
const scalePxPerMm = useMemo(() => {
const mm = Number(distanceMm.replace(',', '.'));
if (!pxDistance || !Number.isFinite(mm) || mm <= 0) return undefined;
return pxDistance / mm;
}, [pxDistance, distanceMm]);
function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const f = e.target.files?.[0] || null;
setFile(f);
setResult(null);
setPoints([]);
if (imgUrl) URL.revokeObjectURL(imgUrl);
setImgUrl(f ? URL.createObjectURL(f) : null);
}
function imgToNatural(x: number, y: number): P | null {
const img = imgRef.current;
if (!img || !imgNatural) return null;
const rect = img.getBoundingClientRect();
const sx = imgNatural.w / rect.width;
const sy = imgNatural.h / rect.height;
return { x: (x - rect.left) * sx, y: (y - rect.top) * sy };
// Wir rechnen in Natural-Pixeln (originale Bildauflösung), damit Maßstab konsistent ist
}
function onClickOverlay(e: React.MouseEvent<HTMLDivElement>) {
const p = imgToNatural(e.clientX, e.clientY);
if (!p) return;
setPoints(prev => {
if (prev.length >= 2) return [p]; // nach zwei Klicks neu beginnen
return [...prev, p];
});
}
async function onAnalyze() {
if (!file) return alert('Bitte zuerst ein Bild wählen.');
if (!scalePxPerMm) return alert('Bitte zwei Punkte setzen und reale Distanz in mm angeben.');
setBusy(true);
setResult(null);
try {
const fd = new FormData();
fd.append('file', file);
fd.append('scale_px_per_mm', String(scalePxPerMm));
// Optional: Hinweise (werden von der API bevorzugt, falls gesetzt)
// fd.append('tile_w', ''); fd.append('tile_h', ''); fd.append('grout', '');
// Die App läuft hinter /nextjs – API daher unter /nextjs/api/analyze
const res = await fetch('/nextjs/api/analyze', { method: 'POST', body: fd });
const json = await res.json();
setResult(json);
} catch (err: any) {
setResult({ ok: false, error: String(err?.message || err) });
} finally {
setBusy(false);
}
}
return (
<div className="min-h-dvh flex flex-col gap-4 p-4">
<h1 className="text-xl font-semibold">Kalibrierung (2-Punkt)</h1>
<div className="flex flex-wrap gap-4">
<label className="inline-flex items-center gap-2">
<span className="text-sm">Bild wählen:</span>
<input type="file" accept="image/*" onChange={onPickFile} />
</label>
<label className="inline-flex items-center gap-2">
<span className="text-sm">Reale Distanz (mm):</span>
<input
className="border rounded px-2 py-1 w-28"
value={distanceMm}
onChange={e => setDistanceMm(e.target.value)}
inputMode="numeric"
placeholder="mm"
/>
</label>
<div className="text-sm grid grid-cols-2 gap-x-3">
<div className="opacity-70">Pixel-Distanz:</div>
<div className="font-mono">{pxDistance ? pxDistance.toFixed(1) : '—'}</div>
<div className="opacity-70">px / mm:</div>
<div className="font-mono">{scalePxPerMm ? scalePxPerMm.toFixed(4) : '—'}</div>
</div>
<button
className="px-3 py-1 rounded bg-black text-white disabled:opacity-40"
disabled={!file || !scalePxPerMm || busy}
onClick={onAnalyze}
>
{busy ? 'Analysiere…' : 'Analysieren (mit Maßstab)'}
</button>
</div>
<div
ref={containerRef}
className="relative border rounded overflow-hidden max-w-full"
style={{ width: 'min(100%, 900px)' }}
onClick={onClickOverlay}
>
{imgUrl ? (
<>
<img
ref={imgRef}
src={imgUrl}
alt="preview"
className="block max-w-full h-auto select-none"
onLoad={e => {
const el = e.currentTarget;
setImgNatural({ w: el.naturalWidth, h: el.naturalHeight });
}}
/>
{/* SVG Overlay zeichnet Linie & Punkte in ViewBox=Naturalgröße, passt sich der IMG-Größe an */}
{imgNatural && (
<svg
className="absolute inset-0 pointer-events-none"
viewBox={`0 0 ${imgNatural.w} ${imgNatural.h}`}
preserveAspectRatio="none"
>
{points[0] && (
<circle cx={points[0].x} cy={points[0].y} r={10} fill="none" strokeWidth={4} stroke="red" />
)}
{points[1] && (
<>
<circle cx={points[1].x} cy={points[1].y} r={10} fill="none" strokeWidth={4} stroke="red" />
<line
x1={points[0].x} y1={points[0].y}
x2={points[1].x} y2={points[1].y}
stroke="red" strokeWidth={4}
/>
</>
)}
</svg>
)}
</>
) : (
<div className="p-12 text-center text-sm opacity-70">Kein Bild ausgewählt</div>
)}
</div>
<div className="mt-2">
<h2 className="font-medium">Ergebnis</h2>
<pre className="bg-neutral-100 p-3 rounded overflow-auto text-sm">
{JSON.stringify(result, null, 2)}
</pre>
</div>
<p className="text-xs opacity-70">
Tipp: Klicke zwei Punkte auf derselben Fuge (oder zwei Fugenlinien) mit bekannter Distanz.
Die API erhält <code>scale_px_per_mm</code> und gibt zusätzlich mm-Werte zurück.
</p>
</div>
);
}