Files

490 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = `<div class="vtt-logo"><img src="/static/${logo}" alt="${vendor.name}" onerror="this.parentElement.innerHTML='<span class=vtt-logo-placeholder>${vendor.name.slice(0,2).toUpperCase()}</span>'"/></div>`;
} else {
logoHtml = `<div class="vtt-logo"><span class="vtt-logo-placeholder">${vendor.name.slice(0,2).toUpperCase()}</span></div>`;
}
let linksHtml = '';
if (mont) linksHtml += `<a class="vtt-link mont" href="${mont}" target="_blank" rel="noopener">MONT ↗</a>`;
if (site) linksHtml += `<a class="vtt-link site" href="${site}" target="_blank" rel="noopener">Сайт ↗</a>`;
tooltip.innerHTML = `
${logoHtml}
<div class="vtt-body">
<div class="vtt-name">${vendor.name}</div>
${desc ? `<div class="vtt-desc">${desc}</div>` : ''}
${linksHtml ? `<div class="vtt-links">${linksHtml}</div>` : ''}
</div>`;
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";
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";
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 = `<span class="stat-pill">Вендоры <b>${allowedVendors.size}</b>&thinsp;/&thinsp;${state.data.vendors.length}</span><span class="stat-pill">Категории <b>${allowedCategories.size}</b>&thinsp;/&thinsp;${state.data.categories.length}</span><span class="stat-pill">Продукты <b>${ps}</b>&thinsp;/&thinsp;${state.data.products.length}</span>`;
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 = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
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
? `<img src="/static/${v.logo}" alt="${v.name}" onerror="this.parentElement.innerHTML='<span class=vib-logo-text>${v.name.slice(0,2).toUpperCase()}</span>'">`
: `<span class="vib-logo-text">${v.name.slice(0,2).toUpperCase()}</span>`;
let linksHtml = '';
if (v.slug) linksHtml += `<a class="vib-link mont" href="/vendor/${v.slug}">Подробнее →</a>`;
if (v.mont_page) linksHtml += `<a class="vib-link site" href="${v.mont_page}" target="_blank" rel="noopener">MONT ↗</a>`;
if (v.website) linksHtml += `<a class="vib-link site" href="${v.website}" target="_blank" rel="noopener">Сайт ↗</a>`;
bar.innerHTML = `
<div class="vib-logo">${logoInner}</div>
<div class="vib-info">
<div class="vib-name">${v.name}</div>
${v.description ? `<div class="vib-desc">${v.description}</div>` : ''}
${linksHtml ? `<div class="vib-links">${linksHtml}</div>` : ''}
</div>`;
el.resultRows.appendChild(bar);
}
}
for (const row of rows) {
const card = document.createElement("article");
card.className = "row-card";
const title = document.createElement("strong");
if (row.vendor.slug) {
const a = document.createElement("a");
a.href = `/vendor/${row.vendor.slug}`;
a.textContent = row.vendor.name;
a.style.cssText = "color:inherit;text-decoration:none;";
a.addEventListener("mouseenter", () => a.style.textDecoration = "underline");
a.addEventListener("mouseleave", () => a.style.textDecoration = "none");
title.appendChild(a);
} else {
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 = `<span class="kind">${item.kind}:</span> ${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();