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"), resultSection: document.querySelector(".result"), filtersSection: document.querySelector(".board"), activeFilters: document.getElementById("activeFilters"), vendorBadge: document.getElementById("vendorBadge"), categoryBadge: document.getElementById("categoryBadge"), }; let clickAudioCtx = null; // ── Vendor tooltip (desktop / hover-capable devices only) ── const canHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches; const tooltip = document.createElement('div'); tooltip.className = 'vendor-tooltip'; document.body.appendChild(tooltip); if (canHover) { tooltip.addEventListener('mouseenter', () => { overTooltip = true; clearTimeout(hideTimer); }); tooltip.addEventListener('mouseleave', () => { overTooltip = false; hideTooltip(); }); } let tooltipTimer = null; let hideTimer = null; let overTooltip = false; let vendorMap = {}; function buildVendorMap() { vendorMap = {}; for (const v of state.data.vendors) { vendorMap[v.id] = v; } } function positionTooltip(chipEl) { const rect = chipEl.getBoundingClientRect(); const tw = 280, th = 260; let left = rect.right + 10; let top = rect.top + rect.height / 2 - th / 2; if (left + tw > window.innerWidth - 8) left = rect.left - tw - 10; if (top < 8) top = 8; if (top + th > window.innerHeight - 8) top = window.innerHeight - 8 - th; tooltip.style.left = left + 'px'; tooltip.style.top = top + 'px'; } function showTooltip(chipEl, vendor) { const logo = vendor.logo || ''; const desc = vendor.description || ''; const mont = vendor.mont_page || ''; const site = vendor.website || ''; let logoHtml = ''; if (logo) { logoHtml = ``; } else { logoHtml = ``; } let linksHtml = ''; if (mont) linksHtml += `MONT ↗`; if (site) linksHtml += `Сайт ↗`; tooltip.innerHTML = ` ${logoHtml}
${vendor.name}
${desc ? `
${desc}
` : ''} ${linksHtml ? `` : ''}
`; positionTooltip(chipEl); tooltip.classList.add('visible'); } function hideTooltip(immediate) { clearTimeout(hideTimer); if (immediate) { tooltip.classList.remove('visible'); return; } hideTimer = setTimeout(() => { if (!overTooltip) tooltip.classList.remove('visible'); }, 120); } 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 = ""; let vi = 0; for (const vendor of state.data.vendors) { if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue; const node = document.createElement("button"); node.className = "chip"; node.style.animationDelay = Math.min(vi * 64, 800) + 'ms'; vi++; 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", () => { const wasSelected = state.selectedVendors.has(vendor.id); state.selectedVendors.clear(); if (!wasSelected) state.selectedVendors.add(vendor.id); render(); if (wasSelected) scrollAfterDeselect(); else scrollToResultsSmooth(); }); if (canHover) { node.addEventListener("mouseenter", () => { clearTimeout(tooltipTimer); tooltipTimer = setTimeout(() => showTooltip(node, vendor), 220); }); node.addEventListener("mouseleave", () => { clearTimeout(tooltipTimer); hideTooltip(); }); } el.vendorList.appendChild(node); } el.categoryList.innerHTML = ""; const showOnlyLinkedCategories = state.selectedVendors.size > 0; let ci = 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"; node.style.animationDelay = Math.min(ci * 56, 700) + 'ms'; ci++; 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", () => { const wasSelected = state.selectedCategories.has(category.id); if (wasSelected) state.selectedCategories.delete(category.id); else state.selectedCategories.add(category.id); render(); if (wasSelected) scrollAfterDeselect(); else scrollToResultsSmooth(); }); el.categoryList.appendChild(node); } const ps = visibleSets().allowedProducts.size; el.stats.innerHTML = `Вендоры ${allowedVendors.size} / ${state.data.vendors.length}Категории ${allowedCategories.size} / ${state.data.categories.length}Продукты ${ps} / ${state.data.products.length}`; if (el.vendorBadge) el.vendorBadge.textContent = allowedVendors.size; if (el.categoryBadge) el.categoryBadge.textContent = allowedCategories.size; } function renderResults() { const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets(); const productsById = new Map(state.data.products.map(p => [p.id, p])); const vendorsById = new Map(state.data.vendors.map(v => [v.id, v])); const isIb = state.scope === 'ib'; 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, products }); } el.resultRows.innerHTML = ""; if (rows.length === 0) { el.resultRows.innerHTML = '
По текущим фильтрам ничего не найдено
'; return; } // Vendor info bars for selected vendors if (state.selectedVendors.size > 0) { for (const vid of state.selectedVendors) { const v = vendorsById.get(vid); if (!v) continue; const bar = document.createElement('div'); bar.className = 'vendor-info-bar'; const logoInner = v.logo ? `${v.name}` : `${v.name.slice(0,2).toUpperCase()}`; let linksHtml = ''; if (v.mont_page) linksHtml += `MONT ↗`; if (v.website) linksHtml += `Сайт ↗`; bar.innerHTML = `
${v.name}
${v.description ? `
${v.description}
` : ''} ${linksHtml ? `` : ''}
`; el.resultRows.appendChild(bar); } } for (const row of rows) { const card = document.createElement("article"); card.className = "row-card"; const title = document.createElement("strong"); title.textContent = row.vendor.name; card.appendChild(title); const tags = document.createElement("div"); tags.className = "tags"; for (const product of row.products) { // IB scope: link to vendor's mont page; infra: link to product url let url = ''; if (isIb) { url = row.vendor.mont_page || ''; } else { url = (product.url && String(product.url).trim()) || ''; } const tag = document.createElement(url ? "a" : "span"); tag.className = "tag"; tag.textContent = product.name; if (url) { tag.href = 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(); renderActiveFilters(); renderResults(); } function scrollToResultsSmooth() { if (!el.resultSection) return; const top = Math.max(el.resultSection.getBoundingClientRect().top + window.scrollY - 10, 0); window.scrollTo({ top, behavior: "smooth" }); } function scrollToFiltersSmooth() { if (!el.filtersSection) return; const top = Math.max(el.filtersSection.getBoundingClientRect().top + window.scrollY - 8, 0); window.scrollTo({ top, behavior: "smooth" }); } function scrollToTopSmooth() { window.scrollTo({ top: 0, behavior: "smooth" }); } function scrollAfterDeselect() { if (window.innerWidth > 980) { scrollToTopSmooth(); return; } scrollToFiltersSmooth(); } function renderActiveFilters() { if (!el.activeFilters) return; el.activeFilters.innerHTML = ""; const vendorsById = new Map(state.data.vendors.map(v => [v.id, v.name])); const categoriesById = new Map(state.data.categories.map(c => [c.id, c.name])); const items = []; for (const id of state.selectedVendors) { const name = vendorsById.get(id); if (name) items.push({ kind: "Вендор", name, id, type: "vendor" }); } for (const id of state.selectedCategories) { const name = categoriesById.get(id); if (name) items.push({ kind: "Категория", name, id, type: "category" }); } if (items.length === 0) { el.activeFilters.style.display = "none"; return; } el.activeFilters.style.display = "flex"; for (const item of items) { const node = document.createElement("div"); node.className = "active-filter"; const text = document.createElement("span"); text.innerHTML = `${item.kind}: ${item.name}`; node.appendChild(text); const remove = document.createElement("button"); remove.className = "remove"; remove.type = "button"; remove.setAttribute("aria-label", `Убрать фильтр ${item.name}`); remove.textContent = "×"; remove.addEventListener("click", () => { if (item.type === "vendor") state.selectedVendors.delete(item.id); else state.selectedCategories.delete(item.id); render(); scrollToFiltersSmooth(); }); node.appendChild(remove); el.activeFilters.appendChild(node); } } async function loadScopeData(scope) { const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`); state.data = await res.json(); buildVendorMap(); } 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(); scrollAfterDeselect(); }); el.modeInfra.addEventListener("click", () => { switchScope("infra"); }); el.modeIb.addEventListener("click", () => { switchScope("ib"); }); init();