/
votter
/
NicheAI
Обзор
Документация
Войти
/
votter
/
NicheAI
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
web/js/work.js
1 235 строк
46 KB
Egor
zayavka example + fix metrics
21 июл 2026, 14:16
21 июл 2026, 14:16
1697f55
Код
Авторство
О чём код?
(async function () { const data = await NicheStore.load(); const observerEl = document.querySelector(".observer"); const stateEl = document.getElementById("obs-state"); const nodesWrap = document.getElementById("pipe-nodes"); const fillEl = document.getElementById("pipe-fill"); let logEl = document.getElementById("logbox"); const verWrap = document.getElementById("verdicts"); const actions = document.getElementById("result-actions"); const statBar = document.getElementById("stat-bar"); if (!data) { document.getElementById("work-main").innerHTML = `<div class="empty"> <h2>Нет данных прогона</h2> <p> Файл <span class="mono">web/data/result.json</span> не найден. </p> <p> Запустите <span class="mono">run.py --web</span> </p> <a class="btn ghost" href="index.html"> К загрузке </a> </div>`; return; } //--------------------------------------- // Debug — только по ?debug=1 в адресной строке, никогда по умолчанию для аналитика — // и тумблер, и панель вообще не попадают в DOM без этого параметра. //--------------------------------------- const debugMode = new URLSearchParams(location.search).get("debug") === "1"; if (debugMode) { document.querySelector(".obs-foot").insertAdjacentHTML( "beforeend", `<span class="toggle" id="dbg-toggle"><span class="sw"></span> debug</span>` ); statBar.insertAdjacentHTML( "afterend", `<div class="debug" id="debug"> <h3>Debug · состояние движка</h3> <div class="debug-grid"> <div class="dbox"><div class="k">Адаптеры (записей)</div><ul id="dbg-adapters"></ul></div> <div class="dbox"><div class="k">Задействованные таблицы</div><ul id="dbg-tables"></ul></div> <div class="dbox"><div class="k">Проверяемые метрики</div><ul id="dbg-metrics"></ul></div> <div class="dbox"><div class="k">ИИ-ядро</div><ul id="dbg-ai"></ul></div> </div> <div class="logbox" id="logbox"></div> </div>` ); logEl = document.getElementById("logbox"); renderDebug(data.debug); document.getElementById("dbg-toggle").addEventListener("click", () => { document.getElementById("dbg-toggle").classList.toggle("on"); document.getElementById("debug").classList.toggle("show"); }); } //--------------------------------------- // Заголовок //--------------------------------------- document.getElementById("doc-project").textContent = data.meta.project || "—"; document.getElementById("doc-meta").textContent = `${data.meta.developer || ""} · заявка от ${data.meta.date || "—"}` + (data.meta.source_file ? ` · ${data.meta.source_file}` : ""); if (data.mode === "demo") document.getElementById("demo-badge").style.display = "inline-block"; (function renderHeaderBadges() { const wrap = document.getElementById("header-badges"); if (!wrap) return; const region = data.overview && data.overview.region; const segBadge = region ? `<span class="vstatus neutral">${region}</span>` : `<span class="vstatus neutral">Регион не определён</span>`; const s = data.summary || {}; const total = s.total || 0; const attnShare = total ? (s.needs_attention || 0) / total : 0; let riskSev = "ok", riskLabel = "Низкий"; if (attnShare > 0.4) { riskSev = "attention"; riskLabel = "Высокий"; } else if (attnShare > 0.15) { riskSev = "warn"; riskLabel = "Умеренный"; } const riskBadge = total ? `<span class="vstatus sev-${riskSev}">Риск: ${riskLabel}</span>` : ""; wrap.innerHTML = segBadge + riskBadge; })(); //--------------------------------------- // Сводка — видна сразу, не ждёт анимации наблюдателя //--------------------------------------- const SECTION_META = { attention: { label: "Требует внимания", order: 0 }, questions: { label: "Вопросы инициатору", order: 1 }, estimate: { label: "Оценка", order: 2 }, confirmed_warn: { label: "Подтверждено с оговоркой", order: 3 }, confirmed: { label: "Подтверждено", order: 4 }, }; function sectionOf(v) { return v.ui?.section || "confirmed"; } function countSection(sec) { return data.verdicts.filter(v => sectionOf(v) === sec).length; } function plural(n, one, few, many) { const m10 = n % 10, m100 = n % 100; if (m10 === 1 && m100 !== 11) return one; if ([2, 3, 4].includes(m10) && ![12, 13, 14].includes(m100)) return few; return many; } // Единственная сводка на странице — считается из тех же ui.section, что и заголовки секций // ниже, а не из отдельно посчитанного backend summary — чтобы числа физически не могли разойтись. function summarySentence() { const attn = countSection("attention"); const ques = countSection("questions"); const est = countSection("estimate"); const cwarn = countSection("confirmed_warn"); const conf = countSection("confirmed"); const bits = []; if (attn) bits.push(`${attn} ${plural(attn, "показатель требует", "показателя требуют", "показателей требует")} внимания`); if (ques) bits.push(`${ques} нужно уточнить у инициатора`); if (est) bits.push(`${est} ${plural(est, "оценка получена", "оценки получены", "оценок получено")}`); if (cwarn) bits.push(`${cwarn} ${plural(cwarn, "подтверждён", "подтверждены", "подтверждено")} с оговоркой`); if (conf) bits.push(`${conf} — чисто ${plural(conf, "подтверждён", "подтверждены", "подтверждено")}`); return bits.length ? bits.join(", ") + "." : "Нет данных для сводки."; } renderStatBar(); function renderStatBar() { statBar.innerHTML = ` <div class="stat-summary">${summarySentence()}</div> <label class="select-all"> <input type="checkbox" id="select-all-check"> <span>Добавить все в отчёт</span> </label> `; } //--------------------------------------- // Отметки «в отчёт» — мастер-чекбокс снимает снапшот выбора до включения, // чтобы снятие галочки возвращало то, что было выбрано до этого, а не очищало всё вслепую — // кроме случая, когда до включения уже было выбрано всё вручную — тогда снятие чистит выбор. //--------------------------------------- const allCanonicals = data.verdicts.map(v => v.canonical).filter(Boolean); let preAllSnapshot = null; function syncSelectAllUI() { const master = document.getElementById("select-all-check"); if (!master) return; const sel = NicheStore.reportSelection(); master.checked = allCanonicals.length > 0 && allCanonicals.every(c => sel.has(c)); } function setupSelectAll() { const master = document.getElementById("select-all-check"); if (!master) return; syncSelectAllUI(); master.addEventListener("change", () => { if (master.checked) { preAllSnapshot = NicheStore.reportSelection(); NicheStore.setReportSelection(new Set(allCanonicals)); } else { const wasAll = preAllSnapshot && preAllSnapshot.size === allCanonicals.length; NicheStore.setReportSelection(wasAll ? new Set() : (preAllSnapshot || new Set())); preAllSnapshot = null; } const sel = NicheStore.reportSelection(); verWrap.querySelectorAll(".report-check").forEach(chk => { chk.checked = sel.has(chk.dataset.canon); }); }); } setupSelectAll(); //--------------------------------------- // Вкладки результатов — «Метрики» (существующий вердикт-флоу, не тронут) и // «Обзор» (воронка TAM/SAM/SOM, §17 П2) — оба рендерятся сразу, переключается только display. //--------------------------------------- function setupResultTabs() { const tabs = [...document.querySelectorAll("#result-tabs .tab")]; const panels = { metrics: document.getElementById("panel-metrics"), overview: document.getElementById("panel-overview"), competitors: document.getElementById("panel-competitors"), trends: document.getElementById("panel-trends"), risks: document.getElementById("panel-risks"), regulation: document.getElementById("panel-regulation"), }; tabs.forEach(tab => { tab.addEventListener("click", () => { tabs.forEach(t => t.classList.remove("active")); tab.classList.add("active"); Object.entries(panels).forEach(([key, el]) => { if (el) el.style.display = key === tab.dataset.tab ? "" : "none"; }); }); }); } function fmtMoney(v) { if (v == null) return "—"; const abs = Math.abs(v); if (abs >= 1e12) return `${NicheStore.fmt(Math.round(v / 1e11) / 10)} трлн ₽`; if (abs >= 1e9) return `${NicheStore.fmt(Math.round(v / 1e8) / 10)} млрд ₽`; if (abs >= 1e6) return `${NicheStore.fmt(Math.round(v / 1e5) / 10)} млн ₽`; return `${NicheStore.fmt(Math.round(v))} ₽`; } function renderOverview(ov) { const root = document.getElementById("panel-overview"); if (!root) return; if (!ov) { root.innerHTML = `<div class="empty"> <h2>Недостаточно данных для воронки</h2> <p>По этому региону/сегменту пока не хватает исходных данных (объём строительства региона или средняя цена).</p> </div>`; return; } const tam = ov.tam; const sam = ov.sam || {}; const som = ov.som || {}; const implied = ov.implied_share; const tamRub = tam ? tam.value * 1e12 : null; const samRub = sam.value != null ? sam.value : null; const somRub = som.value != null ? som.value : null; const base = tamRub || samRub || somRub || 1; const barWidth = v => v == null ? "0%" : Math.max((v / base) * 100, 4) + "%"; const kpiHTML = `<div class="kpi-grid" style="grid-template-columns:repeat(3,1fr)"> <div class="kpi-card"><div class="label">TAM</div><div class="value tabular">${tamRub != null ? fmtMoney(tamRub) : "нет данных"}</div></div> <div class="kpi-card"><div class="label">SAM</div><div class="value tabular">${samRub != null ? fmtMoney(samRub) : "нет данных"}</div></div> <div class="kpi-card"><div class="label">SOM</div><div class="value tabular">${somRub != null ? fmtMoney(somRub) : "не заявлено"}</div></div> </div>`; const funnelHTML = `<div class="funnel"> <div class="step"><div class="lab">${tam ? tam.label : "TAM — рынок МКД РФ"}</div> <div class="bar tabular" style="width:${barWidth(tamRub)};background:var(--brand)">${tamRub != null ? fmtMoney(tamRub) : "нет данных"}</div></div> <div class="step"><div class="lab">${sam.label || "SAM — рынок региона"}</div> <div class="bar tabular" style="width:${barWidth(samRub)};background:oklch(0.66 0.13 148)">${samRub != null ? fmtMoney(samRub) : "нет данных"}</div></div> <div class="step"><div class="lab">${som.label || "SOM — заявленная доля"}</div> <div class="bar tabular" style="width:${barWidth(somRub)};background:oklch(0.75 0.1 148)">${somRub != null ? fmtMoney(somRub) : "не заявлено"}</div></div> </div>`; const notes = []; if (tam) notes.push(`<div class="vexp"><b>TAM.</b> ${tam.label}. Источник: ${(tam.provenance && tam.provenance.source) || "—"}.</div>`); if (sam.value != null) { notes.push(`<div class="vexp" style="margin-top:8px"><b>SAM.</b> ${sam.method}</div>`); } else if (sam.reason) { notes.push(`<div class="vexp" style="margin-top:8px;color:var(--est)">⚠ SAM не посчитан: ${sam.reason}</div>`); } if (som.value != null) { notes.push(`<div class="vexp" style="margin-top:8px">${som.caveat}</div>`); } else if (som.reason) { notes.push(`<div class="vexp" style="margin-top:8px;color:var(--text-dim)">SOM: ${som.reason}</div>`); } if (implied) { notes.push(`<div class="vexp" style="margin-top:14px;padding-top:12px;border-top:1px dashed var(--border)"> <b>Сверка.</b> ${implied.label} — <b>${implied.implied_share_pct}%</b>. ${implied.caveat} </div>`); } root.innerHTML = ` ${kpiHTML} <div class="card" style="margin:16px 0 20px"> <div class="section-label">TAM / SAM / SOM${ov.region ? " · " + ov.region : ""}</div> ${funnelHTML} </div> <div class="card">${notes.join("")}</div> `; } function peerListHTML(title, ownLabel, peers) { const rows = (peers || []).map(p => ` <div style="display:flex;justify-content:space-between;font-size:13.5px;padding:5px 0;border-bottom:1px solid var(--border-softer)"> <span>${p.developer}</span> <span class="tabular" style="color:var(--text-dim)">${NicheStore.fmt(p.volume_th_sqm)} тыс. м²</span> </div>`).join(""); return `<div class="fact-card"> <div class="fact-title">${title}</div> <div class="fact-desc" style="margin:4px 0 8px">${ownLabel}</div> ${rows || `<div class="fact-desc">нет данных для сравнения</div>`} </div>`; } function renderCompetitors(landscape) { const root = document.getElementById("panel-competitors"); if (!root) return; if (!landscape) { root.innerHTML = `<div class="empty"> <h2>Нет данных для структуры рынка</h2> <p>Национальный топ-список застройщиков недоступен.</p> </div>`; return; } const own = landscape.own || {}; const share = landscape.region_share || {}; const nat = landscape.national_context; const kpis = [ { label: "Площадь проекта", value: own.sellable_area_th_sqm != null ? `${NicheStore.fmt(own.sellable_area_th_sqm)} тыс. м²` : "не заявлено" }, { label: "Объём рынка региона", value: own.market_volume_th_sqm != null ? `${NicheStore.fmt(own.market_volume_th_sqm)} тыс. м²` : "нет оценки" }, { label: "Доля от объёма региона", value: share.value_pct != null ? `${share.value_pct}%` : "нет данных" }, ]; const kpiHTML = `<div class="kpi-grid" style="grid-template-columns:repeat(3,1fr)"> ${kpis.map(k => `<div class="kpi-card"><div class="label">${k.label}</div><div class="value tabular">${k.value}</div></div>`).join("")} </div>`; const peersHTML = `<div class="cards-grid-2" style="margin-top:16px"> ${peerListHTML("Ближайшие по площади проекта", own.sellable_area_th_sqm != null ? `Проект: ${NicheStore.fmt(own.sellable_area_th_sqm)} тыс. м²` : "площадь проекта не заявлена", landscape.peers_by_area)} ${peerListHTML("Ближайшие по объёму рынка региона", own.market_volume_th_sqm != null ? `Рынок региона: ${NicheStore.fmt(own.market_volume_th_sqm)} тыс. м²` : "объём рынка региона не оценён", landscape.peers_by_market_volume)} </div>`; const notes = []; if (share.value_pct != null) { notes.push(`<div class="fact-desc">Доля проекта от ${share.denominator_label} — <b>${share.value_pct}%</b>.</div>`); } else if (share.reason) { notes.push(`<div class="fact-desc" style="color:var(--text-dim)">Доля от объёма региона не посчитана: ${share.reason}.</div>`); } if (nat) { notes.push(`<div class="fact-desc" style="margin-top:8px">Крупнейший в топ-списке — <b>${nat.top_developer}</b> (${NicheStore.fmt(nat.top_volume_th_sqm)} тыс. м²) — это ${nat.top_share_pct}% национального ввода жилья за ${nat.year} г. (${NicheStore.fmt(nat.national_commissioning_mln_sqm)} млн м²).</div>`); } notes.push(`<div class="fact-desc" style="margin-top:14px;padding-top:12px;border-top:1px dashed var(--border);color:var(--text-dim)"> Ограничение источника: наш.дом.рф топ-${landscape.table_size} — национальный список без разбивки по региону. Подбор похожих застройщиков — только по масштабу (объём стройки), не по региону. </div>`); root.innerHTML = `${kpiHTML}${peersHTML}<div class="card" style="margin-top:16px">${notes.join("")}</div>`; } function factCardHTML(fact) { const badgeSev = fact.severity ? `sev-${fact.severity}` : "neutral"; const badgeText = fact.level_label || fact.direction_label || (fact.delta_pct != null ? (fact.delta_pct >= 0 ? `+${fact.delta_pct}%` : `${fact.delta_pct}%`) : "Факт"); const commentHTML = fact.comment ? `<div class="fact-desc" style="margin-top:8px">${fact.comment} <span class="qsrc gigachat">gigachat</span></div>` : ""; const srcLine = (fact.provenance && fact.provenance.source) ? `<div class="fact-src">Источник: ${fact.provenance.source}</div>` : ""; return `<div class="fact-card"> <div class="fact-top"> <div class="fact-title">${fact.label}</div> <span class="vstatus ${badgeSev}">${badgeText}</span> </div> <div class="fact-desc tabular">${fact.text}</div> ${commentHTML} ${srcLine} </div>`; } function renderTrends(trends) { const root = document.getElementById("panel-trends"); if (!root) return; if (!trends || !trends.length) { root.innerHTML = `<div class="empty"> <h2>Нет данных по трендам</h2> <p>Национальные источники недоступны.</p> </div>`; return; } root.innerHTML = `<div class="cards-grid-2">${trends.map(factCardHTML).join("")}</div>`; } function renderRisks(risks) { const root = document.getElementById("panel-risks"); if (!root) return; const hasVerdictRisks = risks && risks.from_verdicts && risks.from_verdicts.length; const hasMacro = risks && risks.macro && risks.macro.length; if (!hasVerdictRisks && !hasMacro) { root.innerHTML = `<div class="empty"> <h2>Рисков не выявлено</h2> <p>Ни по этой заявке, ни по доступным макро-данным.</p> </div>`; return; } const verdictHTML = hasVerdictRisks ? ` <div class="vsection-head">Из проверки этой заявки <span class="cnt">${risks.from_verdicts.length}</span></div> <div class="cards-grid-2"> ${risks.from_verdicts.map(r => ` <div class="fact-card"> <div class="fact-top"> <div class="fact-title">${r.label}</div> <span class="vstatus sev-attention">${r.badge || "Требует внимания"}</span> </div> <div class="fact-desc">${r.headline || ""}</div> </div>`).join("")} </div> ` : ""; const macroHTML = hasMacro ? ` <div class="vsection-head" style="margin-top:26px">Макро-риски рынка <span class="cnt">${risks.macro.length}</span></div> <div class="cards-grid-2">${risks.macro.map(factCardHTML).join("")}</div> ` : ""; root.innerHTML = `${verdictHTML}${macroHTML}`; } function renderRegulation(reg) { const root = document.getElementById("panel-regulation"); if (!root) return; root.innerHTML = `<div class="empty"> <h2>В разработке</h2> <p>${(reg && reg.note) || "Раздел по регуляторным рискам пока не подключён к данным."}</p> </div>`; } setupResultTabs(); renderOverview(data.overview); renderCompetitors(data.competitors); renderTrends(data.trends); renderRisks(data.risks); renderRegulation(data.regulation); //--------------------------------------- // Observer — при повторном заходе на уже посчитанный результат // анимация не переигрывается заново, а рендерится сразу в готовом виде. //--------------------------------------- const events = data.events || []; const runKey = "nicheai_observer_played_" + (data.meta.timestamp || data.meta.source_file || ""); const alreadyPlayed = sessionStorage.getItem(runKey) === "1"; events.forEach((ev, i) => { const node = document.createElement("div"); node.className = "node"; node.dataset.i = i; node.innerHTML = `<div class="dot">${i + 1}</div> <div class="lab">${ev.label}</div> <div class="det"></div>`; nodesWrap.appendChild(node); }); const nodes = [...nodesWrap.querySelectorAll(".node")]; function buildObserverNav() { const groups = Object.keys(SECTION_META) .map(sec => ({ sec, label: SECTION_META[sec].label, n: countSection(sec) })) .filter(g => g.n > 0); return `<div class="observer-nav">${ groups.map(g => `<a href="#sec-${g.sec}"><span>${g.label}</span><b>${g.n}</b></a>`).join("") }</div>`; } function finishObserver() { observerEl.classList.add("compact"); observerEl.insertAdjacentHTML("beforeend", buildObserverNav()); } if (alreadyPlayed) { // Уже показывали этот прогон в этой вкладке — не пересчитываем заново, // сразу показываем итоговое состояние. nodes.forEach((node, i) => { node.classList.add("done"); const ev = events[i]; if (ev) node.querySelector(".det").textContent = ev.detail || ""; }); fillEl.style.height = "100%"; stateEl.textContent = "готово"; finishObserver(); revealVerdicts(true); } else { let idx = 0; stateEl.textContent = "запуск…"; const delays = { upload: 350, parse: 700, route: 500, lookup: 850, verify: 700, questions: 600, report: 350 }; function log(ev, phase) { if (!logEl) return; const t = new Date().toLocaleTimeString("ru-RU"); const cls = phase === "done" ? "s-done" : "s-run"; const line = document.createElement("div"); line.className = "line"; line.innerHTML = `<span class="t">${t}</span> <span class="${cls}"> [${phase === "done" ? "OK" : "RUN"}] </span> ${ev.label} ${ev.detail ? " · " + ev.detail : ""}`; logEl.appendChild(line); logEl.scrollTop = logEl.scrollHeight; } function step() { if (idx >= nodes.length) { stateEl.textContent = "готово"; fillEl.style.height = "100%"; sessionStorage.setItem(runKey, "1"); finishObserver(); revealVerdicts(false); return; } const node = nodes[idx]; const ev = events[idx]; node.classList.add("running"); node.querySelector(".det").textContent = ev.detail || ""; fillEl.style.height = ((idx + 0.5) / nodes.length * 100) + "%"; stateEl.textContent = ev.label; log(ev, "run"); setTimeout(() => { node.classList.remove("running"); node.classList.add("done"); log(ev, "done"); idx++; step(); }, delays[ev.stage] || 600); } setTimeout(step, 300); } //--------------------------------------- // Карточки //--------------------------------------- function revealVerdicts(instant) { const sorted = [...data.verdicts] .sort((a, b) => (SECTION_META[sectionOf(a)]?.order ?? 9) - (SECTION_META[sectionOf(b)]?.order ?? 9)); let lastSection = null; let i = 0; sorted.forEach((v) => { const sec = sectionOf(v); if (sec !== lastSection) { const head = document.createElement("div"); head.className = "vsection-head"; head.id = "sec-" + sec; head.dataset.section = sec; const meta = SECTION_META[sec] || { label: sec }; head.innerHTML = `${meta.label} <span class="cnt">${countSection(sec)}</span>`; verWrap.appendChild(head); lastSection = sec; } const card = buildCard(v); card.dataset.section = sec; verWrap.appendChild(card); if (instant) { card.classList.add("in"); } else { setTimeout( () => card.classList.add("in"), 100 + i * 120 ); } i++; }); if (instant) { actions.classList.add("show"); const headerBtn = document.getElementById("header-report-btn"); if (headerBtn) headerBtn.style.display = "inline-block"; } else { setTimeout( () => { actions.classList.add("show"); const headerBtn = document.getElementById("header-report-btn"); if (headerBtn) headerBtn.style.display = "inline-block"; }, sorted.length * 120 + 200 ); } } function buildCard(v) { const el = document.createElement("div"); el.className = "vcard sev-" + (v.ui?.severity || "ok"); //-------------------------------------------------- let nums = ""; if (v.status === "оценка" && v.ui?.kind === "distribution") { nums = bucketBarHTML(v); } else if (v.status === "оценка" && v.estimate) { nums = corridorHTML(v); } else if (v.derived) { nums = `<div class="vnums"> заявлено <b>${NicheStore.fmt(v.claimed.value)}</b> ${v.claimed.unit || ""} <span class="arrow">→</span> факт <b>${NicheStore.fmt(v.derived.value)}</b> ${v.derived.unit || ""} ${ v.deviation != null ? `<span style="color:var(--text-dim)"> (откл. ${(v.deviation * 100).toFixed(1)}%) </span>` : "" } </div>`; } else if (v.found_reference) { nums = `<div class="vnums"> заявлено <b>${NicheStore.fmt(v.claimed.value)}</b> ${v.claimed.unit || ""} <span class="arrow">·</span> <span style="color:var(--text-dim)"> для справки: ${NicheStore.fmt(v.found_reference.value)} ${v.found_reference.unit || ""} </span> </div>`; } else if (v.found) { nums = `<div class="vnums"> заявлено <b>${NicheStore.fmt(v.claimed.value)}</b> ${v.claimed.unit || ""} <span class="arrow">→</span> расчёт <b>${NicheStore.fmt(v.found.value)}</b> ${v.found.unit || ""} ${ v.deviation != null ? `<span style="color:var(--text-dim)"> (откл. ${(v.deviation * 100).toFixed(1)}%) </span>` : "" } </div>`; } else { nums = `<div class="vnums"> заявлено <b>${NicheStore.fmt(v.claimed.value)}</b> ${v.claimed.unit || ""} </div>`; } //-------------------------------------------------- // Вопрос от ИИ-ядра (или шаблонный фолбэк) — с пометкой источника, // чтобы было видно, что реально сформулировал GigaChat, а что шаблон. //-------------------------------------------------- const srcBadge = v.question && v.question_source ? `<span class="qsrc ${v.question_source === "gigachat" ? "gigachat" : "template"}">${ v.question_source === "gigachat" ? "GigaChat" : "шаблон" }</span>` : ""; let explanation = v.question || v.reason || ""; const headline = v.ui?.headline ? `<div class="vheadline">${v.ui.headline}</div>` : ""; const method = v.method ? `<div class="vexp" style="margin-top:6px"> <b>Как рассчитано:</b> ${v.method} </div>` : ""; const caveats = Array.isArray(v.caveats) && v.caveats.length ? `<div class="vexp" style="margin-top:6px;color:var(--est)"> ⚠ ${v.caveats.join("; ")} </div>` : ""; const context = Array.isArray(v.context) && v.context.length && v.ui?.kind !== "distribution" ? `<div class="vexp" style="margin-top:6px;color:var(--text-dim)"> для контекста: ${ v.context .map(c => `${c.label} — ${NicheStore.fmt(c.value)}${c.unit ? " " + c.unit : ""}` ) .join(" · ") } </div>` : ""; const cohort = v.cohort_reference ? `<div class="vexp" style="margin-top:6px;color:var(--text-dim)"> по когорте домов с плановым вводом в ${v.cohort_reference.planned_year} г.: типичная распроданность ${NicheStore.fmt(v.cohort_reference.typical_share_pct)}% </div>` : ""; const methods = Array.isArray(v.methods) && v.methods.length > 1 ? `<div class="vexp" style="margin-top:6px"> <b>Методы (${v.methods.length}):</b> <ul style="margin:4px 0 0 16px;padding:0"> ${ v.methods .map(m => `<li>${m.id}: ${NicheStore.fmt(m.central)} ${m.unit}` + (m.low != null ? ` [${NicheStore.fmt(m.low)}–${NicheStore.fmt(m.high)}]` : "") + `</li>` ) .join("") } </ul> ${ v.triangulation && v.triangulation.agreement ? `<div style="margin-top:4px;color:${v.triangulation.agreement === 'расходятся' ? 'var(--bad)' : 'var(--ok)'}"> ${v.triangulation.agreement === 'расходятся' ? '⚠' : '✓'} методы ${v.triangulation.agreement} ${v.triangulation.spread_pct != null ? `(расхождение ${v.triangulation.spread_pct}%)` : ""} ${v.triangulation.independent === false ? ' — источники НЕ независимы' : ""} </div>` : "" } </div>` : ""; const prov = provHTML(v.provenance); const confidence = confidenceHTML(v); const detailsHTML = ` ${ explanation ? `<div class="vexp">${explanation}${srcBadge}</div>` : "" } ${method} ${methods} ${caveats} ${context} ${cohort} ${prov} ${confidence} `; el.innerHTML = ` <div class="vtop"> <div class="vname">${v.indicator}</div> <div class="vtop-right"> <label class="vcheck" title="добавить эту метрику в отчёт"> <input type="checkbox" class="report-check" data-canon="${v.canonical || ""}"> <span>в отчёт</span> </label> <div class="vstatus sev-${v.ui?.severity || "ok"}"> ${v.ui?.badge_label || NicheStore.LABELS[v.status] || v.status} </div> </div> </div> ${headline} ${nums} <div class="vexpand"><span class="chev">▸</span> подробнее: методы, оговорки, источник</div> <div class="vdetails">${detailsHTML}</div> `; el.querySelector(".vexpand").addEventListener("click", () => { el.classList.toggle("open"); }); const check = el.querySelector(".report-check"); if (check && v.canonical) { const sel = NicheStore.reportSelection(); check.checked = sel.has(v.canonical); check.addEventListener("change", () => { NicheStore.toggleReportSelection(v.canonical, check.checked); syncSelectAllUI(); }); } return el; } //-------------------------------------------------------- // Позиция в рыночном распределении — другой механизм, не шкала диапазона триангуляции: // сегментированная полоса из бакетов рынка с подсвеченным тем, куда попало заявленное значение. //-------------------------------------------------------- function bucketBarHTML(v) { const buckets = Array.isArray(v.context) ? v.context : []; const activeLabel = v.estimate?.bucket; const total = buckets.reduce((s, b) => s + (b.value || 0), 0) || 100; const segs = buckets.map(b => { const isActive = b.label === activeLabel; const width = Math.max(((b.value || 0) / total) * 100, 4); return `<div class="bucket-seg${isActive ? " active" : ""}" style="width:${width}%" title="${b.label}: ${NicheStore.fmt(b.value)}%"></div>`; }).join(""); const activeBucket = buckets.find(b => b.label === activeLabel); return ` <div class="vnums"> заявлено <b>${NicheStore.fmt(v.claimed.value)}</b> ${v.claimed.unit || ""} · позиция: <b>${activeLabel || "—"}</b> </div> <div class="bucket-bar">${segs}</div> <div class="mono" style="font-size:11px;color:var(--text-dim)"> ${activeBucket ? `в этой категории — ${NicheStore.fmt(activeBucket.value)}% рынка` : ""} ${v.position ? ` · ${v.position}` : ""} </div> `; } //-------------------------------------------------------- // Коридор оценки //-------------------------------------------------------- function corridorHTML(v) { const e = v.estimate; const claimed = v.claimed.value; const isPoint = v.ui?.point_estimate || (e.low != null && e.high != null && e.low === e.high); if (isPoint) { return ` <div class="vnums"> заявлено <b>${NicheStore.fmt(claimed)}</b> ${v.claimed.unit || ""} · положение: <b>${v.position}</b> </div> <div class="vexp" style="color:var(--text-dim)"> точечная оценка, один метод, не перепроверено — ${NicheStore.fmt(e.central)} ${e.unit} </div> `; } const lo = e.low; const hi = e.high; const pad = (hi - lo) * 0.4; const min = Math.min(lo, claimed) - pad; const max = Math.max(hi, claimed) + pad; const pos = value => ((value - min) / (max - min) * 100); const claimPos = pos(claimed); const labClass = claimPos < 8 ? "lab-left" : claimPos > 92 ? "lab-right" : ""; return ` <div class="vnums"> заявлено <b>${NicheStore.fmt(claimed)}</b> ${v.claimed.unit || ""} · положение: <b>${v.position}</b> </div> <div class="corridor"> <div class="track"></div> <div class="band" style=" left:${pos(lo)}%; width:${pos(hi)-pos(lo)}%; "> </div> <div class="claim ${labClass}" data-lab="${NicheStore.fmt(claimed)}" style="left:${claimPos}%"> </div> </div> <div class="mono" style="font-size:11px;color:var(--text-dim)"> диапазон ${NicheStore.fmt(lo)} – ${NicheStore.fmt(hi)} ${e.unit} · центр ${NicheStore.fmt(e.central)} </div> `; } //-------------------------------------------------------- // Уверенность системы в собственном выводе — только в развёрнутой части, чтобы не конкурировать // с бейджем severity за внимание, и всегда с разбивкой по факторам — голый процент без объяснения // был бы ещё одним чёрным ящиком ии-числа в интерфейсе, который сам себя позиционирует как объяснимый. //-------------------------------------------------------- function confidenceHTML(v) { const conf = v.ui?.confidence; if (!conf) return ""; const pct = Math.round(conf.score * 100); const factors = Array.isArray(conf.factors) ? conf.factors : []; const body = factors.length ? `<ul>${ factors .map(f => `<li>${f.label}<span>${Math.round(f.delta * 100)}%</span></li>`) .join("") }</ul>` : `<div class="vconf-clean">прямое измерение, без оговорок</div>`; return ` <div class="vconf"> <b>Уверенность системы в этом выводе: ${pct}%</b> ${body} </div> `; } //-------------------------------------------------------- // Provenance //-------------------------------------------------------- function provHTML(p) { if (!p || !p.source) return ""; const parts = []; parts.push(p.source); if (p.period) parts.push(p.period); else if (p.year) parts.push(p.year); else if (p.base_year) parts.push("база " + p.base_year); else if (p.collected_at) parts.push(p.collected_at); let html = `<div class="vprov"> источник: ${parts.join(" · ")}`; if (p.url) { html += ` · <a href="${p.url}" target="_blank" rel="noopener"> открыть </a>`; } html += "</div>"; return html; } //-------------------------------------------------------- // Debug //-------------------------------------------------------- function renderDebug(d) { if (!d) return; const adapters = (d.adapters || []) .map(a => `<li> ${a.id} <span class="pill on"> ${a.records || "OK"} </span> </li>` ) .join(""); const tables = (d.tables || []) .map(t => `<li>${t}</li>`) .join(""); const metrics = (d.routable_metrics || []) .map(m => `<li>${m}</li>`) .join(""); document.getElementById("dbg-adapters").innerHTML = adapters; document.getElementById("dbg-tables").innerHTML = tables; document.getElementById("dbg-metrics").innerHTML = metrics; const ai = d.ai_core || {}; const aiOn = ai.status === "success"; const note = ai.note || ""; document.getElementById("dbg-ai").innerHTML = `<li> режим <span class="pill ${aiOn ? "on" : "off"}"> ${ai.status || "off"} </span> </li> ${ ai.model ? `<li>${ai.model}</li>` : "" } ${ note ? `<li style="color:var(--text-dim)"> ${note} </li>` : "" }`; } //-------------------------------------------------------- // Debug panel //-------------------------------------------------------- })();