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 = `${vendor.name.slice(0,2).toUpperCase()}
`;
}
let linksHtml = '';
if (mont) linksHtml += `MONT ↗`;
if (site) linksHtml += `Сайт ↗`;
tooltip.innerHTML = `
${logoHtml}
${vendor.name}
${desc ? `
${desc}
` : ''}
${linksHtml ? `
${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 = "";
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", () => {
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;
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", () => {
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.slice(0,2).toUpperCase()}`;
let linksHtml = '';
if (v.mont_page) linksHtml += `MONT ↗`;
if (v.website) linksHtml += `Сайт ↗`;
bar.innerHTML = `
${logoInner}
${v.name}
${v.description ? `
${v.description}
` : ''}
${linksHtml ? `
${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();