const state = { data: { vendors: [], categories: [], products: [], product_links: [], links: [] }, scope: "infra", selectedVendors: new Set(), selectedCategories: new Set(), vendorSearch: "", categorySearch: "", }; const el = { vendorList: document.getElementById("vendorList"), categoryList: document.getElementById("categoryList"), vendorSearch: document.getElementById("vendorSearch"), categorySearch: document.getElementById("categorySearch"), resultRows: document.getElementById("resultRows"), stats: document.getElementById("stats"), clearBtn: document.getElementById("clearBtn"), modeInfra: document.getElementById("modeInfra"), modeIb: document.getElementById("modeIb"), }; let clickAudioCtx = null; function playScopeClick() { try { if (!clickAudioCtx) { clickAudioCtx = new (window.AudioContext || window.webkitAudioContext)(); } const now = clickAudioCtx.currentTime; const osc = clickAudioCtx.createOscillator(); const gain = clickAudioCtx.createGain(); osc.type = "triangle"; osc.frequency.setValueAtTime(1800, now); osc.frequency.exponentialRampToValueAtTime(950, now + 0.025); gain.gain.setValueAtTime(0.0001, now); gain.gain.linearRampToValueAtTime(0.14, now + 0.0025); gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.04); osc.connect(gain); gain.connect(clickAudioCtx.destination); osc.start(now); osc.stop(now + 0.045); } catch (_) { // Ignore audio failures silently. } } function normalize(s) { return s.toLowerCase().replace(/ё/g, "е"); } function getMaps() { const productsByVendor = new Map(); const categoriesByProduct = new Map(); const productsByCategory = new Map(); for (const v of state.data.vendors) productsByVendor.set(v.id, new Set()); for (const p of state.data.products) categoriesByProduct.set(p.id, new Set()); for (const c of state.data.categories) productsByCategory.set(c.id, new Set()); for (const p of state.data.products) { if (!productsByVendor.has(p.vendor_id)) productsByVendor.set(p.vendor_id, new Set()); productsByVendor.get(p.vendor_id).add(p.id); } for (const l of state.data.product_links) { if (!categoriesByProduct.has(l.product_id)) categoriesByProduct.set(l.product_id, new Set()); if (!productsByCategory.has(l.category_id)) productsByCategory.set(l.category_id, new Set()); categoriesByProduct.get(l.product_id).add(l.category_id); productsByCategory.get(l.category_id).add(l.product_id); } return { productsByVendor, categoriesByProduct, productsByCategory }; } function visibleSets() { const { productsByVendor, categoriesByProduct, productsByCategory } = getMaps(); const allowedCategories = new Set(state.data.categories.map(c => c.id)); const allowedVendors = new Set(state.data.vendors.map(v => v.id)); const allowedProducts = new Set(state.data.products.map(p => p.id)); if (state.selectedVendors.size > 0) { const fromVendorProducts = new Set(); for (const vId of state.selectedVendors) { for (const pId of (productsByVendor.get(vId) || [])) fromVendorProducts.add(pId); } for (const pId of Array.from(allowedProducts)) { if (!fromVendorProducts.has(pId)) allowedProducts.delete(pId); } } if (state.selectedCategories.size > 0) { const fromCategories = new Set(); for (const cId of state.selectedCategories) { for (const pId of (productsByCategory.get(cId) || [])) fromCategories.add(pId); } for (const pId of Array.from(allowedProducts)) { if (!fromCategories.has(pId)) allowedProducts.delete(pId); } } for (const cId of Array.from(allowedCategories)) { const products = productsByCategory.get(cId) || new Set(); if (![...products].some(pId => allowedProducts.has(pId))) { allowedCategories.delete(cId); } } for (const vId of Array.from(allowedVendors)) { const products = productsByVendor.get(vId) || new Set(); if (![...products].some(pId => allowedProducts.has(pId))) { allowedVendors.delete(vId); } } return { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct }; } function renderChips() { const { allowedCategories, allowedVendors } = visibleSets(); const vendorQ = normalize(state.vendorSearch); const categoryQ = normalize(state.categorySearch); el.vendorList.innerHTML = ""; for (const vendor of state.data.vendors) { if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue; const node = document.createElement("button"); node.className = "chip"; if (state.selectedVendors.has(vendor.id)) node.classList.add("active"); else if (!allowedVendors.has(vendor.id)) node.classList.add("dim"); node.textContent = vendor.name; node.addEventListener("click", () => { if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id); else state.selectedVendors.add(vendor.id); render(); }); el.vendorList.appendChild(node); } el.categoryList.innerHTML = ""; const showOnlyLinkedCategories = state.selectedVendors.size > 0; for (const category of state.data.categories) { if (categoryQ && !normalize(category.name).includes(categoryQ)) continue; if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue; const node = document.createElement("button"); node.className = "chip"; if (state.selectedCategories.has(category.id)) node.classList.add("active"); else if (!allowedCategories.has(category.id)) node.classList.add("dim"); node.textContent = category.name; node.addEventListener("click", () => { if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id); else state.selectedCategories.add(category.id); render(); }); el.categoryList.appendChild(node); } el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`; } function renderResults() { const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets(); const productsById = new Map(state.data.products.map(p => [p.id, p])); const rows = []; for (const vendor of state.data.vendors) { if (!allowedVendors.has(vendor.id)) continue; const productIds = [...(productsByVendor.get(vendor.id) || [])] .filter(pId => allowedProducts.has(pId)) .filter(pId => { if (state.selectedCategories.size === 0) return true; const cats = categoriesByProduct.get(pId) || new Set(); return [...state.selectedCategories].some(cId => cats.has(cId)); }); const products = productIds.map(pId => productsById.get(pId)).filter(Boolean); if (products.length === 0) continue; rows.push({ vendor: vendor.name, products }); } el.resultRows.innerHTML = ""; if (rows.length === 0) { el.resultRows.innerHTML = '
По текущим фильтрам ничего не найдено
'; return; } for (const row of rows) { const card = document.createElement("article"); card.className = "row-card"; const title = document.createElement("strong"); title.textContent = row.vendor; card.appendChild(title); const tags = document.createElement("div"); tags.className = "tags"; for (const product of row.products) { const hasUrl = product.url && String(product.url).trim().length > 0; const tag = document.createElement(hasUrl ? "a" : "span"); tag.className = "tag"; tag.textContent = product.name; if (hasUrl) { tag.href = product.url; tag.target = "_blank"; tag.rel = "noopener noreferrer"; } tags.appendChild(tag); } card.appendChild(tags); el.resultRows.appendChild(card); } } function render() { el.modeInfra.classList.toggle("active", state.scope === "infra"); el.modeIb.classList.toggle("active", state.scope === "ib"); document.body.classList.toggle("scope-ib", state.scope === "ib"); renderChips(); renderResults(); } async function loadScopeData(scope) { const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`); state.data = await res.json(); } async function init() { await loadScopeData(state.scope); render(); } async function switchScope(scope) { if (scope === state.scope) return; playScopeClick(); state.scope = scope; state.selectedVendors.clear(); state.selectedCategories.clear(); state.vendorSearch = ""; state.categorySearch = ""; el.vendorSearch.value = ""; el.categorySearch.value = ""; await loadScopeData(scope); render(); } el.vendorSearch.addEventListener("input", e => { state.vendorSearch = e.target.value || ""; render(); }); el.categorySearch.addEventListener("input", e => { state.categorySearch = e.target.value || ""; render(); }); el.clearBtn.addEventListener("click", () => { state.selectedVendors.clear(); state.selectedCategories.clear(); state.vendorSearch = ""; state.categorySearch = ""; el.vendorSearch.value = ""; el.categorySearch.value = ""; render(); }); el.modeInfra.addEventListener("click", () => { switchScope("infra"); }); el.modeIb.addEventListener("click", () => { switchScope("ib"); }); init();