grundriss/templates/index.html

817 lines
No EOL
30 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Grundriss Planer</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; }
header { background: #16213e; padding: 16px 24px; display: flex; align-items: center; gap: 16px; border-bottom: 2px solid #0f3460; }
header h1 { font-size: 1.3rem; color: #e94560; flex: 1; }
header h1 span { color: #888; font-weight: normal; font-size: 0.9rem; }
.btn { padding: 8px 18px; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; transition: all .2s; }
.btn-primary { background: #e94560; color: #fff; }
.btn-primary:hover { background: #d63851; }
.btn-secondary { background: #0f3460; color: #e0e0e0; }
.btn-secondary:hover { background: #1a4a8a; }
.btn-danger { background: #c0392b; color: #fff; }
.btn-danger:hover { background: #a93226; }
.btn-sm { padding: 4px 10px; font-size: 0.8rem; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover { background: #219a52; }
.container { display: flex; height: calc(100vh - 60px); }
/* Sidebar */
.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; }
.project-list { display: flex; flex-direction: column; gap: 6px; }
.project-item { background: #1a1a2e; padding: 10px 12px; border-radius: 6px; cursor: pointer; border: 1px solid transparent; transition: all .2s; }
.project-item:hover { border-color: #0f3460; }
.project-item.active { border-color: #e94560; background: #1a1a3e; }
.project-item .name { font-weight: 600; }
.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; 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; }
.wall-input { display: flex; gap: 6px; margin-bottom: 6px; align-items: center; }
.wall-input input { flex: 1; background: #1a1a2e; border: 1px solid #333; color: #e0e0e0; padding: 6px 8px; border-radius: 4px; font-size: 0.85rem; }
.wall-input input:focus { outline: none; border-color: #e94560; }
.wall-input .unit { color: #888; font-size: 0.8rem; width: 20px; }
.measurements { margin-top: 12px; }
.measurement { display: flex; justify-content: space-between; padding: 4px 0; font-size: 0.85rem; border-bottom: 1px solid #1a1a2e; }
.measurement .label { color: #aaa; }
.measurement .value { font-weight: 600; color: #27ae60; }
.measurement .value.unknown { color: #e67e22; }
/* 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: 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, .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; }
/* Modal */
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.show { display: flex; }
.modal { background: #16213e; border-radius: 12px; padding: 24px; min-width: 400px; max-width: 500px; border: 1px solid #0f3460; }
.modal h2 { margin-bottom: 16px; color: #e94560; }
.modal label { display: block; margin-bottom: 4px; color: #aaa; font-size: 0.85rem; }
.modal input, .modal select { width: 100%; background: #1a1a2e; border: 1px solid #333; color: #e0e0e0; padding: 8px 10px; border-radius: 4px; margin-bottom: 12px; }
.modal .modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 8px; }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #333; border-radius: 3px; }
/* Empty state */
.empty-state { text-align: center; padding: 40px 20px; color: #666; }
.empty-state .icon { font-size: 3rem; margin-bottom: 12px; }
.empty-state p { margin-bottom: 8px; }
/* 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; }
</style>
</head>
<body>
<header>
<h1>🏠 Grundriss Planer <span id="projectTitle"> Kein Projekt geladen</span></h1>
<button class="btn btn-primary" onclick="showNewProjectModal()">+ Neues Projekt</button>
<button class="btn btn-secondary" onclick="showNewRoomModal()" id="btnAddRoom" disabled>+ Raum</button>
</header>
<div class="container">
<!-- Sidebar -->
<div class="sidebar" id="sidebar">
<h2>📁 Projekte</h2>
<div class="project-list" id="projectList"></div>
<div id="roomSection" style="display:none">
<h2>🏠 Räume</h2>
<div class="room-list" id="roomList"></div>
<h3>📏 Wandmaße (cm)</h3>
<div id="wallInputs"></div>
<button class="btn btn-secondary btn-sm" onclick="addWallInput()">+ Wandmaß</button>
<h3>📐 Raum-Info</h3>
<div class="measurements" id="measurements"></div>
</div>
</div>
<!-- Canvas -->
<div class="canvas-area" id="canvasArea">
<div class="canvas-toolbar" id="canvasToolbar" style="display:none">
<label>Zoom:</label>
<button class="tool-btn" onclick="zoom(-1)"></button>
<input type="number" id="scaleInput" value="5" min="1" max="30" onchange="setScale(this.value)" style="width:50px">
<button class="tool-btn" onclick="zoom(1)">+</button>
<label>Raster: <input type="checkbox" id="gridToggle" checked></label>
<button class="tool-btn" onclick="fitCanvas()">🔲 Passend</button>
</div>
<canvas id="floorplan"></canvas>
<div class="canvas-info" id="canvasInfo">Kein Raum ausgewählt Klicke einen Raum an oder ziehe ihn auf der Fläche</div>
</div>
</div>
<!-- New Project Modal -->
<div class="modal-overlay" id="newProjectModal">
<div class="modal">
<h2>Neues Projekt</h2>
<label>Projektname</label>
<input type="text" id="newProjectName" placeholder="z.B. Meine Wohnung" value="Meine Wohnung">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal('newProjectModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="createProject()">Erstellen</button>
</div>
</div>
</div>
<!-- New Room Modal -->
<div class="modal-overlay" id="newRoomModal">
<div class="modal">
<h2>Neuen Raum hinzufügen</h2>
<label>Raumname</label>
<input type="text" id="newRoomName" placeholder="z.B. Wohnzimmer">
<label>Farbe</label>
<input type="color" id="newRoomColor" value="#e94560">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal('newRoomModal')">Abbrechen</button>
<button class="btn btn-primary" onclick="addRoom()">Hinzufügen</button>
</div>
</div>
</div>
<script>
// ===== STATE =====
const STATE = {
projects: [],
currentProject: null,
currentRoomIdx: -1,
rooms: [],
scale: 5, // pixels per cm
offsetX: 0,
offsetY: 0,
isDragging: false,
dragMode: null, // 'pan' or 'room'
dragStart: null,
selectedRoom: -1,
};
const COLORS = ['#e94560','#3498db','#27ae60','#e67e22','#9b59b6','#1abc9c','#f39c12','#e74c3c','#2ecc71','#ff6b81'];
// ===== API =====
async function api(method, path, body) {
const opts = { method, headers: {'Content-Type':'application/json'} };
if (body) opts.body = JSON.stringify(body);
const r = await fetch('/api' + path, opts);
return r.json();
}
// ===== PROJECTS =====
async function loadProjects() {
STATE.projects = await api('GET', '/projects');
renderProjectList();
}
function renderProjectList() {
const el = document.getElementById('projectList');
if (STATE.projects.length === 0) {
el.innerHTML = '<div class="empty-state"><div class="icon">📐</div><p>Noch keine Projekte</p></div>';
return;
}
el.innerHTML = STATE.projects.map(p => `
<div class="project-item ${STATE.currentProject && STATE.currentProject.id === p.id ? 'active' : ''}"
onclick="loadProject('${p.id}')">
<div class="name">${p.name}</div>
<div class="meta">${p.rooms} Räume · ${p.updated ? new Date(p.updated).toLocaleDateString('de-DE') : ''}</div>
</div>
`).join('');
}
async function createProject() {
const name = document.getElementById('newProjectName').value || 'Meine Wohnung';
const result = await api('POST', '/projects', { name });
closeModal('newProjectModal');
await loadProjects();
await loadProject(result.id);
}
async function loadProject(pid) {
const data = await api('GET', '/projects/' + pid);
if (data.error) return;
STATE.currentProject = data;
STATE.rooms = data.rooms || [];
STATE.currentRoomIdx = -1;
STATE.selectedRoom = -1;
document.getElementById('projectTitle').textContent = ' ' + data.name;
document.getElementById('btnAddRoom').disabled = false;
document.getElementById('roomSection').style.display = 'block';
document.getElementById('canvasToolbar').style.display = 'flex';
renderRoomList();
renderWallInputs();
renderMeasurements();
fitCanvas();
renderProjectList();
}
// ===== ROOMS =====
function showNewRoomModal() {
document.getElementById('newRoomName').value = '';
document.getElementById('newRoomColor').value = COLORS[STATE.rooms.length % COLORS.length];
document.getElementById('newRoomModal').classList.add('show');
}
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,
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();
renderWallInputs();
renderMeasurements();
fitCanvas();
closeModal('newRoomModal');
}
function renderRoomList() {
const el = document.getElementById('roomList');
if (STATE.rooms.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Noch keine Räume</div>';
return;
}
el.innerHTML = STATE.rooms.map((r, i) => `
<div class="room-item ${i === STATE.currentRoomIdx ? 'active' : ''}" onclick="selectRoom(${i})">
<div>
<span class="rcolor" style="background:${r.color}"></span>
<span class="rname">${r.name}</span>
<span class="rdim"> · ${r.width_cm}×${r.height_cm}cm</span>
</div>
<button class="btn btn-danger btn-sm" onclick="event.stopPropagation(); deleteRoom(${i})">✕</button>
</div>
`).join('');
}
function selectRoom(idx) {
STATE.currentRoomIdx = idx;
STATE.selectedRoom = idx;
renderRoomList();
renderWallInputs();
renderMeasurements();
renderCanvas();
}
async function deleteRoom(idx) {
if (!confirm(`"${STATE.rooms[idx].name}" wirklich löschen?`)) return;
STATE.rooms.splice(idx, 1);
if (STATE.currentRoomIdx >= STATE.rooms.length) STATE.currentRoomIdx = STATE.rooms.length - 1;
if (STATE.selectedRoom >= STATE.rooms.length) STATE.selectedRoom = STATE.rooms.length - 1;
await saveCurrentProject();
renderRoomList();
renderWallInputs();
renderMeasurements();
renderCanvas();
}
// ===== WALL INPUTS =====
function renderWallInputs() {
const el = document.getElementById('wallInputs');
const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) { el.innerHTML = ''; return; }
const ms = room.known_measurements || [];
if (ms.length === 0) {
el.innerHTML = '<div style="color:#666;font-size:0.85rem;padding:8px">Füge Wandmaße hinzu!</div>';
return;
}
const sideNames = { top: 'Oben', right: 'Rechts', bottom: 'Unten', left: 'Links' };
el.innerHTML = ms.map((m, i) => `
<div class="wall-input">
<input type="text" value="${m.label}" placeholder="Wand" onchange="updateWall(${i},'label',this.value)" style="width:70px">
<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>
<button class="btn btn-danger btn-sm" onclick="deleteWall(${i})">✕</button>
</div>
`).join('');
}
function addWallInput() {
const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) return;
if (!room.known_measurements) room.known_measurements = [];
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 updateWall(idx, field, val) {
const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) return;
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 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) {
const room = STATE.rooms[STATE.currentRoomIdx];
if (!room) return;
room.known_measurements.splice(idx, 1);
renderWallInputs();
renderMeasurements();
renderCanvas();
saveCurrentProject();
}
// ===== MEASUREMENTS =====
function renderMeasurements() {
const el = document.getElementById('measurements');
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 = `<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) {
html += '<div class="measurement"><span class="label">Keine Maße</span><span class="value unknown">—</span></div>';
} 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>`;
}
});
}
// 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 += `<div class="measurement"><span class="label">→ Unten (geschätzt)</span><span class="value unknown">~${topM.length_cm} cm</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;
}
// ===== CANVAS =====
const canvas = document.getElementById('floorplan');
const ctx = canvas.getContext('2d');
function resizeCanvas() {
const area = document.getElementById('canvasArea');
canvas.width = area.clientWidth - 4;
canvas.height = area.clientHeight - 4;
renderCanvas();
}
function fitCanvas() {
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 + r.width_cm > maxX) maxX = r.x + r.width_cm;
if (r.y + r.height_cm > maxY) maxY = r.y + r.height_cm;
});
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, 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
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 r = getRoomRect(room);
const isSelected = idx === STATE.selectedRoom;
// Room fill
ctx.fillStyle = room.color + (isSelected ? '33' : '15');
ctx.fillRect(r.x, r.y, r.w, r.h);
// Room border
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 = 'bold 13px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.fillText(room.name, r.x + r.w/2, r.y + r.h/2 - 6);
// 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 => {
if (!m.length_cm) return;
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 '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 '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 '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.5;
ctx.setLineDash([5, 3]);
ctx.beginPath(); ctx.moveTo(lx1, ly1); ctx.lineTo(lx2, ly2); ctx.stroke();
ctx.setLineDash([]);
// Arrows
drawArrow(ctx, lx1, ly1, lx2, ly2);
drawArrow(ctx, lx2, ly2, lx1, ly1);
// Label
ctx.fillStyle = '#27ae60';
ctx.font = 'bold 11px -apple-system, sans-serif';
ctx.textAlign = 'center';
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);
}
});
});
// Info
const room = STATE.rooms[STATE.selectedRoom];
document.getElementById('canvasInfo').textContent = room
? `${room.name} · ${room.width_cm}×${room.height_cm}cm · ${Math.round(room.width_cm*room.height_cm/10000*100)/100}`
: 'Kein Raum ausgewählt Klicke einen Raum an';
}
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 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 t = e.touches ? e.touches[0] : e;
return { x: t.clientX - rect.left, y: t.clientY - rect.top };
}
function hitTestRoom(mx, my) {
for (let i = STATE.rooms.length - 1; i >= 0; i--) {
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;
const p = getMousePos(e);
dragStart = { x: p.x, y: p.y, ox: STATE.offsetX, oy: STATE.offsetY };
canvas.classList.add('grabbing');
}
function onMove(e) {
if (isPanning) {
const p = getMousePos(e);
STATE.offsetX = dragStart.ox + (p.x - dragStart.x);
STATE.offsetY = dragStart.oy + (p.y - dragStart.y);
renderCanvas();
return;
}
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;
}
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);
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;
}
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);
renderCanvas();
renderRoomList();
renderMeasurements();
return;
}
}
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 =====
let saveTimeout = null;
function saveCurrentProject() {
if (!STATE.currentProject) return;
STATE.currentProject.rooms = STATE.rooms;
clearTimeout(saveTimeout);
saveTimeout = setTimeout(async () => {
await api('PUT', '/projects/' + STATE.currentProject.id, { rooms: STATE.rooms });
}, 500);
}
// ===== INIT =====
window.addEventListener('resize', resizeCanvas);
document.addEventListener('DOMContentLoaded', () => {
resizeCanvas();
loadProjects();
});
</script>
</body>
</html>