From 4288d3781dab8cd77dd7706634e7747ad294cb26 Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 27 Jul 2026 21:12:37 +0200 Subject: [PATCH] feat: drag rooms, resize handles, no overlap, zoom controls --- templates/index.html | 610 ++++++++++++++++++++++++++++--------------- 1 file changed, 396 insertions(+), 214 deletions(-) diff --git a/templates/index.html b/templates/index.html index 7f686f9..a79d136 100644 --- a/templates/index.html +++ b/templates/index.html @@ -24,7 +24,7 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; } .container { display: flex; height: calc(100vh - 60px); } /* Sidebar */ -.sidebar { width: 320px; min-width: 320px; background: #16213e; padding: 16px; overflow-y: auto; border-right: 2px solid #0f3460; } +.sidebar { width: 340px; min-width: 340px; background: #16213e; padding: 16px; overflow-y: auto; border-right: 2px solid #0f3460; } .sidebar h2 { font-size: 1rem; color: #e94560; margin-bottom: 12px; } .sidebar h3 { font-size: 0.9rem; color: #aaa; margin: 16px 0 8px; } @@ -36,7 +36,9 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; } .project-item .meta { font-size: 0.75rem; color: #888; margin-top: 2px; } .room-list { display: flex; flex-direction: column; gap: 4px; } -.room-item { background: #1a1a2e; padding: 8px 10px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; } +.room-item { background: #1a1a2e; padding: 8px 10px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; border: 1px solid transparent; transition: all .2s; } +.room-item:hover { border-color: #0f3460; } +.room-item.active { border-color: #e94560; } .room-item .rname { font-weight: 500; } .room-item .rdim { font-size: 0.8rem; color: #888; } .room-item .rcolor { width: 14px; height: 14px; border-radius: 3px; display: inline-block; margin-right: 6px; } @@ -54,10 +56,12 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; } /* Canvas area */ .canvas-area { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #0d0d1a; } -.canvas-area canvas { cursor: crosshair; } -.canvas-toolbar { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; background: #16213e; padding: 8px 16px; border-radius: 8px; border: 1px solid #0f3460; z-index: 10; } +.canvas-area canvas { cursor: grab; } +.canvas-area canvas.grabbing { cursor: grabbing; } +.canvas-toolbar { position: absolute; top: 12px; left: 50%; transform: translateX(-50%); display: flex; gap: 8px; background: #16213e; padding: 8px 16px; border-radius: 8px; border: 1px solid #0f3460; z-index: 10; align-items: center; } .canvas-toolbar label { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; color: #aaa; } -.canvas-toolbar select { background: #1a1a2e; border: 1px solid #333; color: #e0e0e0; padding: 4px 8px; border-radius: 4px; } +.canvas-toolbar select, .canvas-toolbar input[type="number"] { background: #1a1a2e; border: 1px solid #333; color: #e0e0e0; padding: 4px 8px; border-radius: 4px; font-size: 0.85rem; } +.canvas-toolbar input[type="number"] { width: 60px; } .canvas-info { position: absolute; bottom: 12px; left: 12px; background: #16213ee0; padding: 8px 12px; border-radius: 6px; font-size: 0.8rem; color: #888; z-index: 10; } @@ -80,8 +84,11 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; } .empty-state .icon { font-size: 3rem; margin-bottom: 12px; } .empty-state p { margin-bottom: 8px; } -/* Dimension labels on canvas */ -.dim-label { position: absolute; font-size: 11px; color: #e94560; font-weight: 600; pointer-events: none; z-index: 5; } +/* Tool buttons */ +.tool-btn { background: #1a1a2e; border: 1px solid #333; color: #e0e0e0; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 0.85rem; transition: all .2s; } +.tool-btn:hover { border-color: #0f3460; } +.tool-btn.active { background: #e94560; color: #fff; border-color: #e94560; } + @@ -101,11 +108,11 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }

🏠 Räume

-

📏 Bekannte Maße

+

📏 Wandmaße (cm)

- + -

📐 Berechnete Maße

+

📐 Raum-Info

@@ -113,18 +120,15 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }
-
Kein Raum ausgewählt
+
Kein Raum ausgewählt – Klicke einen Raum an oder ziehe ihn auf der Fläche
@@ -163,20 +167,16 @@ const STATE = { currentProject: null, currentRoomIdx: -1, rooms: [], - walls: [], // known wall measurements per room - // Canvas state - scale: 10, // pixels per cm + scale: 5, // pixels per cm offsetX: 0, offsetY: 0, isDragging: false, + dragMode: null, // 'pan' or 'room' dragStart: null, selectedRoom: -1, - // Wall drawing - drawingWall: false, - wallStart: null, }; -const COLORS = ['#e94560','#0f3460','#27ae60','#e67e22','#9b59b6','#3498db','#1abc9c','#f39c12','#e74c3c','#2ecc71']; +const COLORS = ['#e94560','#3498db','#27ae60','#e67e22','#9b59b6','#1abc9c','#f39c12','#e74c3c','#2ecc71','#ff6b81']; // ===== API ===== async function api(method, path, body) { @@ -195,7 +195,7 @@ async function loadProjects() { function renderProjectList() { const el = document.getElementById('projectList'); if (STATE.projects.length === 0) { - el.innerHTML = '
📐

Noch keine Projekte

Erstelle ein neues Projekt um zu starten

'; + el.innerHTML = '
📐

Noch keine Projekte

'; return; } el.innerHTML = STATE.projects.map(p => ` @@ -227,7 +227,9 @@ async function loadProject(pid) { document.getElementById('roomSection').style.display = 'block'; document.getElementById('canvasToolbar').style.display = 'flex'; renderRoomList(); - renderCanvas(); + renderWallInputs(); + renderMeasurements(); + fitCanvas(); renderProjectList(); } @@ -241,27 +243,40 @@ function showNewRoomModal() { async function addRoom() { const name = document.getElementById('newRoomName').value || 'Raum ' + (STATE.rooms.length + 1); const color = document.getElementById('newRoomColor').value; + + // Auto-position: place to the right of last room + let x = 100, y = 100; + if (STATE.rooms.length > 0) { + const last = STATE.rooms[STATE.rooms.length - 1]; + x = last.x + last.width_cm + 100; + y = last.y; + } + const room = { id: 'room_' + Date.now(), name, color, - walls: [], // {id, length_cm, label, x1,y1,x2,y2} - placed on canvas - known_measurements: [], // {id, label, length_cm} - x: 50, y: 50, // position on canvas + x: x, // canvas position in cm + y: y, + width_cm: 360, // default size + height_cm: 360, + known_measurements: [], // {id, label, length_cm, side} }; STATE.rooms.push(room); STATE.currentRoomIdx = STATE.rooms.length - 1; STATE.selectedRoom = STATE.currentRoomIdx; await saveCurrentProject(); renderRoomList(); - renderCanvas(); + renderWallInputs(); + renderMeasurements(); + fitCanvas(); closeModal('newRoomModal'); } function renderRoomList() { const el = document.getElementById('roomList'); if (STATE.rooms.length === 0) { - el.innerHTML = '
Noch keine Räume. Klick auf "+ Raum"
'; + el.innerHTML = '
Noch keine Räume
'; return; } el.innerHTML = STATE.rooms.map((r, i) => ` @@ -269,7 +284,7 @@ function renderRoomList() {
${r.name} - ${r.known_measurements ? r.known_measurements.length : 0} Maße + · ${r.width_cm}×${r.height_cm}cm
@@ -304,13 +319,20 @@ function renderWallInputs() { if (!room) { el.innerHTML = ''; return; } const ms = room.known_measurements || []; if (ms.length === 0) { - el.innerHTML = '
Noch keine Maße. Füge Wandmaße hinzu!
'; + el.innerHTML = '
Füge Wandmaße hinzu!
'; return; } + const sideNames = { top: 'Oben', right: 'Rechts', bottom: 'Unten', left: 'Links' }; el.innerHTML = ms.map((m, i) => `
- - + + + cm
@@ -321,29 +343,47 @@ function addWallInput() { const room = STATE.rooms[STATE.currentRoomIdx]; if (!room) return; if (!room.known_measurements) room.known_measurements = []; - const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; - const label = labels[room.known_measurements.length % labels.length]; - room.known_measurements.push({ id: 'm_' + Date.now(), label: 'Wand ' + label, length_cm: 0 }); + const labels = 'ABCDEFGH'; + const label = 'Wand ' + labels[room.known_measurements.length % labels.length]; + const sides = ['top','right','bottom','left']; + const side = sides[room.known_measurements.length % 4]; + room.known_measurements.push({ id: 'm_' + Date.now(), label, length_cm: 0, side }); renderWallInputs(); renderMeasurements(); saveCurrentProject(); } -function updateWallLabel(idx, val) { +function updateWall(idx, field, val) { const room = STATE.rooms[STATE.currentRoomIdx]; if (!room) return; - room.known_measurements[idx].label = val; + if (field === 'length') { + room.known_measurements[idx].length_cm = val || 0; + // Auto-resize room based on measurement + autoResizeRoom(room); + } else if (field === 'side') { + room.known_measurements[idx].side = val; + } else if (field === 'label') { + room.known_measurements[idx].label = val; + } renderMeasurements(); + renderRoomList(); + renderCanvas(); saveCurrentProject(); } -function updateWallLength(idx, val) { - const room = STATE.rooms[STATE.currentRoomIdx]; - if (!room) return; - room.known_measurements[idx].length_cm = parseInt(val) || 0; - renderMeasurements(); - renderCanvas(); - saveCurrentProject(); +function autoResizeRoom(room) { + // If we have top/bottom measurement, set height; if left/right, set width + const ms = room.known_measurements || []; + let topM = ms.find(m => m.side === 'top' && m.length_cm > 0); + let bottomM = ms.find(m => m.side === 'bottom' && m.length_cm > 0); + let leftM = ms.find(m => m.side === 'left' && m.length_cm > 0); + let rightM = ms.find(m => m.side === 'right' && m.length_cm > 0); + + // top/bottom = width dimension, left/right = height dimension + if (topM) room.width_cm = topM.length_cm; + if (bottomM && !topM) room.width_cm = bottomM.length_cm; + if (leftM) room.height_cm = leftM.length_cm; + if (rightM && !leftM) room.height_cm = rightM.length_cm; } function deleteWall(idx) { @@ -362,28 +402,42 @@ function renderMeasurements() { const room = STATE.rooms[STATE.currentRoomIdx]; if (!room) { el.innerHTML = ''; return; } const ms = room.known_measurements || []; + const sideNames = { top: '↔ Oben', right: '↕ Rechts', bottom: '↔ Unten', left: '↕ Links' }; + + let html = `
Raumgröße${room.width_cm}×${room.height_cm} cm
`; + html += `
Fläche${Math.round(room.width_cm * room.height_cm / 10000 * 100) / 100} m²
`; + if (ms.length === 0) { - el.innerHTML = '
Füge Maße hinzu um Berechnungen zu sehen
'; - return; + html += '
Keine Maße
'; + } else { + ms.forEach(m => { + if (m.length_cm > 0) { + html += `
${m.label} (${sideNames[m.side]||'?'})${m.length_cm} cm
`; + } else { + html += `
${m.label} (${sideNames[m.side]||'?'})? cm
`; + } + }); } - const known = ms.filter(m => m.length_cm > 0); - const unknown = ms.filter(m => !m.length_cm || m.length_cm === 0); - let html = ''; - known.forEach(m => { - html += `
${m.label}${m.length_cm} cm
`; - }); - unknown.forEach(m => { - html += `
${m.label}? cm (unbekannt)
`; - }); - // Try to calculate unknown from known - if (known.length > 0 && unknown.length > 0) { - const totalKnown = known.reduce((s, m) => s + m.length_cm, 0); - const avg = Math.round(totalKnown / known.length); - html += `
- 📐 Geschätzt (∅ ${avg} cm) - pro Wand -
`; + + // Calculate opposite walls if both sides have data + const topM = ms.find(m => m.side === 'top' && m.length_cm > 0); + const bottomM = ms.find(m => m.side === 'bottom' && m.length_cm > 0); + const leftM = ms.find(m => m.side === 'left' && m.length_cm > 0); + const rightM = ms.find(m => m.side === 'right' && m.length_cm > 0); + + if (topM && !bottomM) { + html += `
→ Unten (geschätzt)~${topM.length_cm} cm
`; } + if (bottomM && !topM) { + html += `
→ Oben (geschätzt)~${bottomM.length_cm} cm
`; + } + if (leftM && !rightM) { + html += `
→ Rechts (geschätzt)~${leftM.length_cm} cm
`; + } + if (rightM && !leftM) { + html += `
→ Links (geschätzt)~${rightM.length_cm} cm
`; + } + el.innerHTML = html; } @@ -399,229 +453,357 @@ function resizeCanvas() { } function fitCanvas() { - if (STATE.rooms.length === 0) return; - // Find bounding box + if (STATE.rooms.length === 0) { renderCanvas(); return; } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; STATE.rooms.forEach(r => { if (r.x < minX) minX = r.x; if (r.y < minY) minY = r.y; - if (r.x + 400 > maxX) maxX = r.x + 400; - if (r.y + 400 > maxY) maxY = r.y + 400; + if (r.x + r.width_cm > maxX) maxX = r.x + r.width_cm; + if (r.y + r.height_cm > maxY) maxY = r.y + r.height_cm; }); - if (minX === Infinity) return; - const margin = 100; - const bw = maxX - minX + margin * 2; - const bh = maxY - minY + margin * 2; + const margin = 200; + const bw = (maxX - minX) + margin * 2; + const bh = (maxY - minY) + margin * 2; const scaleX = (canvas.width - 40) / bw; const scaleY = (canvas.height - 40) / bh; - STATE.scale = Math.min(scaleX, scaleY, 15); - STATE.offsetX = -minX * STATE.scale + margin * STATE.scale / 2 + 20; - STATE.offsetY = -minY * STATE.scale + margin * STATE.scale / 2 + 20; + STATE.scale = Math.min(scaleX, scaleY, 10); + STATE.scale = Math.max(STATE.scale, 1); + document.getElementById('scaleInput').value = STATE.scale.toFixed(1); + STATE.offsetX = -minX * STATE.scale + (canvas.width - bw * STATE.scale) / 2; + STATE.offsetY = -minY * STATE.scale + (canvas.height - bh * STATE.scale) / 2; renderCanvas(); } +function setScale(v) { + STATE.scale = Math.max(0.5, Math.min(30, parseFloat(v) || 5)); + renderCanvas(); +} + +function zoom(dir) { + STATE.scale = Math.max(0.5, Math.min(30, STATE.scale + dir * 0.5)); + document.getElementById('scaleInput').value = STATE.scale.toFixed(1); + renderCanvas(); +} + +function cmToPx(cm) { return cm * STATE.scale; } +function pxToCm(px) { return px / STATE.scale; } + +function getRoomRect(room) { + return { + x: cmToPx(room.x) + STATE.offsetX, + y: cmToPx(room.y) + STATE.offsetY, + w: cmToPx(room.width_cm), + h: cmToPx(room.height_cm) + }; +} + +function roomsOverlap(a, b) { + return !(a.x + a.width_cm <= b.x || b.x + b.width_cm <= a.x || a.y + a.height_cm <= b.y || b.y + b.height_cm <= a.y); +} + +function resolveOverlap(room, skipIdx) { + let attempts = 0; + while (attempts < 20) { + let moved = false; + for (let i = 0; i < STATE.rooms.length; i++) { + if (i === skipIdx) continue; + const other = STATE.rooms[i]; + if (roomsOverlap(room, other)) { + // Push room to the right of the other + room.x = other.x + other.width_cm + 50; + moved = true; + } + } + if (!moved) break; + attempts++; + } +} + function renderCanvas() { const W = canvas.width, H = canvas.height; ctx.clearRect(0, 0, W, H); // Grid - const gridToggle = document.getElementById('gridToggle'); - if (gridToggle && gridToggle.checked) { - const gridStep = 50 * STATE.scale; // 50cm grid - ctx.strokeStyle = '#1a1a3e'; - ctx.lineWidth = 0.5; - const startX = STATE.offsetX % gridStep; - const startY = STATE.offsetY % gridStep; - for (let x = startX; x < W; x += gridStep) { - ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); - } - for (let y = startY; y < H; y += gridStep) { - ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + if (document.getElementById('gridToggle')?.checked) { + const gridStep = cmToPx(50); // 50cm grid + if (gridStep > 5) { + ctx.strokeStyle = '#1a1a3e'; + ctx.lineWidth = 0.5; + const sx = STATE.offsetX % gridStep; + const sy = STATE.offsetY % gridStep; + for (let x = sx; x < W; x += gridStep) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); + } + for (let y = sy; y < H; y += gridStep) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); + } } } // Draw rooms STATE.rooms.forEach((room, idx) => { - const x = room.x * STATE.scale + STATE.offsetX; - const y = room.y * STATE.scale + STATE.offsetY; - const w = 360 * STATE.scale; // default 360cm - const h = 360 * STATE.scale; + const r = getRoomRect(room); + const isSelected = idx === STATE.selectedRoom; - // Room background - ctx.fillStyle = room.color + '22'; - ctx.fillRect(x, y, w, h); + // Room fill + ctx.fillStyle = room.color + (isSelected ? '33' : '15'); + ctx.fillRect(r.x, r.y, r.w, r.h); // Room border - ctx.strokeStyle = idx === STATE.selectedRoom ? '#e94560' : room.color; - ctx.lineWidth = idx === STATE.selectedRoom ? 3 : 2; - ctx.strokeRect(x, y, w, h); + ctx.strokeStyle = isSelected ? '#fff' : room.color; + ctx.lineWidth = isSelected ? 3 : 2; + ctx.strokeRect(r.x, r.y, r.w, r.h); + + // Resize handles if selected + if (isSelected) { + const hs = 8; + ctx.fillStyle = '#e94560'; + // Corners + [[r.x, r.y], [r.x + r.w, r.y], [r.x, r.y + r.h], [r.x + r.w, r.y + r.h]].forEach(([hx, hy]) => { + ctx.fillRect(hx - hs/2, hy - hs/2, hs, hs); + }); + // Edges (midpoints) + ctx.fillStyle = '#e94560aa'; + [[r.x + r.w/2, r.y], [r.x + r.w/2, r.y + r.h], [r.x, r.y + r.h/2], [r.x + r.w, r.y + r.h/2]].forEach(([hx, hy]) => { + ctx.fillRect(hx - hs/2, hy - hs/2, hs, hs); + }); + } // Room label ctx.fillStyle = '#e0e0e0'; - ctx.font = '14px -apple-system, sans-serif'; + ctx.font = 'bold 13px -apple-system, sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(room.name, x + w/2, y - 8); + ctx.fillText(room.name, r.x + r.w/2, r.y + r.h/2 - 6); - // Known measurements as dimension lines + // Dimensions + ctx.fillStyle = '#aaa'; + ctx.font = '11px -apple-system, sans-serif'; + ctx.fillText(`${room.width_cm}×${room.height_cm}cm`, r.x + r.w/2, r.y + r.h/2 + 12); + + // Wall measurements const ms = room.known_measurements || []; - ms.forEach((m, mi) => { + ms.forEach(m => { if (!m.length_cm) return; - const wallPos = mi % 4; // 0=top, 1=right, 2=bottom, 3=left - let lx1, ly1, lx2, ly2; - const offset = 20 + mi * 14; - switch(wallPos) { - case 0: // top - lx1 = x; ly1 = y - offset; lx2 = x + w; ly2 = y - offset; + let lx1, ly1, lx2, ly2, tx, ty; + switch (m.side) { + case 'top': + lx1 = r.x; ly1 = r.y - 18; lx2 = r.x + r.w; ly2 = r.y - 18; + tx = r.x + r.w/2; ty = r.y - 22; break; - case 1: // right - lx1 = x + w + offset; ly1 = y; lx2 = x + w + offset; ly2 = y + h; + case 'bottom': + lx1 = r.x; ly1 = r.y + r.h + 18; lx2 = r.x + r.w; ly2 = r.y + r.h + 18; + tx = r.x + r.w/2; ty = r.y + r.h + 30; break; - case 2: // bottom - lx1 = x; ly1 = y + h + offset; lx2 = x + w; ly2 = y + h + offset; + case 'left': + lx1 = r.x - 18; ly1 = r.y; lx2 = r.x - 18; ly2 = r.y + r.h; + tx = r.x - 18; ty = r.y + r.h/2; break; - case 3: // left - lx1 = x - offset; ly1 = y; lx2 = x - offset; ly2 = y + h; + case 'right': + lx1 = r.x + r.w + 18; ly1 = r.y; lx2 = r.x + r.w + 18; ly2 = r.y + r.h; + tx = r.x + r.w + 22; ty = r.y + r.h/2; break; } ctx.strokeStyle = '#27ae60'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 3]); + ctx.lineWidth = 1.5; + ctx.setLineDash([5, 3]); ctx.beginPath(); ctx.moveTo(lx1, ly1); ctx.lineTo(lx2, ly2); ctx.stroke(); ctx.setLineDash([]); - // Arrow tips - const angle = wallPos <= 1 ? 0 : Math.PI/2; - const ax = wallPos <= 1 ? lx1 : lx1; - const ay = wallPos <= 1 ? ly1 : ly1; - ctx.fillStyle = '#27ae60'; + // Arrows + drawArrow(ctx, lx1, ly1, lx2, ly2); + drawArrow(ctx, lx2, ly2, lx1, ly1); + // Label - ctx.font = '11px -apple-system, sans-serif'; + ctx.fillStyle = '#27ae60'; + ctx.font = 'bold 11px -apple-system, sans-serif'; ctx.textAlign = 'center'; - ctx.fillText(`${m.label}: ${m.length_cm}cm`, (lx1+lx2)/2, ly1 - 4); - }); - - // Unknown measurements - ms.forEach((m, mi) => { - if (m.length_cm) return; - const wallPos = mi % 4; - let lx1, ly1, lx2, ly2; - const offset = 20 + mi * 14; - switch(wallPos) { - case 0: lx1 = x; ly1 = y - offset; lx2 = x + w; ly2 = y - offset; break; - case 1: lx1 = x + w + offset; ly1 = y; lx2 = x + w + offset; ly2 = y + h; break; - case 2: lx1 = x; ly1 = y + h + offset; lx2 = x + w; ly2 = y + h + offset; break; - case 3: lx1 = x - offset; ly1 = y; lx2 = x - offset; ly2 = y + h; break; + if (m.side === 'left' || m.side === 'right') { + ctx.save(); + ctx.translate(tx, ty); + if (m.side === 'left') ctx.rotate(-Math.PI/2); + else ctx.rotate(Math.PI/2); + ctx.fillText(`${m.label}: ${m.length_cm}cm`, 0, 4); + ctx.restore(); + } else { + ctx.fillText(`${m.label}: ${m.length_cm}cm`, tx, ty); } - ctx.strokeStyle = '#e67e22'; - ctx.lineWidth = 1; - ctx.setLineDash([4, 3]); - ctx.beginPath(); ctx.moveTo(lx1, ly1); ctx.lineTo(lx2, ly2); ctx.stroke(); - ctx.setLineDash([]); - ctx.fillStyle = '#e67e22'; - ctx.font = '11px -apple-system, sans-serif'; - ctx.textAlign = 'center'; - ctx.fillText(`${m.label}: ? cm`, (lx1+lx2)/2, ly1 - 4); }); }); // Info const room = STATE.rooms[STATE.selectedRoom]; document.getElementById('canvasInfo').textContent = room - ? `${room.name} · ${(room.known_measurements||[]).filter(m=>m.length_cm>0).length} bekannte Maße` - : 'Kein Raum ausgewählt'; + ? `${room.name} · ${room.width_cm}×${room.height_cm}cm · ${Math.round(room.width_cm*room.height_cm/10000*100)/100}m²` + : 'Kein Raum ausgewählt – Klicke einen Raum an'; } -// ===== CANVAS EVENTS ===== -let isPanning = false, panStart = null; +function drawArrow(ctx, x1, y1, x2, y2) { + const angle = Math.atan2(y2 - y1, x2 - x1); + const size = 6; + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x1 + size * Math.cos(angle - 0.4), y1 + size * Math.sin(angle - 0.4)); + ctx.lineTo(x1 + size * Math.cos(angle + 0.4), y1 + size * Math.sin(angle + 0.4)); + ctx.closePath(); + ctx.fill(); +} -canvas.addEventListener('mousedown', e => { +// ===== CANVAS INTERACTION ===== +let isPanning = false; +let dragRoomIdx = -1; +let dragOffset = { x: 0, y: 0 }; +let isResizing = false; +let resizeHandle = null; + +function getMousePos(e) { const rect = canvas.getBoundingClientRect(); - const mx = e.clientX - rect.left; - const my = e.clientY - rect.top; + const t = e.touches ? e.touches[0] : e; + return { x: t.clientX - rect.left, y: t.clientY - rect.top }; +} - // Check if clicking on a room +function hitTestRoom(mx, my) { for (let i = STATE.rooms.length - 1; i >= 0; i--) { - const r = STATE.rooms[i]; - const x = r.x * STATE.scale + STATE.offsetX; - const y = r.y * STATE.scale + STATE.offsetY; - const w = 360 * STATE.scale; - const h = 360 * STATE.scale; - if (mx >= x && mx <= x + w && my >= y && my <= y + h) { - STATE.selectedRoom = i; - STATE.currentRoomIdx = i; - renderRoomList(); - renderWallInputs(); - renderMeasurements(); - renderCanvas(); - return; - } + const r = getRoomRect(STATE.rooms[i]); + if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return i; + } + return -1; +} + +function hitTestHandle(mx, my) { + if (STATE.selectedRoom < 0) return null; + const room = STATE.rooms[STATE.selectedRoom]; + const r = getRoomRect(room); + const hs = 10; + const handles = { + 'nw': [r.x, r.y], 'ne': [r.x + r.w, r.y], 'sw': [r.x, r.y + r.h], 'se': [r.x + r.w, r.y + r.h], + 'n': [r.x + r.w/2, r.y], 's': [r.x + r.w/2, r.y + r.h], + 'w': [r.x, r.y + r.h/2], 'e': [r.x + r.w, r.y + r.h/2] + }; + for (const [name, [hx, hy]] of Object.entries(handles)) { + if (Math.abs(mx - hx) < hs && Math.abs(my - hy) < hs) return name; + } + return null; +} + +function onDown(e) { + e.preventDefault(); + const { x: mx, y: my } = getMousePos(e); + + // Check resize handles first + const handle = hitTestHandle(mx, my); + if (handle) { + isResizing = true; + resizeHandle = handle; + canvas.classList.add('grabbing'); + return; + } + + // Check room hit + const idx = hitTestRoom(mx, my); + if (idx >= 0) { + STATE.selectedRoom = idx; + STATE.currentRoomIdx = idx; + const r = getRoomRect(STATE.rooms[idx]); + dragRoomIdx = idx; + dragOffset = { x: mx - r.x, y: my - r.y }; + canvas.classList.add('grabbing'); + renderRoomList(); + renderWallInputs(); + renderMeasurements(); + renderCanvas(); + return; } // Pan canvas isPanning = true; - panStart = { x: e.clientX, y: e.clientY, ox: STATE.offsetX, oy: STATE.offsetY }; -}); + const p = getMousePos(e); + dragStart = { x: p.x, y: p.y, ox: STATE.offsetX, oy: STATE.offsetY }; + canvas.classList.add('grabbing'); +} -canvas.addEventListener('mousemove', e => { +function onMove(e) { if (isPanning) { - STATE.offsetX = panStart.ox + (e.clientX - panStart.x); - STATE.offsetY = panStart.oy + (e.clientY - panStart.y); + const p = getMousePos(e); + STATE.offsetX = dragStart.ox + (p.x - dragStart.x); + STATE.offsetY = dragStart.oy + (p.y - dragStart.y); renderCanvas(); + return; } -}); -canvas.addEventListener('mouseup', () => { isPanning = false; }); -canvas.addEventListener('mouseleave', () => { isPanning = false; }); + if (dragRoomIdx >= 0) { + const p = getMousePos(e); + const room = STATE.rooms[dragRoomIdx]; + const newX = pxToCm(p.x - dragOffset.x - STATE.offsetX); + const newY = pxToCm(p.y - dragOffset.y - STATE.offsetY); + // Snap to 10cm grid + room.x = Math.round(newX / 10) * 10; + room.y = Math.round(newY / 10) * 10; + renderCanvas(); + return; + } -// Touch support -canvas.addEventListener('touchstart', e => { - e.preventDefault(); - const touch = e.touches[0]; - const rect = canvas.getBoundingClientRect(); - const mx = touch.clientX - rect.left; - const my = touch.clientY - rect.top; + if (isResizing && STATE.selectedRoom >= 0) { + const p = getMousePos(e); + const room = STATE.rooms[STATE.selectedRoom]; + const mx_cm = pxToCm(p.x - STATE.offsetX); + const my_cm = pxToCm(p.y - STATE.offsetY); - for (let i = STATE.rooms.length - 1; i >= 0; i--) { - const r = STATE.rooms[i]; - const x = r.x * STATE.scale + STATE.offsetX; - const y = r.y * STATE.scale + STATE.offsetY; - const w = 360 * STATE.scale; - const h = 360 * STATE.scale; - if (mx >= x && mx <= x + w && my >= y && my <= y + h) { - STATE.selectedRoom = i; - STATE.currentRoomIdx = i; - renderRoomList(); - renderWallInputs(); - renderMeasurements(); - renderCanvas(); - return; + if (resizeHandle.includes('e')) room.width_cm = Math.max(50, Math.round((mx_cm - room.x) / 10) * 10); + if (resizeHandle.includes('w')) { + const newW = Math.max(50, room.x + room.width_cm - mx_cm); + room.x = room.x + room.width_cm - newW; + room.width_cm = newW; } - } - isPanning = true; - panStart = { x: touch.clientX, y: touch.clientY, ox: STATE.offsetX, oy: STATE.offsetY }; -}, { passive: false }); + if (resizeHandle.includes('s')) room.height_cm = Math.max(50, Math.round((my_cm - room.y) / 10) * 10); + if (resizeHandle.includes('n')) { + const newH = Math.max(50, room.y + room.height_cm - my_cm); + room.y = room.y + room.height_cm - newH; + room.height_cm = newH; + } + // Snap minimum + room.width_cm = Math.max(50, room.width_cm); + room.height_cm = Math.max(50, room.height_cm); -canvas.addEventListener('touchmove', e => { - e.preventDefault(); - if (isPanning && e.touches.length === 1) { - const touch = e.touches[0]; - STATE.offsetX = panStart.ox + (touch.clientX - panStart.x); - STATE.offsetY = panStart.oy + (touch.clientY - panStart.y); renderCanvas(); + renderRoomList(); + renderMeasurements(); + return; } -}, { passive: false }); +} -canvas.addEventListener('touchend', () => { isPanning = false; }); +function onUp() { + if (dragRoomIdx >= 0 || isResizing) { + saveCurrentProject(); + } + isPanning = false; + dragRoomIdx = -1; + isResizing = false; + resizeHandle = null; + canvas.classList.remove('grabbing'); +} + +canvas.addEventListener('mousedown', onDown); +canvas.addEventListener('mousemove', onMove); +canvas.addEventListener('mouseup', onUp); +canvas.addEventListener('mouseleave', onUp); + +canvas.addEventListener('touchstart', onDown, { passive: false }); +canvas.addEventListener('touchmove', onMove, { passive: false }); +canvas.addEventListener('touchend', onUp); // ===== MODALS ===== function showNewProjectModal() { document.getElementById('newProjectModal').classList.add('show'); } function closeModal(id) { document.getElementById(id).classList.remove('show'); } // ===== SAVE ===== -async function saveCurrentProject() { +let saveTimeout = null; +function saveCurrentProject() { if (!STATE.currentProject) return; STATE.currentProject.rooms = STATE.rooms; - await api('PUT', '/projects/' + STATE.currentProject.id, { rooms: STATE.rooms }); + clearTimeout(saveTimeout); + saveTimeout = setTimeout(async () => { + await api('PUT', '/projects/' + STATE.currentProject.id, { rooms: STATE.rooms }); + }, 500); } // ===== INIT ===== @@ -632,4 +814,4 @@ document.addEventListener('DOMContentLoaded', () => { }); - + \ No newline at end of file