817 lines
No EOL
30 KiB
HTML
817 lines
No EOL
30 KiB
HTML
<!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}m²`
|
||
: '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> |