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:
m
2026-02-07 12:58:52 +01:00
parent cf0fe586eb
commit ceea42ac1d
5 changed files with 1411 additions and 22 deletions

View File

@@ -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();
}
}

View File

@@ -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

View File

@@ -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);