Add IKEA furniture catalog with 41 items and tabbed browse UI
- Create data/ikea-catalog.json with 41 curated IKEA items across 23 series (KALLAX, BILLY, MALM, PAX, HEMNES, LACK, etc.) with verified dimensions - Add source tabs (All/Standard/IKEA) to catalog panel for filtering - Add IKEA series filter bar when viewing IKEA items - Add IKEA badge and series label on item cards - Add mergeCatalog() to renderer for loading additional catalog files - Add scripts/import-ikea-hf.js for importing from HuggingFace dataset
This commit is contained in:
185
src/catalog.js
185
src/catalog.js
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* Shows source tabs (All / Standard / IKEA), categories, series filter,
|
||||
* 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 }) {
|
||||
@@ -11,7 +12,9 @@ export class CatalogPanel {
|
||||
this.state = state;
|
||||
this.interaction = interaction;
|
||||
|
||||
this.selectedSource = 'all'; // 'all', 'standard', 'ikea'
|
||||
this.selectedCategory = 'all';
|
||||
this.selectedSeries = 'all';
|
||||
this.searchQuery = '';
|
||||
this.selectedRoomId = null;
|
||||
|
||||
@@ -25,6 +28,11 @@ export class CatalogPanel {
|
||||
this.container.innerHTML = '';
|
||||
this.container.className = 'catalog-panel';
|
||||
|
||||
// Source tabs
|
||||
this._sourceBar = document.createElement('div');
|
||||
this._sourceBar.className = 'catalog-source-tabs';
|
||||
this.container.appendChild(this._sourceBar);
|
||||
|
||||
// Search
|
||||
const searchWrap = document.createElement('div');
|
||||
searchWrap.className = 'catalog-search';
|
||||
@@ -40,6 +48,12 @@ export class CatalogPanel {
|
||||
this._categoryBar.className = 'catalog-categories';
|
||||
this.container.appendChild(this._categoryBar);
|
||||
|
||||
// Series filter (IKEA only, hidden by default)
|
||||
this._seriesBar = document.createElement('div');
|
||||
this._seriesBar.className = 'catalog-series';
|
||||
this._seriesBar.style.display = 'none';
|
||||
this.container.appendChild(this._seriesBar);
|
||||
|
||||
// Items list
|
||||
this._itemList = document.createElement('div');
|
||||
this._itemList.className = 'catalog-items';
|
||||
@@ -58,16 +72,57 @@ export class CatalogPanel {
|
||||
this._createFormContainer.style.display = 'none';
|
||||
this.container.appendChild(this._createFormContainer);
|
||||
|
||||
this._renderSourceTabs();
|
||||
this._renderCategories();
|
||||
this._renderSeriesFilter();
|
||||
this._renderItems();
|
||||
}
|
||||
|
||||
_renderSourceTabs() {
|
||||
this._sourceBar.innerHTML = '';
|
||||
const hasIkea = this._hasIkeaItems();
|
||||
|
||||
const sources = [
|
||||
{ id: 'all', label: 'All' },
|
||||
{ id: 'standard', label: 'Standard' },
|
||||
];
|
||||
if (hasIkea) {
|
||||
sources.push({ id: 'ikea', label: 'IKEA' });
|
||||
}
|
||||
|
||||
for (const src of sources) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'catalog-source-btn' + (src.id === this.selectedSource ? ' active' : '');
|
||||
btn.textContent = src.label;
|
||||
btn.addEventListener('click', () => {
|
||||
this.selectedSource = src.id;
|
||||
this.selectedSeries = 'all';
|
||||
this._renderSourceTabs();
|
||||
this._renderCategories();
|
||||
this._renderSeriesFilter();
|
||||
this._renderItems();
|
||||
});
|
||||
this._sourceBar.appendChild(btn);
|
||||
}
|
||||
|
||||
// Item count badge
|
||||
const count = this._getFilteredItems().length;
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'catalog-count';
|
||||
badge.textContent = count;
|
||||
this._sourceBar.appendChild(badge);
|
||||
}
|
||||
|
||||
_renderCategories() {
|
||||
const catalog = this.renderer.catalogData;
|
||||
if (!catalog) return;
|
||||
|
||||
this._categoryBar.innerHTML = '';
|
||||
const categories = ['all', ...catalog.categories];
|
||||
|
||||
// Get categories from filtered items
|
||||
const items = this._getSourceFilteredItems();
|
||||
const activeCats = new Set(items.map(it => it.category));
|
||||
const categories = ['all', ...catalog.categories.filter(c => activeCats.has(c))];
|
||||
|
||||
const LABELS = {
|
||||
all: 'All',
|
||||
@@ -90,12 +145,101 @@ export class CatalogPanel {
|
||||
btn.addEventListener('click', () => {
|
||||
this.selectedCategory = cat;
|
||||
this._renderCategories();
|
||||
this._renderSeriesFilter();
|
||||
this._renderItems();
|
||||
});
|
||||
this._categoryBar.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_renderSeriesFilter() {
|
||||
// Only show series filter when IKEA source is active
|
||||
if (this.selectedSource !== 'ikea') {
|
||||
this._seriesBar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
const items = this._getSourceFilteredItems();
|
||||
const seriesSet = new Set();
|
||||
for (const it of items) {
|
||||
if (it.ikeaSeries) seriesSet.add(it.ikeaSeries);
|
||||
}
|
||||
|
||||
if (seriesSet.size < 2) {
|
||||
this._seriesBar.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
this._seriesBar.style.display = '';
|
||||
this._seriesBar.innerHTML = '';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'catalog-series-label';
|
||||
label.textContent = 'Series:';
|
||||
this._seriesBar.appendChild(label);
|
||||
|
||||
const seriesList = ['all', ...Array.from(seriesSet).sort()];
|
||||
for (const s of seriesList) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'catalog-series-btn' + (s === this.selectedSeries ? ' active' : '');
|
||||
btn.textContent = s === 'all' ? 'All' : s;
|
||||
btn.addEventListener('click', () => {
|
||||
this.selectedSeries = s;
|
||||
this._renderSeriesFilter();
|
||||
this._renderItems();
|
||||
});
|
||||
this._seriesBar.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
_hasIkeaItems() {
|
||||
const catalog = this.renderer.catalogData;
|
||||
if (!catalog) return false;
|
||||
return catalog.items.some(it => it.id.startsWith('ikea-'));
|
||||
}
|
||||
|
||||
/** Get items filtered by source tab only */
|
||||
_getSourceFilteredItems() {
|
||||
const catalog = this.renderer.catalogData;
|
||||
if (!catalog) return [];
|
||||
|
||||
let items = catalog.items;
|
||||
if (this.selectedSource === 'ikea') {
|
||||
items = items.filter(it => it.id.startsWith('ikea-'));
|
||||
} else if (this.selectedSource === 'standard') {
|
||||
items = items.filter(it => !it.id.startsWith('ikea-'));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Get items with all filters applied */
|
||||
_getFilteredItems() {
|
||||
let items = this._getSourceFilteredItems();
|
||||
|
||||
// Filter by category
|
||||
if (this.selectedCategory !== 'all') {
|
||||
items = items.filter(it => it.category === this.selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by series (IKEA only)
|
||||
if (this.selectedSeries !== 'all') {
|
||||
items = items.filter(it => it.ikeaSeries === this.selectedSeries);
|
||||
}
|
||||
|
||||
// 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) ||
|
||||
(it.ikeaSeries && it.ikeaSeries.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
_renderItems() {
|
||||
const catalog = this.renderer.catalogData;
|
||||
if (!catalog) {
|
||||
@@ -103,22 +247,11 @@ export class CatalogPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let items = catalog.items;
|
||||
const items = this._getFilteredItems();
|
||||
|
||||
// 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)
|
||||
);
|
||||
}
|
||||
// Update count badge
|
||||
const badge = this._sourceBar.querySelector('.catalog-count');
|
||||
if (badge) badge.textContent = items.length;
|
||||
|
||||
this._itemList.innerHTML = '';
|
||||
|
||||
@@ -142,13 +275,18 @@ export class CatalogPanel {
|
||||
const color = item.mesh?.parts?.[0]?.color || '#888';
|
||||
|
||||
const dims = item.dimensions;
|
||||
const dimStr = `${dims.width}×${dims.depth}×${dims.height}m`;
|
||||
const dimStr = `${dims.width}\u00d7${dims.depth}\u00d7${dims.height}m`;
|
||||
|
||||
// Add IKEA badge for IKEA items
|
||||
const isIkea = item.id.startsWith('ikea-');
|
||||
const badge = isIkea ? `<span class="catalog-item-badge">IKEA</span>` : '';
|
||||
const series = isIkea && item.ikeaSeries ? `<span class="catalog-item-series">${item.ikeaSeries}</span>` : '';
|
||||
|
||||
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 class="catalog-item-name">${badge}${item.name}</div>` +
|
||||
`<div class="catalog-item-dims">${dimStr}${series}</div>` +
|
||||
`</div>` +
|
||||
`<button class="catalog-item-add" title="Add to room">+</button>`;
|
||||
|
||||
@@ -397,8 +535,11 @@ export class CatalogPanel {
|
||||
this.selectedRoomId = roomId;
|
||||
}
|
||||
|
||||
/** Refresh the item list (e.g., after floor change). */
|
||||
/** Refresh the full panel (e.g., after catalog merge or floor change). */
|
||||
refresh() {
|
||||
this._renderSourceTabs();
|
||||
this._renderCategories();
|
||||
this._renderSeriesFilter();
|
||||
this._renderItems();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,6 +207,89 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Source tabs */
|
||||
.catalog-source-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 10px 12px 6px;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.catalog-source-btn {
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.catalog-source-btn.active {
|
||||
background: #4a90d9;
|
||||
color: #fff;
|
||||
border-color: #4a90d9;
|
||||
}
|
||||
.catalog-count {
|
||||
margin-left: auto;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
background: #f0f0f0;
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Series filter */
|
||||
.catalog-series {
|
||||
padding: 4px 12px 6px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.catalog-series-label {
|
||||
font-size: 10px;
|
||||
color: #888;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
.catalog-series-btn {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.catalog-series-btn.active {
|
||||
background: #0058a3;
|
||||
color: #fff;
|
||||
border-color: #0058a3;
|
||||
}
|
||||
|
||||
/* IKEA badge */
|
||||
.catalog-item-badge {
|
||||
display: inline-block;
|
||||
font-size: 8px;
|
||||
font-weight: 700;
|
||||
background: #0058a3;
|
||||
color: #ffda1a;
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
margin-right: 4px;
|
||||
vertical-align: middle;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.catalog-item-series {
|
||||
font-size: 9px;
|
||||
color: #0058a3;
|
||||
margin-left: 6px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Custom furniture creator */
|
||||
.catalog-create-btn {
|
||||
display: block;
|
||||
@@ -375,6 +458,10 @@
|
||||
houseRenderer.loadHouse('../data/sample-house.json').then(async (house) => {
|
||||
document.getElementById('house-name').textContent = house.name;
|
||||
await houseRenderer.loadCatalog('../data/furniture-catalog.json');
|
||||
// Merge IKEA catalog items into the main catalog
|
||||
await houseRenderer.mergeCatalog('../data/ikea-catalog.json').catch(e =>
|
||||
console.warn('IKEA catalog not loaded:', e.message)
|
||||
);
|
||||
const design = await houseRenderer.loadDesign('../designs/sample-house-design.json');
|
||||
|
||||
// Initialize state and interaction manager
|
||||
|
||||
@@ -120,6 +120,37 @@ export class HouseRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
async mergeCatalog(url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to load catalog: ${res.status} ${res.statusText}`);
|
||||
const extra = await res.json();
|
||||
if (!this.catalogData) {
|
||||
return this.loadCatalog(url);
|
||||
}
|
||||
// Merge categories
|
||||
for (const cat of extra.categories || []) {
|
||||
if (!this.catalogData.categories.includes(cat)) {
|
||||
this.catalogData.categories.push(cat);
|
||||
}
|
||||
}
|
||||
// Merge items, avoiding duplicates by id
|
||||
for (const item of extra.items || []) {
|
||||
if (!this._catalogIndex.has(item.id)) {
|
||||
this.catalogData.items.push(item);
|
||||
this._catalogIndex.set(item.id, item);
|
||||
}
|
||||
}
|
||||
// Store extra catalog for tabbed access
|
||||
if (!this._extraCatalogs) this._extraCatalogs = [];
|
||||
this._extraCatalogs.push(extra);
|
||||
return extra;
|
||||
} catch (err) {
|
||||
this._emitError('mergeCatalog', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async loadDesign(url) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
|
||||
Reference in New Issue
Block a user