Add catalog sidebar panel with categories, search, and click-to-place
This commit is contained in:
233
src/catalog.js
Normal file
233
src/catalog.js
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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} m²`;
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user