/** * HouseEditor — UI panel for house customization. * * Provides controls to create/edit houses, add/remove rooms, * adjust dimensions, manage floors, and save as templates. */ export class HouseEditor { constructor(container, { renderer, onHouseChanged }) { this.container = container; this.renderer = renderer; this.onHouseChanged = onHouseChanged || (() => {}); this._editing = false; this._selectedRoomId = null; this.render(); } get houseData() { return this.renderer.houseData; } render() { this.container.innerHTML = ''; if (!this.houseData) { this.container.innerHTML = '

No house loaded

'; return; } // Toggle button const toggleBtn = document.createElement('button'); toggleBtn.className = 'he-toggle-btn'; toggleBtn.textContent = this._editing ? 'Close Editor' : 'Edit House'; toggleBtn.addEventListener('click', () => { this._editing = !this._editing; this.render(); }); this.container.appendChild(toggleBtn); if (!this._editing) return; // House metadata section this._renderMetadataSection(); // Building section this._renderBuildingSection(); // Floor management this._renderFloorSection(); // Room list with editing this._renderRoomSection(); // Room editor (if a room is selected) if (this._selectedRoomId) { this._renderRoomEditor(); } // Save as template this._renderSaveSection(); } setSelectedRoom(roomId) { this._selectedRoomId = roomId; if (this._editing) { this.render(); } } // ---- Metadata Section ---- _renderMetadataSection() { const section = this._createSection('House Info'); const nameRow = this._createFieldRow('Name'); const nameInput = document.createElement('input'); nameInput.className = 'he-input'; nameInput.value = this.houseData.name || ''; nameInput.addEventListener('change', () => { this.houseData.name = nameInput.value; this.onHouseChanged('name'); }); nameRow.appendChild(nameInput); section.appendChild(nameRow); const descRow = this._createFieldRow('Description'); const descInput = document.createElement('input'); descInput.className = 'he-input'; descInput.value = this.houseData.description || ''; descInput.addEventListener('change', () => { this.houseData.description = descInput.value; this.onHouseChanged('description'); }); descRow.appendChild(descInput); section.appendChild(descRow); this.container.appendChild(section); } // ---- Building Section ---- _renderBuildingSection() { const section = this._createSection('Building'); const building = this.houseData.building || {}; const footprint = building.footprint || {}; const widthRow = this._createFieldRow('Width (m)'); const widthInput = this._createNumberInput(footprint.width || 12, 4, 30, 0.5, (val) => { if (!this.houseData.building) this.houseData.building = {}; if (!this.houseData.building.footprint) this.houseData.building.footprint = {}; this.houseData.building.footprint.width = val; this.onHouseChanged('building'); }); widthRow.appendChild(widthInput); section.appendChild(widthRow); const depthRow = this._createFieldRow('Depth (m)'); const depthInput = this._createNumberInput(footprint.depth || 10, 4, 30, 0.5, (val) => { if (!this.houseData.building) this.houseData.building = {}; if (!this.houseData.building.footprint) this.houseData.building.footprint = {}; this.houseData.building.footprint.depth = val; this.onHouseChanged('building'); }); depthRow.appendChild(depthInput); section.appendChild(depthRow); this.container.appendChild(section); } // ---- Floor Section ---- _renderFloorSection() { const section = this._createSection('Floors'); const floors = this.houseData.floors || []; for (let i = 0; i < floors.length; i++) { const floor = floors[i]; const row = document.createElement('div'); row.className = 'he-floor-row'; const label = document.createElement('span'); label.className = 'he-floor-label'; label.textContent = `${floor.name} (${floor.rooms.length} rooms)`; row.appendChild(label); // Ceiling height const heightInput = this._createNumberInput(floor.ceilingHeight || 2.5, 2.2, 4.0, 0.1, (val) => { floor.ceilingHeight = val; this._rebuildFloor(); }); heightInput.title = 'Ceiling height'; heightInput.style.width = '55px'; row.appendChild(heightInput); // Remove floor button (only if more than 1 floor) if (floors.length > 1) { const removeBtn = document.createElement('button'); removeBtn.className = 'he-icon-btn he-icon-btn-danger'; removeBtn.textContent = '\u00d7'; removeBtn.title = 'Remove floor'; removeBtn.addEventListener('click', () => this._removeFloor(i)); row.appendChild(removeBtn); } section.appendChild(row); } const addBtn = document.createElement('button'); addBtn.className = 'he-add-btn'; addBtn.textContent = '+ Add Floor'; addBtn.addEventListener('click', () => this._addFloor()); section.appendChild(addBtn); this.container.appendChild(section); } // ---- Room Section ---- _renderRoomSection() { const section = this._createSection('Rooms'); const floor = this.houseData.floors[this.renderer.currentFloor]; if (!floor) return; for (const room of floor.rooms) { const row = document.createElement('div'); row.className = 'he-room-row' + (this._selectedRoomId === room.id ? ' active' : ''); const info = document.createElement('span'); info.className = 'he-room-info'; info.textContent = `${room.name} (${room.dimensions.width}\u00d7${room.dimensions.length}m)`; info.addEventListener('click', () => { this._selectedRoomId = room.id; this.renderer.focusRoom(room.id); this.render(); }); row.appendChild(info); const removeBtn = document.createElement('button'); removeBtn.className = 'he-icon-btn he-icon-btn-danger'; removeBtn.textContent = '\u00d7'; removeBtn.title = 'Remove room'; removeBtn.addEventListener('click', () => this._removeRoom(room.id)); row.appendChild(removeBtn); section.appendChild(row); } const addBtn = document.createElement('button'); addBtn.className = 'he-add-btn'; addBtn.textContent = '+ Add Room'; addBtn.addEventListener('click', () => this._showAddRoomForm(section, addBtn)); section.appendChild(addBtn); this.container.appendChild(section); } // ---- Room Editor ---- _renderRoomEditor() { const floor = this.houseData.floors[this.renderer.currentFloor]; if (!floor) return; const room = floor.rooms.find(r => r.id === this._selectedRoomId); if (!room) return; const section = this._createSection(`Edit: ${room.name}`); // Name const nameRow = this._createFieldRow('Name'); const nameInput = document.createElement('input'); nameInput.className = 'he-input'; nameInput.value = room.name; nameInput.addEventListener('change', () => { room.name = nameInput.value; this._rebuildFloor(); }); nameRow.appendChild(nameInput); section.appendChild(nameRow); // English name const nameEnRow = this._createFieldRow('Name (EN)'); const nameEnInput = document.createElement('input'); nameEnInput.className = 'he-input'; nameEnInput.value = room.nameEN || ''; nameEnInput.addEventListener('change', () => { room.nameEN = nameEnInput.value; this._rebuildFloor(); }); nameEnRow.appendChild(nameEnInput); section.appendChild(nameEnRow); // Type const typeRow = this._createFieldRow('Type'); const typeSelect = document.createElement('select'); typeSelect.className = 'he-input'; const roomTypes = [ 'living', 'kitchen', 'dining', 'bedroom', 'bathroom', 'hallway', 'office', 'utility', 'storage', 'garage' ]; for (const t of roomTypes) { const opt = document.createElement('option'); opt.value = t; opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); if (room.type === t) opt.selected = true; typeSelect.appendChild(opt); } typeSelect.addEventListener('change', () => { room.type = typeSelect.value; }); typeRow.appendChild(typeSelect); section.appendChild(typeRow); // Dimensions const dimsLabel = document.createElement('div'); dimsLabel.className = 'he-field-label'; dimsLabel.textContent = 'Dimensions'; section.appendChild(dimsLabel); const dimsRow = document.createElement('div'); dimsRow.className = 'he-dims-row'; const wLabel = document.createElement('label'); wLabel.className = 'he-dim-label'; wLabel.textContent = 'W'; dimsRow.appendChild(wLabel); const wInput = this._createNumberInput(room.dimensions.width, 1, 15, 0.25, (val) => { room.dimensions.width = val; this._rebuildFloor(); }); dimsRow.appendChild(wInput); const lLabel = document.createElement('label'); lLabel.className = 'he-dim-label'; lLabel.textContent = 'L'; dimsRow.appendChild(lLabel); const lInput = this._createNumberInput(room.dimensions.length, 1, 15, 0.25, (val) => { room.dimensions.length = val; this._rebuildFloor(); }); dimsRow.appendChild(lInput); section.appendChild(dimsRow); // Position const posLabel = document.createElement('div'); posLabel.className = 'he-field-label'; posLabel.textContent = 'Position'; section.appendChild(posLabel); const posRow = document.createElement('div'); posRow.className = 'he-dims-row'; const xLabel = document.createElement('label'); xLabel.className = 'he-dim-label'; xLabel.textContent = 'X'; posRow.appendChild(xLabel); const xInput = this._createNumberInput(room.position.x, 0, 30, 0.25, (val) => { room.position.x = val; this._rebuildFloor(); }); posRow.appendChild(xInput); const yLabel = document.createElement('label'); yLabel.className = 'he-dim-label'; yLabel.textContent = 'Y'; posRow.appendChild(yLabel); const yInput = this._createNumberInput(room.position.y, 0, 30, 0.25, (val) => { room.position.y = val; this._rebuildFloor(); }); posRow.appendChild(yInput); section.appendChild(posRow); // Flooring const flooringRow = this._createFieldRow('Flooring'); const flooringSelect = document.createElement('select'); flooringSelect.className = 'he-input'; for (const f of ['hardwood', 'tile']) { const opt = document.createElement('option'); opt.value = f; opt.textContent = f.charAt(0).toUpperCase() + f.slice(1); if (room.flooring === f) opt.selected = true; flooringSelect.appendChild(opt); } flooringSelect.addEventListener('change', () => { room.flooring = flooringSelect.value; this._rebuildFloor(); }); flooringRow.appendChild(flooringSelect); section.appendChild(flooringRow); this.container.appendChild(section); } // ---- Save Section ---- _renderSaveSection() { const section = this._createSection('Template'); const saveBtn = document.createElement('button'); saveBtn.className = 'he-save-btn'; saveBtn.textContent = 'Save as House Template'; saveBtn.addEventListener('click', () => this._saveAsTemplate()); section.appendChild(saveBtn); const newBtn = document.createElement('button'); newBtn.className = 'he-add-btn'; newBtn.style.marginTop = '6px'; newBtn.textContent = 'New Empty House'; newBtn.addEventListener('click', () => this._createNewHouse()); section.appendChild(newBtn); this.container.appendChild(section); } // ---- Actions ---- _addFloor() { const floors = this.houseData.floors; const level = floors.length; const id = `floor-${level}`; floors.push({ id, name: `Floor ${level}`, nameEN: `Floor ${level}`, level, ceilingHeight: 2.5, rooms: [] }); this.onHouseChanged('floors'); this.render(); } _removeFloor(index) { this.houseData.floors.splice(index, 1); // Re-index levels this.houseData.floors.forEach((f, i) => { f.level = i; }); // If current floor was removed, switch to last available if (this.renderer.currentFloor >= this.houseData.floors.length) { this.renderer.showFloor(this.houseData.floors.length - 1); } else { this._rebuildFloor(); } this.onHouseChanged('floors'); this.render(); } _showAddRoomForm(section, addBtn) { // Replace button with form addBtn.style.display = 'none'; const form = document.createElement('div'); form.className = 'he-add-room-form'; const nameInput = document.createElement('input'); nameInput.className = 'he-input'; nameInput.placeholder = 'Room name'; form.appendChild(nameInput); const typeSelect = document.createElement('select'); typeSelect.className = 'he-input'; const roomTypes = [ 'living', 'kitchen', 'dining', 'bedroom', 'bathroom', 'hallway', 'office', 'utility', 'storage', 'garage' ]; for (const t of roomTypes) { const opt = document.createElement('option'); opt.value = t; opt.textContent = t.charAt(0).toUpperCase() + t.slice(1); typeSelect.appendChild(opt); } form.appendChild(typeSelect); const dimsRow = document.createElement('div'); dimsRow.className = 'he-dims-row'; const wInput = document.createElement('input'); wInput.className = 'he-input'; wInput.type = 'number'; wInput.value = '4'; wInput.min = '1'; wInput.max = '15'; wInput.step = '0.25'; wInput.placeholder = 'Width'; wInput.style.flex = '1'; dimsRow.appendChild(wInput); const xSpan = document.createElement('span'); xSpan.textContent = '\u00d7'; xSpan.style.padding = '0 4px'; xSpan.style.color = '#999'; dimsRow.appendChild(xSpan); const lInput = document.createElement('input'); lInput.className = 'he-input'; lInput.type = 'number'; lInput.value = '3'; lInput.min = '1'; lInput.max = '15'; lInput.step = '0.25'; lInput.placeholder = 'Length'; lInput.style.flex = '1'; dimsRow.appendChild(lInput); form.appendChild(dimsRow); const actions = document.createElement('div'); actions.className = 'he-form-actions'; const cancelBtn = document.createElement('button'); cancelBtn.className = 'he-cancel-btn'; cancelBtn.textContent = 'Cancel'; cancelBtn.addEventListener('click', () => { form.remove(); addBtn.style.display = ''; }); actions.appendChild(cancelBtn); const submitBtn = document.createElement('button'); submitBtn.className = 'he-submit-btn'; submitBtn.textContent = 'Add'; submitBtn.addEventListener('click', () => { const name = nameInput.value.trim(); if (!name) { nameInput.focus(); return; } this._addRoom({ name, type: typeSelect.value, width: parseFloat(wInput.value) || 4, length: parseFloat(lInput.value) || 3 }); }); actions.appendChild(submitBtn); form.appendChild(actions); section.appendChild(form); nameInput.focus(); } _addRoom({ name, type, width, length }) { const floor = this.houseData.floors[this.renderer.currentFloor]; if (!floor) return; // Auto-position: find rightmost edge of existing rooms let maxX = 0; for (const r of floor.rooms) { const edge = r.position.x + r.dimensions.width; if (edge > maxX) maxX = edge; } const id = `${floor.id}-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now().toString(36).slice(-4)}`; const room = { id, name, nameEN: name, type, position: { x: maxX + 0.24, y: 0 }, dimensions: { width, length }, flooring: (type === 'bathroom' || type === 'kitchen' || type === 'utility') ? 'tile' : 'hardwood', walls: { south: { type: 'exterior' }, north: { type: 'exterior' }, west: { type: 'interior' }, east: { type: 'exterior' } } }; floor.rooms.push(room); this._rebuildFloor(); this._selectedRoomId = room.id; this.onHouseChanged('rooms'); this.render(); } _removeRoom(roomId) { const floor = this.houseData.floors[this.renderer.currentFloor]; if (!floor) return; const idx = floor.rooms.findIndex(r => r.id === roomId); if (idx === -1) return; floor.rooms.splice(idx, 1); if (this._selectedRoomId === roomId) { this._selectedRoomId = null; } this._rebuildFloor(); this.onHouseChanged('rooms'); this.render(); } _saveAsTemplate() { const data = structuredClone(this.houseData); data.savedAt = new Date().toISOString(); const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${(data.name || 'house').replace(/\s+/g, '-').toLowerCase()}.json`; a.click(); URL.revokeObjectURL(url); } _createNewHouse() { const newHouse = { name: 'New House', description: '', units: 'meters', building: { footprint: { width: 10, depth: 8 }, wallThickness: 0.24, roofType: 'gable' }, floors: [ { id: 'floor-0', name: 'Ground Floor', nameEN: 'Ground Floor', level: 0, ceilingHeight: 2.6, rooms: [ { id: 'floor-0-hallway', name: 'Hallway', nameEN: 'Hallway', type: 'hallway', position: { x: 3.5, y: 0 }, dimensions: { width: 2.0, length: 8.0 }, flooring: 'tile', walls: { south: { type: 'exterior', doors: [{ id: 'entry', type: 'entry', position: 0.3, width: 1.1, height: 2.2, connectsTo: 'exterior' }] }, north: { type: 'exterior' }, west: { type: 'interior' }, east: { type: 'interior' } } }, { id: 'floor-0-living', name: 'Living Room', nameEN: 'Living Room', type: 'living', position: { x: 0, y: 3.0 }, dimensions: { width: 3.5, length: 5.0 }, flooring: 'hardwood', walls: { south: { type: 'interior' }, north: { type: 'exterior', windows: [{ id: 'lr-w1', type: 'casement', position: 0.5, width: 1.4, height: 1.4, sillHeight: 0.6 }] }, west: { type: 'exterior', windows: [{ id: 'lr-w2', type: 'casement', position: 1.5, width: 1.2, height: 1.4, sillHeight: 0.6 }] }, east: { type: 'interior' } } }, { id: 'floor-0-kitchen', name: 'Kitchen', nameEN: 'Kitchen', type: 'kitchen', position: { x: 0, y: 0 }, dimensions: { width: 3.5, length: 3.0 }, flooring: 'tile', walls: { south: { type: 'exterior', windows: [{ id: 'k-w1', type: 'casement', position: 1.0, width: 1.2, height: 1.2, sillHeight: 0.9 }] }, north: { type: 'interior' }, west: { type: 'exterior' }, east: { type: 'interior' } } } ] } ] }; this.renderer.houseData = newHouse; this.renderer.showFloor(0); this._selectedRoomId = null; this.onHouseChanged('new'); this.render(); } _rebuildFloor() { this.renderer.showFloor(this.renderer.currentFloor); this.onHouseChanged('rebuild'); } // ---- UI Helpers ---- _createSection(title) { const section = document.createElement('div'); section.className = 'he-section'; const h = document.createElement('div'); h.className = 'he-section-title'; h.textContent = title; section.appendChild(h); return section; } _createFieldRow(label) { const row = document.createElement('div'); row.className = 'he-field-row'; const lbl = document.createElement('label'); lbl.className = 'he-field-label'; lbl.textContent = label; row.appendChild(lbl); return row; } _createNumberInput(value, min, max, step, onChange) { const input = document.createElement('input'); input.className = 'he-input he-num-input'; input.type = 'number'; input.value = value; input.min = min; input.max = max; input.step = step; input.addEventListener('change', () => { const val = parseFloat(input.value); if (!isNaN(val) && val >= min && val <= max) { onChange(val); } }); return input; } }