355 lines
12 KiB
JavaScript
355 lines
12 KiB
JavaScript
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"),
|
||
};
|
||
|
||
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", () => {
|
||
const wasSelected = state.selectedVendors.has(vendor.id);
|
||
if (wasSelected) state.selectedVendors.delete(vendor.id);
|
||
else state.selectedVendors.add(vendor.id);
|
||
render();
|
||
if (wasSelected) scrollAfterDeselect();
|
||
else scrollToResultsSmooth();
|
||
});
|
||
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);
|
||
}
|
||
|
||
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 = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
|
||
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();
|
||
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();
|
||
}
|
||
|
||
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();
|