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 { InteractionManager } from './interaction.js';
|
||||||
import { ThemeManager } from './themes.js';
|
import { ThemeManager } from './themes.js';
|
||||||
import { ExportManager } from './export.js';
|
import { ExportManager } from './export.js';
|
||||||
|
import { CatalogPanel } from './catalog.js';
|
||||||
|
|
||||||
const viewer = document.getElementById('viewer');
|
const viewer = document.getElementById('viewer');
|
||||||
const houseRenderer = new HouseRenderer(viewer);
|
const houseRenderer = new HouseRenderer(viewer);
|
||||||
@@ -272,6 +273,7 @@
|
|||||||
let interaction = null;
|
let interaction = null;
|
||||||
let themeManager = null;
|
let themeManager = null;
|
||||||
let exportManager = null;
|
let exportManager = null;
|
||||||
|
let catalogPanel = null;
|
||||||
|
|
||||||
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||||
document.getElementById('house-name').textContent = house.name;
|
document.getElementById('house-name').textContent = house.name;
|
||||||
@@ -294,6 +296,11 @@
|
|||||||
|
|
||||||
themeManager = new ThemeManager(houseRenderer);
|
themeManager = new ThemeManager(houseRenderer);
|
||||||
exportManager = new ExportManager(houseRenderer, designState);
|
exportManager = new ExportManager(houseRenderer, designState);
|
||||||
|
catalogPanel = new CatalogPanel(document.getElementById('catalog-panel'), {
|
||||||
|
renderer: houseRenderer,
|
||||||
|
state: designState,
|
||||||
|
interaction
|
||||||
|
});
|
||||||
buildFloorButtons();
|
buildFloorButtons();
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
buildThemeButtons();
|
buildThemeButtons();
|
||||||
@@ -316,6 +323,7 @@
|
|||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
buildRoomList();
|
buildRoomList();
|
||||||
selectedRoom = null;
|
selectedRoom = null;
|
||||||
|
if (catalogPanel) catalogPanel.setSelectedRoom(null);
|
||||||
});
|
});
|
||||||
container.appendChild(btn);
|
container.appendChild(btn);
|
||||||
}
|
}
|
||||||
@@ -340,6 +348,7 @@
|
|||||||
document.querySelectorAll('.room-item').forEach(el => {
|
document.querySelectorAll('.room-item').forEach(el => {
|
||||||
el.classList.toggle('active', el.dataset.roomId === roomId);
|
el.classList.toggle('active', el.dataset.roomId === roomId);
|
||||||
});
|
});
|
||||||
|
if (catalogPanel) catalogPanel.setSelectedRoom(roomId);
|
||||||
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
const room = houseRenderer.getRooms().find(r => r.id === roomId);
|
||||||
if (room) {
|
if (room) {
|
||||||
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
document.getElementById('info').textContent = `${room.name} (${room.nameEN}) — ${room.area} m²`;
|
||||||
|
|||||||
@@ -349,9 +349,9 @@ export class InteractionManager {
|
|||||||
this._syncMeshFromState(_detail.roomId, _detail.index);
|
this._syncMeshFromState(_detail.roomId, _detail.index);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'furniture-remove') {
|
if (type === 'furniture-add' || type === 'furniture-remove') {
|
||||||
// Mesh was already removed by state; re-render the floor
|
// Re-render the floor to reflect added/removed furniture
|
||||||
this.clearSelection();
|
if (type === 'furniture-remove') this.clearSelection();
|
||||||
this.renderer.designData = this.state.design;
|
this.renderer.designData = this.state.design;
|
||||||
this.renderer._clearFloor();
|
this.renderer._clearFloor();
|
||||||
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
|
const floor = this.renderer.houseData.floors[this.renderer.currentFloor];
|
||||||
|
|||||||
Reference in New Issue
Block a user