feat: drag rooms, resize handles, no overlap, zoom controls

This commit is contained in:
sascha 2026-07-27 21:12:37 +02:00
parent 737aed5dbb
commit 4288d3781d

View file

@ -24,7 +24,7 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }
.container { display: flex; height: calc(100vh - 60px); } .container { display: flex; height: calc(100vh - 60px); }
/* Sidebar */ /* 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 h2 { font-size: 1rem; color: #e94560; margin-bottom: 12px; }
.sidebar h3 { font-size: 0.9rem; color: #aaa; margin: 16px 0 8px; } .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; } .project-item .meta { font-size: 0.75rem; color: #888; margin-top: 2px; }
.room-list { display: flex; flex-direction: column; gap: 4px; } .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 .rname { font-weight: 500; }
.room-item .rdim { font-size: 0.8rem; color: #888; } .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; } .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 */
.canvas-area { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; position: relative; overflow: hidden; background: #0d0d1a; } .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-area canvas { cursor: grab; }
.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.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 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; } .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 .icon { font-size: 3rem; margin-bottom: 12px; }
.empty-state p { margin-bottom: 8px; } .empty-state p { margin-bottom: 8px; }
/* Dimension labels on canvas */ /* Tool buttons */
.dim-label { position: absolute; font-size: 11px; color: #e94560; font-weight: 600; pointer-events: none; z-index: 5; } .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; }
</style> </style>
</head> </head>
<body> <body>
@ -101,11 +108,11 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }
<h2>🏠 Räume</h2> <h2>🏠 Räume</h2>
<div class="room-list" id="roomList"></div> <div class="room-list" id="roomList"></div>
<h3>📏 Bekannte Maße</h3> <h3>📏 Wandmaße (cm)</h3>
<div id="wallInputs"></div> <div id="wallInputs"></div>
<button class="btn btn-secondary btn-sm" onclick="addWallInput()">+ Wandmaß hinzufügen</button> <button class="btn btn-secondary btn-sm" onclick="addWallInput()">+ Wandmaß</button>
<h3>📐 Berechnete Maße</h3> <h3>📐 Raum-Info</h3>
<div class="measurements" id="measurements"></div> <div class="measurements" id="measurements"></div>
</div> </div>
</div> </div>
@ -113,18 +120,15 @@ header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }
<!-- Canvas --> <!-- Canvas -->
<div class="canvas-area" id="canvasArea"> <div class="canvas-area" id="canvasArea">
<div class="canvas-toolbar" id="canvasToolbar" style="display:none"> <div class="canvas-toolbar" id="canvasToolbar" style="display:none">
<label>Werkzeug: <label>Zoom:</label>
<select id="toolSelect"> <button class="tool-btn" onclick="zoom(-1)"></button>
<option value="select">Auswählen</option> <input type="number" id="scaleInput" value="5" min="1" max="30" onchange="setScale(this.value)" style="width:50px">
<option value="wall">Wand zeichnen</option> <button class="tool-btn" onclick="zoom(1)">+</button>
<option value="move">Verschieben</option>
</select>
</label>
<label>Raster: <input type="checkbox" id="gridToggle" checked></label> <label>Raster: <input type="checkbox" id="gridToggle" checked></label>
<button class="btn btn-secondary btn-sm" onclick="fitCanvas()">🔲 Passend</button> <button class="tool-btn" onclick="fitCanvas()">🔲 Passend</button>
</div> </div>
<canvas id="floorplan"></canvas> <canvas id="floorplan"></canvas>
<div class="canvas-info" id="canvasInfo">Kein Raum ausgewählt</div> <div class="canvas-info" id="canvasInfo">Kein Raum ausgewählt Klicke einen Raum an oder ziehe ihn auf der Fläche</div>
</div> </div>
</div> </div>
@ -163,20 +167,16 @@ const STATE = {
currentProject: null, currentProject: null,
currentRoomIdx: -1, currentRoomIdx: -1,
rooms: [], rooms: [],
walls: [], // known wall measurements per room scale: 5, // pixels per cm
// Canvas state
scale: 10, // pixels per cm
offsetX: 0, offsetX: 0,
offsetY: 0, offsetY: 0,
isDragging: false, isDragging: false,
dragMode: null, // 'pan' or 'room'
dragStart: null, dragStart: null,
selectedRoom: -1, 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 ===== // ===== API =====
async function api(method, path, body) { async function api(method, path, body) {
@ -195,7 +195,7 @@ async function loadProjects() {
function renderProjectList() { function renderProjectList() {
const el = document.getElementById('projectList'); const el = document.getElementById('projectList');
if (STATE.projects.length === 0) { if (STATE.projects.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="icon">📐</div><p>Noch keine Projekte</p><p style="font-size:0.8rem">Erstelle ein neues Projekt um zu starten</p></div>'; el.innerHTML = '<div class="empty-state"><div class="icon">📐</div><p>Noch keine Projekte</p></div>';
return; return;
} }
el.innerHTML = STATE.projects.map(p => ` el.innerHTML = STATE.projects.map(p => `
@ -227,7 +227,9 @@ async function loadProject(pid) {
document.getElementById('roomSection').style.display = 'block'; document.getElementById('roomSection').style.display = 'block';
document.getElementById('canvasToolbar').style.display = 'flex'; document.getElementById('canvasToolbar').style.display = 'flex';
renderRoomList(); renderRoomList();
renderCanvas(); renderWallInputs();
renderMeasurements();
fitCanvas();
renderProjectList(); renderProjectList();
} }
@ -241,27 +243,40 @@ function showNewRoomModal() {
async function addRoom() { async function addRoom() {
const name = document.getElementById('newRoomName').value || 'Raum ' + (STATE.rooms.length + 1); const name = document.getElementById('newRoomName').value || 'Raum ' + (STATE.rooms.length + 1);
const color = document.getElementById('newRoomColor').value; 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 = { const room = {
id: 'room_' + Date.now(), id: 'room_' + Date.now(),
name, name,
color, color,
walls: [], // {id, length_cm, label, x1,y1,x2,y2} - placed on canvas x: x, // canvas position in cm
known_measurements: [], // {id, label, length_cm} y: y,
x: 50, y: 50, // position on canvas width_cm: 360, // default size
height_cm: 360,
known_measurements: [], // {id, label, length_cm, side}
}; };
STATE.rooms.push(room); STATE.rooms.push(room);
STATE.currentRoomIdx = STATE.rooms.length - 1; STATE.currentRoomIdx = STATE.rooms.length - 1;
STATE.selectedRoom = STATE.currentRoomIdx; STATE.selectedRoom = STATE.currentRoomIdx;
await saveCurrentProject(); await saveCurrentProject();
renderRoomList(); renderRoomList();
renderCanvas(); renderWallInputs();
renderMeasurements();
fitCanvas();
closeModal('newRoomModal'); closeModal('newRoomModal');
} }
function renderRoomList() { function renderRoomList() {
const el = document.getElementById('roomList'); const el = document.getElementById('roomList');
if (STATE.rooms.length === 0) { if (STATE.rooms.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Noch keine Räume. Klick auf "+ Raum"</div>'; el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Noch keine Räume</div>';
return; return;
} }
el.innerHTML = STATE.rooms.map((r, i) => ` el.innerHTML = STATE.rooms.map((r, i) => `
@ -269,7 +284,7 @@ function renderRoomList() {
<div> <div>
<span class="rcolor" style="background:${r.color}"></span> <span class="rcolor" style="background:${r.color}"></span>
<span class="rname">${r.name}</span> <span class="rname">${r.name}</span>
<span class="rdim">${r.known_measurements ? r.known_measurements.length : 0} Maße</span> <span class="rdim"> · ${r.width_cm}×${r.height_cm}cm</span>
</div> </div>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); deleteRoom(${i})"></button> <button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); deleteRoom(${i})"></button>
</div> </div>
@ -304,13 +319,20 @@ function renderWallInputs() {
if (!room) { el.innerHTML = ''; return; } if (!room) { el.innerHTML = ''; return; }
const ms = room.known_measurements || []; const ms = room.known_measurements || [];
if (ms.length === 0) { if (ms.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Noch keine Maße. Füge Wandmaße hinzu!</div>'; el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Füge Wandmaße hinzu!</div>';
return; return;
} }
const sideNames = { top: 'Oben', right: 'Rechts', bottom: 'Unten', left: 'Links' };
el.innerHTML = ms.map((m, i) => ` el.innerHTML = ms.map((m, i) => `
<div class="wall-input"> <div class="wall-input">
<input type="text" value="${m.label}" placeholder="z.B. Wand A" onchange="updateWallLabel(${i}, this.value)" style="width:80px"> <input type="text" value="${m.label}" placeholder="Wand" onchange="updateWall(${i},'label',this.value)" style="width:70px">
<input type="number" value="${m.length_cm}" placeholder="cm" min="1" onchange="updateWallLength(${i}, this.value)" style="width:80px"> <select onchange="updateWall(${i},'side',this.value)" style="width:80px;background:#1a1a2e;border:1px solid #333;color:#e0e0e0;padding:6px;border-radius:4px;font-size:0.85rem">
<option value="top" ${m.side==='top'?'selected':''}>Oben</option>
<option value="right" ${m.side==='right'?'selected':''}>Rechts</option>
<option value="bottom" ${m.side==='bottom'?'selected':''}>Unten</option>
<option value="left" ${m.side==='left'?'selected':''}>Links</option>
</select>
<input type="number" value="${m.length_cm}" placeholder="cm" min="1" onchange="updateWall(${i},'length',parseInt(this.value))" style="width:70px">
<span class="unit">cm</span> <span class="unit">cm</span>
<button class="btn btn-danger btn-sm" onclick="deleteWall(${i})"></button> <button class="btn btn-danger btn-sm" onclick="deleteWall(${i})"></button>
</div> </div>
@ -321,29 +343,47 @@ function addWallInput() {
const room = STATE.rooms[STATE.currentRoomIdx]; const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) return; if (!room) return;
if (!room.known_measurements) room.known_measurements = []; if (!room.known_measurements) room.known_measurements = [];
const labels = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; const labels = 'ABCDEFGH';
const label = labels[room.known_measurements.length % labels.length]; const label = 'Wand ' + labels[room.known_measurements.length % labels.length];
room.known_measurements.push({ id: 'm_' + Date.now(), label: 'Wand ' + label, length_cm: 0 }); 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(); renderWallInputs();
renderMeasurements(); renderMeasurements();
saveCurrentProject(); saveCurrentProject();
} }
function updateWallLabel(idx, val) { function updateWall(idx, field, val) {
const room = STATE.rooms[STATE.currentRoomIdx]; const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) return; 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(); renderMeasurements();
renderRoomList();
renderCanvas();
saveCurrentProject(); saveCurrentProject();
} }
function updateWallLength(idx, val) { function autoResizeRoom(room) {
const room = STATE.rooms[STATE.currentRoomIdx]; // If we have top/bottom measurement, set height; if left/right, set width
if (!room) return; const ms = room.known_measurements || [];
room.known_measurements[idx].length_cm = parseInt(val) || 0; let topM = ms.find(m => m.side === 'top' && m.length_cm > 0);
renderMeasurements(); let bottomM = ms.find(m => m.side === 'bottom' && m.length_cm > 0);
renderCanvas(); let leftM = ms.find(m => m.side === 'left' && m.length_cm > 0);
saveCurrentProject(); 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) { function deleteWall(idx) {
@ -362,28 +402,42 @@ function renderMeasurements() {
const room = STATE.rooms[STATE.currentRoomIdx]; const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) { el.innerHTML = ''; return; } if (!room) { el.innerHTML = ''; return; }
const ms = room.known_measurements || []; const ms = room.known_measurements || [];
const sideNames = { top: '↔ Oben', right: '↕ Rechts', bottom: '↔ Unten', left: '↕ Links' };
let html = `<div class="measurement"><span class="label">Raumgröße</span><span class="value">${room.width_cm}×${room.height_cm} cm</span></div>`;
html += `<div class="measurement"><span class="label">Fläche</span><span class="value">${Math.round(room.width_cm * room.height_cm / 10000 * 100) / 100} m²</span></div>`;
if (ms.length === 0) { if (ms.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Füge Maße hinzu um Berechnungen zu sehen</div>'; html += '<div class="measurement"><span class="label">Keine Maße</span><span class="value unknown"></span></div>';
return; } else {
ms.forEach(m => {
if (m.length_cm > 0) {
html += `<div class="measurement"><span class="label">${m.label} (${sideNames[m.side]||'?'})</span><span class="value">${m.length_cm} cm</span></div>`;
} else {
html += `<div class="measurement"><span class="label">${m.label} (${sideNames[m.side]||'?'})</span><span class="value unknown">? cm</span></div>`;
}
});
} }
const known = ms.filter(m => m.length_cm > 0);
const unknown = ms.filter(m => !m.length_cm || m.length_cm === 0); // Calculate opposite walls if both sides have data
let html = ''; const topM = ms.find(m => m.side === 'top' && m.length_cm > 0);
known.forEach(m => { const bottomM = ms.find(m => m.side === 'bottom' && m.length_cm > 0);
html += `<div class="measurement"><span class="label">${m.label}</span><span class="value">${m.length_cm} cm</span></div>`; const leftM = ms.find(m => m.side === 'left' && m.length_cm > 0);
}); const rightM = ms.find(m => m.side === 'right' && m.length_cm > 0);
unknown.forEach(m => {
html += `<div class="measurement"><span class="label">${m.label}</span><span class="value unknown">? cm (unbekannt)</span></div>`; if (topM && !bottomM) {
}); html += `<div class="measurement"><span class="label">→ Unten (geschätzt)</span><span class="value unknown">~${topM.length_cm} cm</span></div>`;
// 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 += `<div class="measurement" style="border-top:1px solid #333;margin-top:4px;padding-top:6px">
<span class="label">📐 Geschätzt (∅ ${avg} cm)</span>
<span class="value unknown">pro Wand</span>
</div>`;
} }
if (bottomM && !topM) {
html += `<div class="measurement"><span class="label">→ Oben (geschätzt)</span><span class="value unknown">~${bottomM.length_cm} cm</span></div>`;
}
if (leftM && !rightM) {
html += `<div class="measurement"><span class="label">→ Rechts (geschätzt)</span><span class="value unknown">~${leftM.length_cm} cm</span></div>`;
}
if (rightM && !leftM) {
html += `<div class="measurement"><span class="label">→ Links (geschätzt)</span><span class="value unknown">~${rightM.length_cm} cm</span></div>`;
}
el.innerHTML = html; el.innerHTML = html;
} }
@ -399,229 +453,357 @@ function resizeCanvas() {
} }
function fitCanvas() { function fitCanvas() {
if (STATE.rooms.length === 0) return; if (STATE.rooms.length === 0) { renderCanvas(); return; }
// Find bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
STATE.rooms.forEach(r => { STATE.rooms.forEach(r => {
if (r.x < minX) minX = r.x; if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y; if (r.y < minY) minY = r.y;
if (r.x + 400 > maxX) maxX = r.x + 400; if (r.x + r.width_cm > maxX) maxX = r.x + r.width_cm;
if (r.y + 400 > maxY) maxY = r.y + 400; if (r.y + r.height_cm > maxY) maxY = r.y + r.height_cm;
}); });
if (minX === Infinity) return; const margin = 200;
const margin = 100; const bw = (maxX - minX) + margin * 2;
const bw = maxX - minX + margin * 2; const bh = (maxY - minY) + margin * 2;
const bh = maxY - minY + margin * 2;
const scaleX = (canvas.width - 40) / bw; const scaleX = (canvas.width - 40) / bw;
const scaleY = (canvas.height - 40) / bh; const scaleY = (canvas.height - 40) / bh;
STATE.scale = Math.min(scaleX, scaleY, 15); STATE.scale = Math.min(scaleX, scaleY, 10);
STATE.offsetX = -minX * STATE.scale + margin * STATE.scale / 2 + 20; STATE.scale = Math.max(STATE.scale, 1);
STATE.offsetY = -minY * STATE.scale + margin * STATE.scale / 2 + 20; 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(); 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() { function renderCanvas() {
const W = canvas.width, H = canvas.height; const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H); ctx.clearRect(0, 0, W, H);
// Grid // Grid
const gridToggle = document.getElementById('gridToggle'); if (document.getElementById('gridToggle')?.checked) {
if (gridToggle && gridToggle.checked) { const gridStep = cmToPx(50); // 50cm grid
const gridStep = 50 * STATE.scale; // 50cm grid if (gridStep > 5) {
ctx.strokeStyle = '#1a1a3e'; ctx.strokeStyle = '#1a1a3e';
ctx.lineWidth = 0.5; ctx.lineWidth = 0.5;
const startX = STATE.offsetX % gridStep; const sx = STATE.offsetX % gridStep;
const startY = STATE.offsetY % gridStep; const sy = STATE.offsetY % gridStep;
for (let x = startX; x < W; x += gridStep) { for (let x = sx; x < W; x += gridStep) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke(); ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
} }
for (let y = startY; y < H; y += gridStep) { for (let y = sy; y < H; y += gridStep) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
} }
} }
// Draw rooms // Draw rooms
STATE.rooms.forEach((room, idx) => { STATE.rooms.forEach((room, idx) => {
const x = room.x * STATE.scale + STATE.offsetX; const r = getRoomRect(room);
const y = room.y * STATE.scale + STATE.offsetY; const isSelected = idx === STATE.selectedRoom;
const w = 360 * STATE.scale; // default 360cm
const h = 360 * STATE.scale;
// Room background // Room fill
ctx.fillStyle = room.color + '22'; ctx.fillStyle = room.color + (isSelected ? '33' : '15');
ctx.fillRect(x, y, w, h); ctx.fillRect(r.x, r.y, r.w, r.h);
// Room border // Room border
ctx.strokeStyle = idx === STATE.selectedRoom ? '#e94560' : room.color; ctx.strokeStyle = isSelected ? '#fff' : room.color;
ctx.lineWidth = idx === STATE.selectedRoom ? 3 : 2; ctx.lineWidth = isSelected ? 3 : 2;
ctx.strokeRect(x, y, w, h); 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 // Room label
ctx.fillStyle = '#e0e0e0'; ctx.fillStyle = '#e0e0e0';
ctx.font = '14px -apple-system, sans-serif'; ctx.font = 'bold 13px -apple-system, sans-serif';
ctx.textAlign = 'center'; 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 || []; const ms = room.known_measurements || [];
ms.forEach((m, mi) => { ms.forEach(m => {
if (!m.length_cm) return; if (!m.length_cm) return;
const wallPos = mi % 4; // 0=top, 1=right, 2=bottom, 3=left let lx1, ly1, lx2, ly2, tx, ty;
let lx1, ly1, lx2, ly2; switch (m.side) {
const offset = 20 + mi * 14; case 'top':
switch(wallPos) { lx1 = r.x; ly1 = r.y - 18; lx2 = r.x + r.w; ly2 = r.y - 18;
case 0: // top tx = r.x + r.w/2; ty = r.y - 22;
lx1 = x; ly1 = y - offset; lx2 = x + w; ly2 = y - offset;
break; break;
case 1: // right case 'bottom':
lx1 = x + w + offset; ly1 = y; lx2 = x + w + offset; ly2 = y + h; 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; break;
case 2: // bottom case 'left':
lx1 = x; ly1 = y + h + offset; lx2 = x + w; ly2 = y + h + offset; 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; break;
case 3: // left case 'right':
lx1 = x - offset; ly1 = y; lx2 = x - offset; ly2 = y + h; 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; break;
} }
ctx.strokeStyle = '#27ae60'; ctx.strokeStyle = '#27ae60';
ctx.lineWidth = 1; ctx.lineWidth = 1.5;
ctx.setLineDash([4, 3]); ctx.setLineDash([5, 3]);
ctx.beginPath(); ctx.moveTo(lx1, ly1); ctx.lineTo(lx2, ly2); ctx.stroke(); ctx.beginPath(); ctx.moveTo(lx1, ly1); ctx.lineTo(lx2, ly2); ctx.stroke();
ctx.setLineDash([]); ctx.setLineDash([]);
// Arrow tips // Arrows
const angle = wallPos <= 1 ? 0 : Math.PI/2; drawArrow(ctx, lx1, ly1, lx2, ly2);
const ax = wallPos <= 1 ? lx1 : lx1; drawArrow(ctx, lx2, ly2, lx1, ly1);
const ay = wallPos <= 1 ? ly1 : ly1;
ctx.fillStyle = '#27ae60';
// Label // Label
ctx.font = '11px -apple-system, sans-serif'; ctx.fillStyle = '#27ae60';
ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center'; ctx.textAlign = 'center';
ctx.fillText(`${m.label}: ${m.length_cm}cm`, (lx1+lx2)/2, ly1 - 4); if (m.side === 'left' || m.side === 'right') {
}); ctx.save();
ctx.translate(tx, ty);
// Unknown measurements if (m.side === 'left') ctx.rotate(-Math.PI/2);
ms.forEach((m, mi) => { else ctx.rotate(Math.PI/2);
if (m.length_cm) return; ctx.fillText(`${m.label}: ${m.length_cm}cm`, 0, 4);
const wallPos = mi % 4; ctx.restore();
let lx1, ly1, lx2, ly2; } else {
const offset = 20 + mi * 14; ctx.fillText(`${m.label}: ${m.length_cm}cm`, tx, ty);
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;
} }
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 // Info
const room = STATE.rooms[STATE.selectedRoom]; const room = STATE.rooms[STATE.selectedRoom];
document.getElementById('canvasInfo').textContent = room document.getElementById('canvasInfo').textContent = room
? `${room.name} · ${(room.known_measurements||[]).filter(m=>m.length_cm>0).length} bekannte Maße` ? `${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'; : 'Kein Raum ausgewählt Klicke einen Raum an';
} }
// ===== CANVAS EVENTS ===== function drawArrow(ctx, x1, y1, x2, y2) {
let isPanning = false, panStart = null; 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 rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left; const t = e.touches ? e.touches[0] : e;
const my = e.clientY - rect.top; 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--) { for (let i = STATE.rooms.length - 1; i >= 0; i--) {
const r = STATE.rooms[i]; const r = getRoomRect(STATE.rooms[i]);
const x = r.x * STATE.scale + STATE.offsetX; if (mx >= r.x && mx <= r.x + r.w && my >= r.y && my <= r.y + r.h) return i;
const y = r.y * STATE.scale + STATE.offsetY; }
const w = 360 * STATE.scale; return -1;
const h = 360 * STATE.scale; }
if (mx >= x && mx <= x + w && my >= y && my <= y + h) {
STATE.selectedRoom = i; function hitTestHandle(mx, my) {
STATE.currentRoomIdx = i; if (STATE.selectedRoom < 0) return null;
renderRoomList(); const room = STATE.rooms[STATE.selectedRoom];
renderWallInputs(); const r = getRoomRect(room);
renderMeasurements(); const hs = 10;
renderCanvas(); const handles = {
return; '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 // Pan canvas
isPanning = true; 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) { if (isPanning) {
STATE.offsetX = panStart.ox + (e.clientX - panStart.x); const p = getMousePos(e);
STATE.offsetY = panStart.oy + (e.clientY - panStart.y); STATE.offsetX = dragStart.ox + (p.x - dragStart.x);
STATE.offsetY = dragStart.oy + (p.y - dragStart.y);
renderCanvas(); renderCanvas();
return;
} }
});
canvas.addEventListener('mouseup', () => { isPanning = false; }); if (dragRoomIdx >= 0) {
canvas.addEventListener('mouseleave', () => { isPanning = false; }); 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 if (isResizing && STATE.selectedRoom >= 0) {
canvas.addEventListener('touchstart', e => { const p = getMousePos(e);
e.preventDefault(); const room = STATE.rooms[STATE.selectedRoom];
const touch = e.touches[0]; const mx_cm = pxToCm(p.x - STATE.offsetX);
const rect = canvas.getBoundingClientRect(); const my_cm = pxToCm(p.y - STATE.offsetY);
const mx = touch.clientX - rect.left;
const my = touch.clientY - rect.top;
for (let i = STATE.rooms.length - 1; i >= 0; i--) { if (resizeHandle.includes('e')) room.width_cm = Math.max(50, Math.round((mx_cm - room.x) / 10) * 10);
const r = STATE.rooms[i]; if (resizeHandle.includes('w')) {
const x = r.x * STATE.scale + STATE.offsetX; const newW = Math.max(50, room.x + room.width_cm - mx_cm);
const y = r.y * STATE.scale + STATE.offsetY; room.x = room.x + room.width_cm - newW;
const w = 360 * STATE.scale; room.width_cm = newW;
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('s')) room.height_cm = Math.max(50, Math.round((my_cm - room.y) / 10) * 10);
isPanning = true; if (resizeHandle.includes('n')) {
panStart = { x: touch.clientX, y: touch.clientY, ox: STATE.offsetX, oy: STATE.offsetY }; const newH = Math.max(50, room.y + room.height_cm - my_cm);
}, { passive: false }); 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(); 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 ===== // ===== MODALS =====
function showNewProjectModal() { document.getElementById('newProjectModal').classList.add('show'); } function showNewProjectModal() { document.getElementById('newProjectModal').classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); } function closeModal(id) { document.getElementById(id).classList.remove('show'); }
// ===== SAVE ===== // ===== SAVE =====
async function saveCurrentProject() { let saveTimeout = null;
function saveCurrentProject() {
if (!STATE.currentProject) return; if (!STATE.currentProject) return;
STATE.currentProject.rooms = STATE.rooms; 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 ===== // ===== INIT =====