Files
house-design/src/house-editor.js
m bc94d41f2b Add house customization UI with create/edit houses and room management
Adds HouseEditor panel to the right sidebar with:
- Edit house name and description
- Adjust building footprint dimensions
- Add/remove floors with ceiling height control
- Add/remove rooms with name, type, and dimensions
- Edit selected room: name, type, dimensions, position, flooring
- Save house as JSON template
- Create new empty house from scratch
2026-02-07 16:31:55 +01:00

682 lines
21 KiB
JavaScript

/**
* 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 = '<p style="color:#999;font-size:12px;">No house loaded</p>';
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;
}
}