65 lines
3.6 KiB
JavaScript
65 lines
3.6 KiB
JavaScript
// app.js — catalog gallery: load parts, functional search over use-cases, class filter, PBR thumbnails.
|
|
import { renderThumbnail } from '/assets/viewer.js';
|
|
|
|
const $ = (s) => document.querySelector(s);
|
|
let PARTS = [], activeClass = null;
|
|
|
|
function haystack(p) {
|
|
return [p.role, p.summary, p.when_to_use, p.usage, p.mpn, p.class, p.subclass, p.package,
|
|
(p.use_cases || []).join(' '), Object.values(p.tags || {}).flat().join(' ')].join(' ').toLowerCase();
|
|
}
|
|
function score(p, terms) {
|
|
const h = haystack(p); let s = 0;
|
|
for (const t of terms) {
|
|
if (!t) continue;
|
|
const uc = (p.use_cases || []).join(' ').toLowerCase();
|
|
if (uc.includes(t)) s += 3; // functional use-case match weighs most
|
|
if ((p.class || '').toLowerCase().includes(t)) s += 2;
|
|
if ((p.mpn || '').toLowerCase().includes(t)) s += 2;
|
|
if (h.includes(t)) s += 1;
|
|
}
|
|
return s;
|
|
}
|
|
function chip(label, val) {
|
|
const c = document.createElement('span'); c.className = 'chip' + (activeClass === val ? ' on' : '');
|
|
c.textContent = label; c.onclick = () => { activeClass = activeClass === val ? null : val; render(); };
|
|
return c;
|
|
}
|
|
function renderChips() {
|
|
const counts = {}; for (const p of PARTS) counts[p.class] = (counts[p.class] || 0) + 1;
|
|
const box = $('#chips'); box.innerHTML = '';
|
|
box.appendChild(chip('All', null));
|
|
Object.entries(counts).sort((a, b) => b[1] - a[1]).forEach(([c, n]) => box.appendChild(chip(`${c} (${n})`, c)));
|
|
}
|
|
function card(p) {
|
|
const el = document.createElement('a'); el.className = 'card reveal'; el.href = `/part.html?id=${p.id}`;
|
|
el.innerHTML = `<div class="thumb"><span class="cls">${p.class}</span>${p.type === 'series' ? '<span class="ser">SERIES</span>' : ''}<canvas></canvas></div>
|
|
<div class="body"><div class="nm">${(p.role || p.summary || '').split('—')[0].slice(0, 46)}</div>
|
|
<div class="mpn">${p.mpn || p.id}</div>
|
|
<div class="sm">${p.when_to_use || p.summary || ''}</div>
|
|
<div class="meta"><span><b>PKG</b> ${p.package || (p.series && p.series.package) || '—'}</span>
|
|
<span><b>3D</b> ✓</span>${p.stock ? `<span><b>STK</b> ${(p.stock / 1000).toFixed(0)}k</span>` : ''}</div></div>`;
|
|
return el;
|
|
}
|
|
async function render() {
|
|
const terms = ($('#q').value || '').toLowerCase().split(/\s+/).filter((t) => t.length > 1);
|
|
let list = PARTS.filter((p) => !activeClass || p.class === activeClass);
|
|
if (terms.length) list = list.map((p) => ({ p, s: score(p, terms) })).filter((x) => x.s > 0).sort((a, b) => b.s - a.s).map((x) => x.p);
|
|
$('#count').textContent = `${list.length} part${list.length === 1 ? '' : 's'}${activeClass ? ' · ' + activeClass : ''}${terms.length ? ' · ranked by relevance' : ''}`;
|
|
const grid = $('#grid'); grid.innerHTML = '';
|
|
if (!list.length) { grid.innerHTML = '<div class="empty">No parts match — try a broader description, or <a href="/submit.html">contribute one</a>.</div>'; return; }
|
|
const cards = list.map((p) => { const el = card(p); grid.appendChild(el); return { el, p }; });
|
|
window.dispatchEvent(new Event('catalog:rendered'));
|
|
// thumbnails one at a time through the shared renderer
|
|
for (const { el, p } of cards) {
|
|
const cv = el.querySelector('canvas');
|
|
const data = await renderThumbnail(p.model_3d, 340);
|
|
if (data) { const img = new Image(); img.src = data; img.style.cssText = 'width:100%;height:100%;object-fit:contain'; cv.replaceWith(img); }
|
|
}
|
|
}
|
|
(async () => {
|
|
PARTS = await (await fetch('/assets/catalog.json')).json();
|
|
$('#cred-n').textContent = localStorage.getItem('ppcb_credits') || '5';
|
|
renderChips(); render();
|
|
let t; $('#q').addEventListener('input', () => { clearTimeout(t); t = setTimeout(render, 160); });
|
|
})();
|