/* =========================================================================
models.js — live catalog: search + provider filter + sort + render
Loads data/models-catalog.json once, then filters entirely client-side.
Renders incrementally; caps visible cards for snappy scroll on 374 items.
========================================================================= */
(function () {
'use strict';
const GRID = document.getElementById('model-grid');
const RAIL = document.getElementById('filter-rail');
const SEARCH = document.getElementById('model-search');
const COUNT = document.getElementById('model-count');
const SORT = document.getElementById('model-sort');
if (!GRID) return;
let DATA = null; // { total_models, total_targets, providers, models }
let view = []; // current filtered+sorted slice
let state = { q: '', provider: 'all', sort: 'targets' };
const PAGE = 60; // render in pages for scroll perf
let shown = PAGE;
/* ---- load the catalog ---- */
async function load() {
try {
const res = await fetch('data/models-catalog.json', { cache: 'force-cache' });
DATA = await res.json();
} catch (e) {
GRID.innerHTML = '
Could not load the catalog. Try again in a moment.
';
return;
}
buildFilters();
apply();
}
/* ---- build the provider filter rail ---- */
function buildFilters() {
const frag = document.createDocumentFragment();
const all = document.createElement('button');
all.type = 'button';
all.dataset.provider = 'all';
all.setAttribute('aria-pressed', 'true');
all.innerHTML = `All ${DATA.total_models}`;
frag.appendChild(all);
DATA.providers.forEach((p) => {
const b = document.createElement('button');
b.type = 'button';
b.dataset.provider = p.name;
b.setAttribute('aria-pressed', 'false');
b.innerHTML = `${p.name} ${p.count}`;
frag.appendChild(b);
});
RAIL.innerHTML = '';
RAIL.appendChild(frag);
RAIL.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-provider]');
if (!btn) return;
state.provider = btn.dataset.provider;
RAIL.querySelectorAll('button').forEach((b) =>
b.setAttribute('aria-pressed', String(b === btn)));
shown = PAGE;
apply();
});
}
/* ---- filter + sort ---- */
function apply() {
const q = state.q.trim().toLowerCase();
view = DATA.models.filter((m) => {
if (state.provider !== 'all' && m.provider !== state.provider) return false;
if (!q) return true;
return (
m.name.toLowerCase().includes(q) ||
m.provider.toLowerCase().includes(q) ||
m.upstream.some((u) => u.toLowerCase().includes(q))
);
});
if (state.sort === 'targets') view.sort((a, b) => b.targets - a.targets);
else if (state.sort === 'name') view.sort((a, b) => a.name.localeCompare(b.name));
shown = PAGE;
render();
}
/* ---- render the visible page ---- */
function render() {
COUNT.innerHTML = `showing ${view.length} of ${DATA.total_models} models`;
if (!view.length) {
GRID.innerHTML = 'No models match. Try a different search.
';
return;
}
const slice = view.slice(0, shown);
const html = slice.map((m) => `
${escapeHtml(m.name)}
${escapeHtml(m.provider)}
${m.targets.toLocaleString()} targets
${m.stream ? 'stream' : ''}
`).join('');
const more = view.length > shown
? ``
: '';
GRID.innerHTML = html + more;
const moreBtn = GRID.querySelector('.model-more');
if (moreBtn) moreBtn.addEventListener('click', () => { shown += PAGE; render(); });
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
));
}
/* ---- wire controls ---- */
let debounce;
SEARCH.addEventListener('input', (e) => {
clearTimeout(debounce);
debounce = setTimeout(() => { state.q = e.target.value; apply(); }, 120);
});
SORT.addEventListener('change', (e) => { state.sort = e.target.value; apply(); });
load();
})();