Add catalog sidebar panel with categories, search, and click-to-place

This commit is contained in:
m
2026-02-07 12:27:11 +01:00
parent e10abf4cf3
commit 32eaf70635
3 changed files with 245 additions and 3 deletions

233
src/catalog.js Normal file
View File

@@ -0,0 +1,233 @@
/**
* CatalogPanel — left sidebar for browsing furniture catalog.
*
* Shows categories, search, and item cards. Clicking an item
* adds it to the center of the selected room via DesignState.
*/
export class CatalogPanel {
constructor(container, { renderer, state, interaction }) {
this.container = container;
this.renderer = renderer;
this.state = state;
this.interaction = interaction;
this.selectedCategory = 'all';
this.searchQuery = '';
this.selectedRoomId = null;
this._build();
this._bindEvents();
}
// ---- Build DOM ----
_build() {
this.container.innerHTML = '';
this.container.className = 'catalog-panel';
// Search
const searchWrap = document.createElement('div');
searchWrap.className = 'catalog-search';
this._searchInput = document.createElement('input');
this._searchInput.type = 'text';
this._searchInput.placeholder = 'Search furniture...';
this._searchInput.className = 'catalog-search-input';
searchWrap.appendChild(this._searchInput);
this.container.appendChild(searchWrap);
// Categories
this._categoryBar = document.createElement('div');
this._categoryBar.className = 'catalog-categories';
this.container.appendChild(this._categoryBar);
// Items list
this._itemList = document.createElement('div');
this._itemList.className = 'catalog-items';
this.container.appendChild(this._itemList);
this._renderCategories();
this._renderItems();
}
_renderCategories() {
const catalog = this.renderer.catalogData;
if (!catalog) return;
this._categoryBar.innerHTML = '';
const categories = ['all', ...catalog.categories];
const LABELS = {
all: 'All',
seating: 'Seating',
tables: 'Tables',
storage: 'Storage',
beds: 'Beds',
bathroom: 'Bath',
kitchen: 'Kitchen',
office: 'Office',
lighting: 'Lighting',
decor: 'Decor'
};
for (const cat of categories) {
const btn = document.createElement('button');
btn.className = 'catalog-cat-btn' + (cat === this.selectedCategory ? ' active' : '');
btn.textContent = LABELS[cat] || cat;
btn.dataset.category = cat;
btn.addEventListener('click', () => {
this.selectedCategory = cat;
this._renderCategories();
this._renderItems();
});
this._categoryBar.appendChild(btn);
}
}
_renderItems() {
const catalog = this.renderer.catalogData;
if (!catalog) {
this._itemList.innerHTML = '<div class="catalog-empty">No catalog loaded</div>';
return;
}
let items = catalog.items;
// Filter by category
if (this.selectedCategory !== 'all') {
items = items.filter(it => it.category === this.selectedCategory);
}
// Filter by search
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
items = items.filter(it =>
it.name.toLowerCase().includes(q) ||
it.id.toLowerCase().includes(q) ||
it.category.toLowerCase().includes(q)
);
}
this._itemList.innerHTML = '';
if (items.length === 0) {
this._itemList.innerHTML = '<div class="catalog-empty">No items found</div>';
return;
}
for (const item of items) {
const card = this._createItemCard(item);
this._itemList.appendChild(card);
}
}
_createItemCard(item) {
const card = document.createElement('div');
card.className = 'catalog-item';
card.dataset.catalogId = item.id;
// Color swatch from first part
const color = item.mesh?.parts?.[0]?.color || '#888';
const dims = item.dimensions;
const dimStr = `${dims.width}×${dims.depth}×${dims.height}m`;
card.innerHTML =
`<div class="catalog-item-swatch" style="background:${color}"></div>` +
`<div class="catalog-item-info">` +
`<div class="catalog-item-name">${item.name}</div>` +
`<div class="catalog-item-dims">${dimStr}</div>` +
`</div>` +
`<button class="catalog-item-add" title="Add to room">+</button>`;
card.querySelector('.catalog-item-add').addEventListener('click', (e) => {
e.stopPropagation();
this._placeItem(item);
});
card.addEventListener('click', () => {
this._placeItem(item);
});
return card;
}
// ---- Place item ----
_placeItem(catalogItem) {
// Determine target room
const roomId = this._getTargetRoom(catalogItem);
if (!roomId) return;
// Get room center for initial placement
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
const room = floor?.rooms.find(r => r.id === roomId);
if (!room) return;
// Place at room center (local coords)
const cx = room.dimensions.width / 2;
const cz = room.dimensions.length / 2;
const placement = {
catalogId: catalogItem.id,
position: { x: cx, z: cz },
rotation: 0,
wallMounted: false
};
// InteractionManager handles the re-render via furniture-add event
this.state.addFurniture(roomId, placement);
}
_getTargetRoom(catalogItem) {
// Use currently selected room from interaction manager or sidebar
if (this.selectedRoomId) {
return this.selectedRoomId;
}
// If interaction has a room selected (via furniture selection)
if (this.interaction.selectedRoomId) {
return this.interaction.selectedRoomId;
}
// Try to find a matching room on the current floor
const rooms = this.renderer.getRooms();
if (rooms.length === 0) return null;
// If catalog item has room hints, try to match
if (catalogItem.rooms && catalogItem.rooms.length > 0) {
for (const room of rooms) {
// Room ids contain the room type (eg "eg-wohnzimmer")
for (const hint of catalogItem.rooms) {
if (room.id.includes(hint)) return room.id;
}
}
}
// Fallback: first room on the floor
return rooms[0].id;
}
// ---- Events ----
_bindEvents() {
this._searchInput.addEventListener('input', () => {
this.searchQuery = this._searchInput.value.trim();
this._renderItems();
});
// Track room selection from main sidebar
this.renderer.container.addEventListener('roomclick', (e) => {
this.selectedRoomId = e.detail.roomId;
});
}
/** Called externally when a room is selected in the main sidebar. */
setSelectedRoom(roomId) {
this.selectedRoomId = roomId;
}
/** Refresh the item list (e.g., after floor change). */
refresh() {
this._renderItems();
}
}

