feat: initial commit web-cad-neu with docker-compose, frontend and backend
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { BackgroundService, DEFAULT_BACKGROUND, type BackgroundConfig } from '../services/backgroundService';
|
||||
|
||||
interface BackgroundImportProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onApply: (config: BackgroundConfig, image: HTMLImageElement | null) => void;
|
||||
backgroundService: BackgroundService;
|
||||
}
|
||||
|
||||
const BackgroundImport: React.FC<BackgroundImportProps> = ({ open, onClose, onApply, backgroundService }) => {
|
||||
const [config, setConfig] = useState<BackgroundConfig>({ ...DEFAULT_BACKGROUND });
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [calibrationMode, setCalibrationMode] = useState(false);
|
||||
const [calibPoint1, setCalibPoint1] = useState<{ x: number; y: number } | null>(null);
|
||||
const [calibPoint2, setCalibPoint2] = useState<{ x: number; y: number } | null>(null);
|
||||
const [realDistance, setRealDistance] = useState('');
|
||||
const [unit, setUnit] = useState('m');
|
||||
const [error, setError] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setError('');
|
||||
try {
|
||||
const cfg = await backgroundService.loadFromFile(file);
|
||||
setConfig(cfg);
|
||||
setPreviewUrl(cfg.src);
|
||||
} catch (err) {
|
||||
setError('Fehler beim Laden des Bildes: ' + (err as Error).message);
|
||||
}
|
||||
}, [backgroundService]);
|
||||
|
||||
const handlePreviewClick = useCallback((e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
if (!calibrationMode || !previewCanvasRef.current) return;
|
||||
const rect = previewCanvasRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
if (!calibPoint1) {
|
||||
setCalibPoint1({ x, y });
|
||||
} else if (!calibPoint2) {
|
||||
setCalibPoint2({ x, y });
|
||||
}
|
||||
}, [calibrationMode, calibPoint1, calibPoint2]);
|
||||
|
||||
const handleCalibrate = useCallback(() => {
|
||||
if (!calibPoint1 || !calibPoint2 || !realDistance) return;
|
||||
const dx = calibPoint2.x - calibPoint1.x;
|
||||
const dy = calibPoint2.y - calibPoint1.y;
|
||||
const pixelDist = Math.sqrt(dx * dx + dy * dy);
|
||||
const realDist = parseFloat(realDistance);
|
||||
if (realDist <= 0) {
|
||||
setError('Bitte eine gültige Referenzstrecke eingeben.');
|
||||
return;
|
||||
}
|
||||
const result = backgroundService.calibrateScale(pixelDist, realDist, unit);
|
||||
setConfig(prev => ({ ...prev, scale: result.scale }));
|
||||
setCalibrationMode(false);
|
||||
setCalibPoint1(null);
|
||||
setCalibPoint2(null);
|
||||
setRealDistance('');
|
||||
}, [calibPoint1, calibPoint2, realDistance, unit, backgroundService]);
|
||||
|
||||
const handleApply = useCallback(() => {
|
||||
const cfg = backgroundService.getConfig();
|
||||
onApply(cfg, backgroundService.getImage());
|
||||
onClose();
|
||||
}, [backgroundService, onApply, onClose]);
|
||||
|
||||
const updateConfig = useCallback((partial: Partial<BackgroundConfig>) => {
|
||||
backgroundService.updateConfig(partial);
|
||||
setConfig(backgroundService.getConfig());
|
||||
}, [backgroundService]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" style={{
|
||||
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)', display: 'flex',
|
||||
alignItems: 'center', justifyContent: 'center', zIndex: 1000,
|
||||
}}>
|
||||
<div className="modal-dialog" style={{
|
||||
background: 'var(--color-bg-primary, #1e1e2e)', borderRadius: '8px',
|
||||
padding: '24px', maxWidth: '600px', width: '90%', maxHeight: '90vh',
|
||||
overflow: 'auto', color: 'var(--color-text, #e0e0e0)',
|
||||
border: '1px solid var(--color-border, #333)',
|
||||
}}>
|
||||
<h2 style={{ marginTop: 0, marginBottom: '16px' }}>Hintergrund importieren</h2>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ff6b6b', marginBottom: '12px', fontSize: '14px' }}>{error}</div>
|
||||
)}
|
||||
|
||||
{/* File Upload */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '14px' }}>Bilddatei (PNG, JPG, SVG)</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml,application/pdf"
|
||||
onChange={handleFileSelect}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: '4px',
|
||||
border: '1px solid var(--color-border, #444)',
|
||||
background: 'var(--color-bg-secondary, #2a2a3a)',
|
||||
color: 'var(--color-text, #e0e0e0)', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Datei wählen…
|
||||
</button>
|
||||
{config.name && (
|
||||
<span style={{ marginLeft: '12px', fontSize: '13px' }}>{config.name} ({config.width}×{config.height}px)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{previewUrl && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
onClick={handlePreviewClick}
|
||||
style={{
|
||||
maxWidth: '100%', maxHeight: '200px',
|
||||
border: '1px solid var(--color-border, #333)',
|
||||
cursor: calibrationMode ? 'crosshair' : 'default',
|
||||
display: 'block',
|
||||
}}
|
||||
width={config.width || 400}
|
||||
height={config.height || 300}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Calibration */}
|
||||
{previewUrl && (
|
||||
<div style={{ marginBottom: '16px', padding: '12px', border: '1px solid var(--color-border, #333)', borderRadius: '4px' }}>
|
||||
<div style={{ fontSize: '14px', marginBottom: '8px', fontWeight: 600 }}>Maßstabs-Kalibrierung</div>
|
||||
{!calibrationMode ? (
|
||||
<button
|
||||
onClick={() => setCalibrationMode(true)}
|
||||
style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer',
|
||||
border: '1px solid var(--color-border, #444)', borderRadius: '4px',
|
||||
background: 'var(--color-bg-secondary, #2a2a3a)', color: 'var(--color-text, #e0e0e0)' }}
|
||||
>
|
||||
Kalibrierung starten
|
||||
</button>
|
||||
) : (
|
||||
<div>
|
||||
<p style={{ fontSize: '13px', margin: '0 0 8px' }}>
|
||||
Klicken Sie auf zwei Punkte mit bekanntem Abstand im Bild.
|
||||
</p>
|
||||
{calibPoint1 && !calibPoint2 && <p style={{ fontSize: '13px', color: '#8be9fd' }}>Erster Punkt gesetzt. Bitte zweiten Punkt klicken.</p>}
|
||||
{calibPoint1 && calibPoint2 && (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="Referenzstrecke"
|
||||
value={realDistance}
|
||||
onChange={(e) => setRealDistance(e.target.value)}
|
||||
style={{ width: '120px', padding: '4px 8px', fontSize: '13px' }}
|
||||
/>
|
||||
<select value={unit} onChange={(e) => setUnit(e.target.value)} style={{ padding: '4px 8px', fontSize: '13px' }}>
|
||||
<option value="m">m</option>
|
||||
<option value="cm">cm</option>
|
||||
<option value="mm">mm</option>
|
||||
</select>
|
||||
<button onClick={handleCalibrate} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Übernehmen</button>
|
||||
<button onClick={() => { setCalibPoint1(null); setCalibPoint2(null); }} style={{ padding: '6px 12px', fontSize: '13px', cursor: 'pointer' }}>Zurücksetzen</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{config.scale !== 1 && (
|
||||
<p style={{ fontSize: '12px', marginTop: '8px', color: '#50fa7b' }}>
|
||||
Aktueller Maßstab: {config.scale.toFixed(2)} px/mm
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{previewUrl && (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
{/* Opacity */}
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Deckkraft: {Math.round(config.opacity * 100)}%</label>
|
||||
<input
|
||||
type="range" min="0" max="1" step="0.05"
|
||||
value={config.opacity}
|
||||
onChange={(e) => updateConfig({ opacity: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
{/* Position */}
|
||||
<div style={{ display: 'flex', gap: '12px', marginBottom: '8px' }}>
|
||||
<label style={{ fontSize: '13px' }}>X-Offset:
|
||||
<input type="number" value={config.offsetX}
|
||||
onChange={(e) => updateConfig({ offsetX: parseFloat(e.target.value) || 0 })}
|
||||
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
|
||||
</label>
|
||||
<label style={{ fontSize: '13px' }}>Y-Offset:
|
||||
<input type="number" value={config.offsetY}
|
||||
onChange={(e) => updateConfig({ offsetY: parseFloat(e.target.value) || 0 })}
|
||||
style={{ width: '80px', marginLeft: '6px', padding: '4px' }} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Rotation */}
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Rotation: {config.rotation}°</label>
|
||||
<input
|
||||
type="range" min="-180" max="180" step="1"
|
||||
value={config.rotation}
|
||||
onChange={(e) => updateConfig({ rotation: parseFloat(e.target.value) })}
|
||||
style={{ width: '100%', marginBottom: '12px' }}
|
||||
/>
|
||||
|
||||
{/* Scale */}
|
||||
<label style={{ display: 'block', marginBottom: '4px', fontSize: '13px' }}>Skalierung: {config.scale.toFixed(3)}</label>
|
||||
<input
|
||||
type="number" step="0.01" value={config.scale}
|
||||
onChange={(e) => updateConfig({ scale: parseFloat(e.target.value) || 1 })}
|
||||
style={{ width: '120px', padding: '4px' }}
|
||||
/>
|
||||
|
||||
{/* Visibility */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '6px', marginTop: '12px', fontSize: '13px' }}>
|
||||
<input
|
||||
type="checkbox" checked={config.visible}
|
||||
onChange={(e) => updateConfig({ visible: e.target.checked })}
|
||||
/>
|
||||
Sichtbar
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{ padding: '8px 16px', cursor: 'pointer', borderRadius: '4px',
|
||||
border: '1px solid var(--color-border, #444)', background: 'transparent',
|
||||
color: 'var(--color-text, #e0e0e0)' }}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={!previewUrl}
|
||||
style={{ padding: '8px 16px', cursor: previewUrl ? 'pointer' : 'not-allowed', borderRadius: '4px',
|
||||
border: 'none', background: previewUrl ? '#50fa7b' : '#555',
|
||||
color: previewUrl ? '#1e1e2e' : '#999', fontWeight: 600 }}
|
||||
>
|
||||
Anwenden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackgroundImport;
|
||||
Reference in New Issue
Block a user