View File

@@ -263,6 +263,7 @@
import { InteractionManager } from './interaction.js';
import { ThemeManager } from './themes.js';
import { ExportManager } from './export.js';
import { CatalogPanel } from './catalog.js';
const viewer = document.getElementById('viewer');
const houseRenderer = new HouseRenderer(viewer);
@@ -272,6 +273,7 @@
let interaction = null;
let themeManager = null;
let exportManager = null;
let catalogPanel = null;
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
document.getElementById('house-name').textContent = house.name;
@@ -294,6 +296,11 @@
themeManager = new ThemeManager(houseRenderer);
exportManager = new ExportManager(houseRenderer, designState);
catalogPanel = new CatalogPanel(document.getElementById('catalog-panel'), {
renderer: houseRenderer,
state: designState,
interaction
});
buildFloorButtons();
buildRoomList();
buildThemeButtons();
@@ -316,6 +323,7 @@
btn.classList.add('active');
buildRoomList();
selectedRoom = null;
if (catalogPanel) catalogPanel.setSelectedRoom(null);
});
container.appendChild(btn);
}
@@ -340,6 +348,7 @@
document.querySelectorAll('.room-item').forEach(el => {
el.classList.toggle('active', el.dataset.roomId === roomId);
});
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
const room = houseRenderer.getRooms().find(r => r.id === roomId);
if (room) {
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area}`;

View File

@@ -349,9 +349,9 @@ export class InteractionManager {
this._syncMeshFromState(_detail.roomId, _detail.index);
}
if (type === 'furniture-remove') {
// Mesh was already removed by state; re-render the floor
this.clearSelection();
if (type === 'furniture-add' || type === 'furniture-remove') {
// Re-render the floor to reflect added/removed furniture
if (type === 'furniture-remove') this.clearSelection();
this.renderer.designData = this.state.design;
this.renderer._clearFloor();
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];