bharatgraph / frontend /js /app.js
abinazebinoy's picture
fix(app.js): correct bad escape sequence in search filter button
353faba
Raw
History Blame Contribute Delete
50.7 kB
function sanitize(str) {
const d = document.createElement("div");
d.textContent = String(str || "");
return d.innerHTML;
}
const State = {
theme: localStorage.getItem("bg-theme") || "dark",
language: localStorage.getItem("bg-lang") || "en", // LOGIC-4 FIX: persist language
searchResults: [],
currentEntity: null,
feedSocket: null,
apiConnected: false,
};
const Views = {
home: () => {
const main = document.getElementById("app-main");
main.innerHTML = `
<section style="padding:var(--space-20) 0 var(--space-12);">
<div class="container container--narrow" style="text-align:center">
<div style="font-size:11px;font-weight:600;letter-spacing:.15em;
text-transform:uppercase;color:var(--accent-primary);
margin-bottom:var(--space-4)">
Sach Ko Prakash Mein Laana
</div>
<h1 style="font-size:clamp(2rem,5vw,3.5rem);font-weight:700;
color:var(--text-primary);margin-bottom:var(--space-4);
line-height:1.15">
Public Transparency<br>
<span style="color:var(--color-saffron)">Intelligence Platform</span>
</h1>
<p style="font-size:var(--font-size-lg);color:var(--text-secondary);
max-width:580px;margin:0 auto var(--space-10);
line-height:var(--line-height-relaxed)">
Every claim backed by official records. Every relationship traceable
to its source. Investigative intelligence for journalists, researchers,
and citizens.
</p>
<div style="max-width:640px;margin:0 auto var(--space-4)">
<div class="search-bar" id="home-search-bar">
<input class="search-bar__input" id="home-search-input"
placeholder="Search any politician, company, scheme, or ministry"
autocomplete="off" spellcheck="false">
<button class="search-bar__btn" id="home-search-btn">Search</button>
</div>
</div>
<div style="display:flex;align-items:center;justify-content:center;
gap:var(--space-4);flex-wrap:wrap;font-size:var(--font-size-xs);
color:var(--text-muted)">
<span>21 official data sources</span>
<span>|</span>
<span>15 parallel investigators</span>
<span>|</span>
<span>22 Indian languages</span>
</div>
</div>
</section>
<section style="padding:var(--space-12) 0;background:var(--bg-secondary);
border-top:1px solid var(--border-color);">
<div class="container">
<div class="grid grid--4" id="stats-grid">
${[1,2,3,4].map(() => `
<div class="skeleton" style="height:100px;border-radius:12px"></div>
`).join("")}
</div>
</div>
</section>
<section style="padding:var(--space-12) 0">
<div class="container">
<div style="display:grid;grid-template-columns:1fr 380px;gap:var(--space-8)">
<div>
<h2 class="section-title">How It Works</h2>
<p class="section-subtitle">
Enter any entity. The platform runs 15 specialist investigators
in parallel, each analysing from a different angle.
Findings are synthesised -- where three or more investigators
agree, confidence is marked as high.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${[
["01", "Search", "Enter any politician, company, ministry, or scheme name in any Indian language."],
["02", "Investigate", "15 specialist AI investigators run in parallel across 21 official data sources."],
["03", "Synthesise", "Findings are cross-validated. Agreed patterns are marked with high confidence."],
["04", "Export", "Download a tamper-evident PDF dossier with a SHA-256 integrity hash. (Not a legal document -- for research use only.)"],
].map(([num, title, desc]) => `
<div style="display:flex;gap:var(--space-4);padding:var(--space-5);
background:var(--bg-card);border:1px solid var(--border-color);
border-radius:var(--radius-lg)">
<div style="width:36px;height:36px;border-radius:50%;
background:rgba(255,153,51,0.15);
display:flex;align-items:center;justify-content:center;
font-size:var(--font-size-xs);font-weight:700;
color:var(--color-saffron);flex-shrink:0">${num}</div>
<div>
<div style="font-weight:600;margin-bottom:var(--space-1)">${title}</div>
<div style="font-size:var(--font-size-sm);color:var(--text-secondary)">${desc}</div>
</div>
</div>
`).join("")}
</div>
</div>
<div>
<div style="display:flex;align-items:center;justify-content:space-between;
margin-bottom:var(--space-4)">
<h2 style="font-size:var(--font-size-xl);font-weight:600">Live Feed</h2>
<span class="feed-item__indicator feed-item__indicator--MODERATE"
style="animation:pulse 1s infinite"></span>
</div>
<div id="home-feed" style="border:1px solid var(--border-color);
border-radius:var(--radius-lg);
overflow:hidden;background:var(--bg-card)">
<div style="padding:40px;text-align:center;color:var(--text-muted);
font-size:var(--font-size-sm)">
Connecting to live feed...
</div>
</div>
<div style="margin-top:var(--space-3);text-align:center">
<a href="#/live-feed" data-route="/live-feed"
onclick="Router.navigate('/live-feed');return false;"
style="font-size:var(--font-size-sm);color:var(--accent-primary)">
View all intelligence
</a>
</div>
</div>
</div>
</div>
</section>
`;
document.getElementById("home-search-btn").addEventListener("click", () => {
const q = document.getElementById("home-search-input").value.trim();
const _l=State.language&&State.language!=='en'?'&lang='+State.language:'';
if (q) Router.navigate(`/search?q=${encodeURIComponent(q)}${_l}`);
});
document.getElementById("home-search-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const q = e.target.value.trim();
const _l=State.language&&State.language!=='en'?'&lang='+State.language:'';
if (q) Router.navigate(`/search?q=${encodeURIComponent(q)}${_l}`);
}
});
Api.stats().then(data => {
const grid = document.getElementById("stats-grid");
if (!grid) return;
const nodes = data.nodes || {};
const stats = [
[Object.values(nodes).reduce((a,b)=>a+b,0).toLocaleString("en-IN"), "Total Entities"],
[(nodes.Politician || 0).toLocaleString("en-IN"), "Politicians Tracked"],
[(nodes.Company || 0).toLocaleString("en-IN"), "Companies Mapped"],
[(nodes.Contract || 0).toLocaleString("en-IN"), "Contracts Indexed"],
];
grid.innerHTML = stats
.map(([v,l]) => `
<div class="stat-card">
<div class="stat-card__value">${v || "--"}</div>
<div class="stat-card__label">${l}</div>
</div>
`).join("");
}).catch(() => {
const grid = document.getElementById("stats-grid");
if (grid) grid.innerHTML = `
<div style="grid-column:1/-1;text-align:center;color:var(--text-muted);
font-size:var(--font-size-sm);padding:var(--space-8)">
Live data unavailable -- backend may be starting up.
</div>
`;
});
Views._connectFeedToHome();
},
_connectFeedToHome: () => {
// FEED-2 FIX: reset retry counter each time _connectFeedToHome is called
// so navigating Home -> elsewhere -> Home restarts the 20-retry budget
if (typeof connect !== 'undefined') connect._retries = 0;
function connect(delay) {
delay = delay || 2000;
let ws;
try {
ws = Api.createFeedSocket();
} catch (_) { return; }
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
const container = document.getElementById("home-feed");
if (!container) { ws.close(); return; }
container.innerHTML = "";
// Show real feed items when available, fall back to heartbeat message
const items = data.items || [];
if (items.length > 0) {
items.slice(0, 3).forEach(item => {
container.appendChild(Components.FeedItem({
headline: `[${item.label || "Entity"}] ${item.name || "--"}`,
risk_level: "MODERATE",
detected_at: item.scraped_at || data.at,
source: item.source || "BharatGraph",
}));
});
} else if (data.message && data.message.includes("pipeline")) {
// BUG-12 FIX: "run /admin/pipeline" is a backend message that
// was never shown to users -- show a friendly callout instead
if (!document.getElementById("feed-empty-callout")) {
const callout = document.createElement("div");
callout.id = "feed-empty-callout";
callout.style.cssText = "padding:16px;background:rgba(255,153,51,0.08);border:1px solid rgba(255,153,51,0.25);border-radius:8px;font-size:12px;color:var(--color-saffron);text-align:center;margin:8px";
callout.textContent = "Database is being populated. Intelligence data will appear here after the pipeline completes.";
container.appendChild(callout);
}
} else {
container.appendChild(Components.FeedItem({
headline: data.message || "New intelligence available",
risk_level: "MODERATE",
detected_at: data.at,
source: "BharatGraph Feed",
}));
}
} catch (_) {}
};
ws.onerror = () => {};
// BUG-6 FIX + M-07 FIX: reconnect with max retry cap
ws.onclose = () => {
const container = document.getElementById("home-feed");
if (!container) return; // user navigated away -- stop retrying
if ((connect._retries || 0) >= 20) {
// M-07 FIX: max 20 retries (~10 min at 30s ceiling) -- stop after that
const msg = document.createElement("div");
msg.style.cssText = "padding:12px;text-align:center;font-size:12px;color:var(--text-muted)";
msg.textContent = "Live feed unavailable. Refresh the page to reconnect.";
container.appendChild(msg);
return;
}
connect._retries = (connect._retries || 0) + 1;
const nextDelay = Math.min(delay * 2, 30000);
setTimeout(() => connect(nextDelay), nextDelay);
};
}
connect(2000);
},
search: () => {
const params = new URLSearchParams(window.location.hash.split("?")[1] || "");
const query = params.get("q") || "";
const type = params.get("type") || "";
const main = document.getElementById("app-main");
main.innerHTML = `
<div style="padding:var(--space-8) 0">
<div class="container">
<div class="search-bar" style="max-width:700px;margin-bottom:var(--space-6)">
<!-- BUG-10 FIX: removed duplicate id attribute -->
<input class="search-bar__input" id="search-input"
placeholder="Search entities..."
autocomplete="off">
<button class="search-bar__btn" id="search-btn">Search</button>
</div>
<div style="display:flex;align-items:center;gap:var(--space-3);
margin-bottom:var(--space-3);flex-wrap:wrap">
<select id="lang-select" style="font-size:var(--font-size-xs);padding:4px 8px;
background:var(--bg-secondary);color:var(--text-primary);
border:1px solid var(--border-color);border-radius:6px;cursor:pointer"
onchange="State.language=this.value;applyLanguage(this.value);document.getElementById('navbar-lang-select')&&(document.getElementById('navbar-lang-select').value=this.value);(function(){const q=document.getElementById('search-input')&&document.getElementById('search-input').value.trim()||new URLSearchParams(window.location.hash.split('?')[1]||'').get('q')||'';if(q){const t=new URLSearchParams(window.location.hash.split('?')[1]||'').get('type')||'';Router.navigate('/search?q='+encodeURIComponent(q)+(t?'&type='+encodeURIComponent(t):'')+'&lang='+this.value);}}).call(this)">
<option value="en">?? English</option>
<option value="hi">HI -- हिन्दी</option>
<option value="ta">TA -- தமிழ்</option>
<option value="te">TE -- తెలుగు</option>
<option value="kn">KN -- ಕನ್ನಡ</option>
<option value="ml">ML -- മലയാളം</option>
<option value="mr">MR -- मराठी</option>
<option value="bn">BN -- বাংলা</option>
<option value="gu">GU -- ગુજરાતી</option>
<option value="pa">PA -- ਪੰਜਾਬੀ</option>
<option value="or">OR -- ଓଡ଼ିଆ</option>
<option value="as">AS -- অসমীয়া</option>
<option value="ur">UR -- اردو</option>
<option value="kok">KOK -- कोंकणी</option>
<option value="mai">MAI -- मैथिली</option>
<option value="mni">MNI -- মৈতৈলোন্</option>
<option value="sat">SAT -- ṥṁṭtāḷi</option>
<option value="ks">KS -- کٲشہر</option>
<option value="ne">NE -- नेपाली</option>
<option value="doi">DOI -- डोगरी</option>
<option value="sa">SA -- संस्कृतम्</option>
<option value="sd">SD -- سنڌي</option>
</select>
</div>
<div style="display:flex;align-items:center;gap:var(--space-2);
margin-bottom:var(--space-6);flex-wrap:wrap">
<span style="font-size:var(--font-size-sm);color:var(--text-muted)">Filter:</span>
${["All","politician","company","audit","contract","ministry","party","scheme","tender","electoralbond","regulatory","enforcement","insolvency","ngo","parliamentquestion","vigilancecircular","icijentity","sanctionedentity","courtcase","localbody","datagov","crimereport"].map(t => `
<button class="btn btn--sm ${type === t || (!type && t==="All") ? "btn--primary" : "btn--secondary"}"
onclick="Router.navigate('/search?q=${encodeURIComponent(query)}${t!=="All"?"&type="+t.toLowerCase():""}'+(State.language&&State.language!=='en'?'&lang='+State.language:''))">
${t.charAt(0).toUpperCase()+t.slice(1)}
</button>
`).join("")}
</div>
<div id="search-results">
${query ? (() => { const w = document.createElement("div"); w.appendChild(Components.SkeletonGroup(5)); return w.innerHTML; })() : ""}
</div>
</div>
</div>
`;
// B-06 FIX: wire data-nav-path filter buttons after DOM is built
document.querySelectorAll("[data-nav-path]").forEach(function(btn) {
btn.addEventListener("click", function() { Router.navigate(btn.dataset.navPath); });
});
// C-01 FIX: pre-fill the search input with the current query
// so users can see what they searched and edit it
const _searchInput = document.getElementById("search-input");
if (_searchInput && query) _searchInput.value = query;
document.getElementById("search-btn").addEventListener("click", () => {
const q = document.getElementById("search-input").value.trim();
const _l=State.language&&State.language!=='en'?'&lang='+State.language:'';
if (q) Router.navigate(`/search?q=${encodeURIComponent(q)}${_l}`);
});
document.getElementById("search-input").addEventListener("keydown", (e) => {
if (e.key === "Enter") {
const q = e.target.value.trim();
const _l=State.language&&State.language!=='en'?'&lang='+State.language:'';
if (q) Router.navigate(`/search?q=${encodeURIComponent(q)}${_l}`);
}
});
if (!query) {
document.getElementById("search-results").innerHTML = `
<div style="text-align:center;padding:var(--space-16);color:var(--text-muted)">
Enter a name to begin searching
</div>
`;
return;
}
// BUG-19 FIX: skeleton was rendered twice -- once in the innerHTML template
// above (when query is present) and once here via explicit appendChild.
// The innerHTML="" cleared the first one but caused a double-render flash.
// Now only render via innerHTML template; skip this explicit block.
// (skeletons variable kept for any legacy references below this block)
const _skeletonContainer = document.getElementById("search-results");
if (_skeletonContainer && !_skeletonContainer.hasChildNodes()) {
const skeletons = document.createElement("div");
skeletons.appendChild(Components.SkeletonGroup(5));
_skeletonContainer.appendChild(skeletons);
}
const lang = (typeof State !== 'undefined' && State.language) ? State.language : 'en';
const searchPromise = (lang && lang !== 'en')
? Api.multilingualSearch(query, lang)
: Api.search(query, type ? type.toLowerCase() : null, 20, lang);
searchPromise.then(rawData => {
// Normalise shape: multilingual returns {id, type} while main search
// returns {entity_id, entity_type}. Unify before rendering.
const data = {
...rawData,
total: rawData.total || (rawData.results || []).length,
query: rawData.query || query,
results: (rawData.results || []).map(r => ({
entity_id: r.entity_id || r.id || "",
entity_type: r.entity_type || r.type || "Unknown",
name: r.name || r.title || r.entity_id || r.id || "",
state: r.state || null,
party: r.party || null,
source: r.source || r.dataset || null,
sources: r.sources || [],
})),
};
const container = document.getElementById("search-results");
if (!container) return;
container.innerHTML = "";
if (!data.results || data.results.length === 0) {
container.innerHTML = `
<div style="text-align:center;padding:var(--space-16);color:var(--text-muted)">
No results found for "${sanitize(query)}"
</div>
`;
return;
}
const header = document.createElement("div");
header.style.cssText = "margin-bottom:var(--space-4);font-size:var(--font-size-sm);color:var(--text-muted)";
header.textContent = `${data.total} result${data.total !== 1 ? "s" : ""} for "${data.query}"`;
container.appendChild(header);
const list = document.createElement("div");
list.style.cssText = "display:flex;flex-direction:column;gap:var(--space-3)";
data.results.forEach(entity => {
list.appendChild(Components.ResultCard(entity, (e) => {
Router.navigate(`/entity/${e.entity_id}`);
}));
});
container.appendChild(list);
}).catch(err => {
const container = document.getElementById("search-results");
if (container) container.innerHTML = `
<div style="text-align:center;padding:var(--space-8);color:var(--color-risk-very-high)">
Search failed. The API may be starting up -- please retry in a moment.
</div>
`;
});
},
entity: ({ id }) => {
const main = document.getElementById("app-main");
main.innerHTML = `
<div style="padding:var(--space-8) 0">
<div class="container">
<div id="entity-content">
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
${[120,80,400,300].map(h =>
`<div class="skeleton" style="height:${h}px;border-radius:12px"></div>`
).join("")}
</div>
</div>
</div>
</div>
`;
Promise.all([
Api.profile(id).catch(() => null),
Api.risk(id).catch(() => null),
Api.graphConnections(id, 2).catch(() => null),
]).then(([profile, risk, graph]) => {
const container = document.getElementById("entity-content");
if (!container) return;
if (!profile && !risk) {
container.innerHTML = `
<div style="text-align:center;padding:var(--space-16);color:var(--text-muted)">
Entity not found: ${sanitize(id)}
</div>
`;
return;
}
const name = profile?.name || risk?.entity_name || id;
const type = profile?.entity_type || "Unknown";
const score = risk?.risk_score ?? 0;
const level = risk?.risk_level || "UNKNOWN";
container.innerHTML = `
<div style="margin-bottom:var(--space-6)">
<a href="#/search"
onclick="var _hp=window.history;if(_hp.length>1&&document.referrer.includes('#/search')){_hp.back();}else{Router.navigate('/search');} return false;"
style="font-size:var(--font-size-sm);color:var(--text-muted);
text-decoration:none">
&larr; Back to results
</a>
</div>
<div style="display:grid;grid-template-columns:1fr 320px;
gap:var(--space-6);align-items:start">
<div>
<div style="margin-bottom:var(--space-6)">
<div style="display:flex;align-items:center;gap:var(--space-3);
margin-bottom:var(--space-2)">
<span class="badge badge--${sanitize(type).toLowerCase()}">${sanitize(type)}</span>
</div>
<h1 style="font-size:var(--font-size-3xl);font-weight:700;
color:var(--text-primary);margin-bottom:var(--space-2)">${sanitize(name)}</h1>
${profile?.overview
? Object.entries(profile.overview)
.filter(([,v]) => v)
.map(([k,v]) => `
<span style="font-size:var(--font-size-sm);
color:var(--text-secondary);
margin-right:var(--space-4)">
<strong>${sanitize(k)}:</strong> ${sanitize(String(v))}
</span>
`).join("")
: ""}
</div>
<div id="dossier-tabs" style="margin-bottom:var(--space-4)">
<div class="tab-bar">
<button class="tab-bar__tab active" data-tab="findings">Findings</button>
<button class="tab-bar__tab" data-tab="graph">Relationship Graph</button>
<button class="tab-bar__tab" data-tab="evidence">Evidence Locker</button>
</div>
</div>
<div id="tab-content-findings">
<h3 style="font-size:var(--font-size-base);font-weight:600;
margin-bottom:var(--space-4)">Analytical Findings</h3>
<div id="findings-list">
${risk?.factors?.filter(f => f.score > 0).map(f =>
`<div class="finding-item finding-item--${f.score > 20 ? "HIGH" : "MODERATE"}">
<div class="finding-item__severity">${f.name.replace(/_/g," ")}</div>
<div class="finding-item__desc">${f.description}</div>
</div>`
).join("") || `<p style="color:var(--text-muted)">No findings in current dataset.</p>`}
</div>
</div>
<div id="tab-content-graph" style="display:none">
<div class="graph-container" id="entity-graph">
<div class="graph-controls">
<button class="btn btn--sm btn--secondary"
onclick="GraphRenderer.resetZoom('entity-graph')">Reset</button>
</div>
<div class="graph-legend" id="graph-legend"></div>
</div>
</div>
<div id="tab-content-evidence" style="display:none">
${risk?.sources?.map(s => `
<div class="evidence-item" style="padding:var(--space-3) var(--space-4);
margin-bottom:var(--space-2);background:var(--bg-secondary);
border:1px solid var(--border-color);border-radius:var(--radius-md)">
<div style="font-size:var(--font-size-sm);font-weight:600">${s.institution}</div>
<div style="font-size:var(--font-size-xs);color:var(--text-muted)">
${s.document_title}
${s.url && (s.url.startsWith('https://') || s.url.startsWith('http://')) ? `| <a href="${sanitize(s.url)}" target="_blank" rel="noopener noreferrer">${sanitize(s.url)}</a>` : ""}
</div>
</div>
`).join("") || `<p style="color:var(--text-muted)">No evidence sources in current dataset.</p>`}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:var(--space-4)">
<div class="card">
<div class="card__header">
<div class="card__title">Structural Risk Indicator</div>
</div>
<div class="card__body">
<div id="risk-bar-container" style="margin-bottom:var(--space-4)"></div>
<div id="risk-badge-container" style="margin-bottom:var(--space-4)"></div>
<p style="font-size:var(--font-size-xs);color:var(--text-muted);
line-height:var(--line-height-relaxed)">
${sanitize(risk?.explanation || "Risk explanation unavailable.")}
</p>
</div>
</div>
<div class="card">
<div class="card__body">
<a href="${Api.exportPdf(id)}" target="_blank"
class="btn btn--secondary" style="width:100%;justify-content:center">
Download PDF Dossier
</a>
</div>
</div>
</div>
</div>
`;
const barContainer = document.getElementById("risk-bar-container");
if (barContainer) barContainer.appendChild(Components.RiskBar(score, level));
const badgeContainer = document.getElementById("risk-badge-container");
if (badgeContainer) badgeContainer.appendChild(Components.RiskBadge(level));
document.querySelectorAll(".tab-bar__tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab-bar__tab").forEach(t => t.classList.remove("active"));
tab.classList.add("active");
const tabId = tab.dataset.tab;
["findings","graph","evidence"].forEach(t => {
const el = document.getElementById(`tab-content-${t}`);
if (el) el.style.display = t === tabId ? "block" : "none";
});
if (tabId === "graph" && graph) {
GraphRenderer.init("entity-graph", graph);
GraphRenderer.renderLegend("graph-legend");
} else if (GraphRenderer._currentSimulation) {
// L-06 FIX: stop simulation when switching away from graph tab
GraphRenderer._currentSimulation.stop();
}
});
});
});
},
liveFeed: () => {
const main = document.getElementById("app-main");
main.innerHTML = `
<div style="padding:var(--space-8) 0">
<div class="container">
<div style="display:flex;align-items:center;gap:var(--space-4);
margin-bottom:var(--space-6)">
<h1 class="section-title" style="margin-bottom:0">Live Intelligence Feed</h1>
<div style="display:flex;align-items:center;gap:var(--space-2);
font-size:var(--font-size-xs);color:var(--text-muted)">
<span class="feed-item__indicator feed-item__indicator--MODERATE"
style="animation:pulse 1s infinite"></span>
<span id="feed-status">Connecting...</span>
</div>
</div>
<p class="section-subtitle">
Real-time analytical intelligence derived from CAG audit reports,
government procurement data, and official regulatory filings.
</p>
<div id="feed-container" class="card">
<div style="padding:60px;text-align:center;color:var(--text-muted)">
Connecting to feed...
</div>
</div>
</div>
</div>
`;
const feedItems = [];
// BUG-3+4+20 FIX: reconnect with exponential backoff, render data.items,
// show meaningful onerror message instead of swallowing silently.
function connectFeed(delay) {
delay = delay || 2000;
let ws;
try {
ws = Api.createFeedSocket();
} catch (_) {
const s = document.getElementById("feed-status");
if (s) s.textContent = "WebSocket unavailable";
return;
}
ws.onopen = () => {
const s = document.getElementById("feed-status");
if (s) s.textContent = "Connected";
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// BUG-4 FIX: render real Neo4j records from data.items when available
const items = data.items || [];
const seenIds = new Set(feedItems.map(i => i._id).filter(Boolean));
const newItems = items.filter(i => i.id && !seenIds.has(i.id));
if (newItems.length > 0) {
newItems.forEach(item => {
feedItems.unshift({
headline: "[" + (item.label || "Entity") + "] " + sanitize(item.name || "-"),
risk_level: ({"EnforcementAction":"VERY_HIGH","RegulatoryOrder":"HIGH","AuditReport":"HIGH","ElectoralBond":"MODERATE","Contract":"MODERATE","VigilanceCircular":"MODERATE","PressRelease":"LOW"})[item.label] || "MODERATE",
detected_at: item.scraped_at || data.at || new Date().toISOString(),
source: sanitize(item.source || "BharatGraph"),
_id: item.id,
});
});
} else if (data.message && data.message.includes("pipeline")) {
// FEED-1 FIX: liveFeed view had no pipeline-message guard.
// The admin system message was rendered as fake intelligence.
// Show user-friendly empty-state instead.
const container2 = document.getElementById("feed-container");
const callout = document.createElement("div");
callout.id = "lf-empty-callout";
callout.style.cssText = "padding:20px;text-align:center;color:var(--color-saffron);font-size:13px;border:1px solid rgba(255,153,51,0.25);border-radius:8px;margin:8px";
callout.textContent = "Intelligence database is being populated. Check back after the daily pipeline run completes.";
container2.appendChild(callout);
} else {
feedItems.unshift({
headline: sanitize(data.message || "Feed update received"),
risk_level: "MODERATE",
detected_at: data.at || new Date().toISOString(),
source: "BharatGraph API",
});
}
if (feedItems.length > 50) feedItems.pop();
const container = document.getElementById("feed-container");
if (!container) { ws.close(); return; }
container.innerHTML = "";
feedItems.forEach(item => container.appendChild(Components.FeedItem(item)));
} catch (_) {}
};
// BUG-20 FIX: show meaningful error instead of swallowing silently
ws.onerror = (err) => {
const s = document.getElementById("feed-status");
if (s) s.textContent = "Connection error -- retrying...";
};
// M-07 FIX: reconnect with max 20 retry cap
ws.onclose = () => {
const container = document.getElementById("feed-container");
if (!container) return;
if ((connectFeed._retries || 0) >= 20) {
const s = document.getElementById("feed-status");
if (s) s.textContent = "Feed unavailable -- refresh to retry";
return;
}
connectFeed._retries = (connectFeed._retries || 0) + 1;
const s = document.getElementById("feed-status");
if (s) s.textContent = "Reconnecting...";
const nextDelay = Math.min(delay * 2, 30000);
setTimeout(() => connectFeed(nextDelay), nextDelay);
};
}
connectFeed(2000);
},
"connection-map": (params) => {
const urlParams = new URLSearchParams(window.location.hash.split("?")[1] || "");
const entityId = urlParams.get("entity") || "";
const entityName = urlParams.get("name") || entityId;
const main = document.getElementById("app-main");
main.innerHTML = `
<div style="padding:var(--space-8) 0">
<div class="container">
<h2 style="font-size:var(--font-size-xl);font-weight:700;color:var(--text-primary);margin-bottom:4px">
Connection Map
</h2>
<p style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-bottom:var(--space-6)">
Evidence-based relationship map for: <strong>${sanitize(entityName)}</strong>
</p>
<div style="display:grid;grid-template-columns:1fr 340px;gap:var(--space-6)">
<!-- Graph area -->
<div style="background:var(--bg-secondary);border-radius:12px;
border:1px solid var(--border-color);min-height:500px;
display:flex;flex-direction:column">
<div style="padding:12px 16px;border-bottom:1px solid var(--border-color);
display:flex;align-items:center;justify-content:space-between">
<span style="font-size:12px;font-weight:600;color:var(--text-secondary)">
Relationship Graph
</span>
<div style="display:flex;gap:6px">
<span style="font-size:10px;color:var(--text-muted)">
&#9733; Strong &nbsp; &#9671; Medium &nbsp; &#9675; Weak
</span>
</div>
</div>
<div id="conn-graph" style="flex:1;min-height:440px;position:relative">
<div class="spinner" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)"></div>
</div>
</div>
<!-- Evidence list -->
<div>
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.08em;color:var(--text-muted);margin-bottom:10px">
Evidence Chain
</div>
<div id="conn-evidence-list">
<div class="spinner" style="margin:20px auto"></div>
</div>
<div style="margin-top:16px">
<div style="font-size:11px;font-weight:700;text-transform:uppercase;
letter-spacing:.08em;color:var(--text-muted);margin-bottom:10px">
Path Finder
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<input id="path-target" class="search-bar__input"
placeholder="Target entity ID..."
style="font-size:12px;padding:8px 12px">
<button onclick="Views._findPath(this.dataset.eid)"
data-eid="${sanitize(entityId)}"
class="btn btn--secondary" style="font-size:12px">Find Shortest Path</button>
</div>
<div id="path-result" style="margin-top:10px"></div>
</div>
</div>
</div>
</div>
</div>
`;
if (!entityId) return;
// Load evidence
Api.nodeEvidence(entityId).then(data => {
const edges = data.edges || [];
const listEl = document.getElementById("conn-evidence-list");
if (!listEl) return;
listEl.innerHTML = edges.length ? edges.map(e => `
<div style="padding:10px 12px;margin-bottom:6px;background:var(--bg-secondary);
border-radius:8px;border:1px solid var(--border-color);cursor:pointer"
data-eid="${sanitize(e.connected_id||'')}"
data-ename="${sanitize(e.connected_to||'')}"
onclick="EvidencePanel.open(this.getAttribute('data-eid'),this.getAttribute('data-ename'))">
<div style="font-size:10px;font-weight:700;text-transform:uppercase;
color:var(--color-saffron);margin-bottom:2px">${sanitize(e.rel_label||"")}</div>
<div style="font-size:12px;font-weight:600;color:var(--text-primary)">
${sanitize(e.connected_to||e.connected_id||"--")}
</div>
<div style="font-size:10px;color:var(--text-secondary);margin-top:2px">
${sanitize(e.why||"")}
</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:2px">
&#128196; ${sanitize(e.source||"")}
</div>
</div>`).join("") : `<div style="text-align:center;padding:20px;color:var(--text-muted);font-size:12px">
No connections in current dataset</div>`;
// Render D3 graph
Views._renderConnectionGraph(entityId, entityName, edges);
}).catch(() => {
const el = document.getElementById("conn-graph");
if (el) el.innerHTML = `<div style="text-align:center;padding:60px;color:var(--text-muted)">
Could not load graph data</div>`;
});
},
_findPath: (entityA) => {
const b = document.getElementById("path-target").value.trim();
const res = document.getElementById("path-result");
if (!b || !res) return;
res.innerHTML = `<div class="spinner" style="margin:10px auto"></div>`;
Api._request(`/connection-map?a=${encodeURIComponent(entityA)}&b=${encodeURIComponent(b)}`)
.then(data => {
const path = data.path || data.paths || [];
res.innerHTML = path.length ? `
<div style="padding:10px;background:var(--bg-secondary);border-radius:8px;
border:1px solid var(--border-color);font-size:12px">
<div style="font-weight:600;color:var(--color-saffron);margin-bottom:6px">
${path.length} hop path found
</div>
${(Array.isArray(path[0]) ? path[0] : path).map(n =>
`<span style="color:var(--text-primary)">${sanitize(n)}</span>
<span style="color:var(--text-muted);margin:0 4px">-></span>`
).join("").replace(/<span[^>]*>-><\/span>$/, "")}
</div>` : `
<div style="color:var(--text-muted);font-size:12px">No path found</div>`;
}).catch(() => {
res.innerHTML = `<div style="color:var(--text-muted);font-size:12px">Path search unavailable</div>`;
});
},
_renderConnectionGraph: (centerId, centerName, edges) => {
const container = document.getElementById("conn-graph");
if (!container || !window.d3) {
if (container) container.innerHTML = `
<div style="text-align:center;padding:60px;color:var(--text-muted);font-size:12px">
Graph visualization requires D3.js</div>`;
return;
}
const w = container.clientWidth || 600, h = container.clientHeight || 440;
container.innerHTML = "";
const nodes = [{id: centerId, name: centerName, group: "center"}];
const links = [];
const seen = new Set([centerId]);
edges.slice(0, 20).forEach(e => {
// M-10 FIX: Math.random() creates different IDs each render -- duplicates accumulate.
// Use deterministic fallback based on edge properties.
const tid = e.connected_id || e.connected_to || ("unknown-" + btoa(JSON.stringify({l:e.rel_label,w:e.why})).slice(0, 8));
if (!seen.has(tid)) { seen.add(tid); nodes.push({id:tid, name:e.connected_to||tid, group:e.rel_label||"OTHER"}); }
links.push({source:centerId, target:tid, label:e.rel_label||"", strength:e.strength||"medium"});
});
const svg = d3.select(container).append("svg").attr("width","100%").attr("height","100%")
.attr("viewBox",`0 0 ${w} ${h}`);
const sim = d3.forceSimulation(nodes)
.force("link",d3.forceLink(links).id(d=>d.id).distance(120))
.force("charge",d3.forceManyBody().strength(-300))
.force("center",d3.forceCenter(w/2,h/2));
const link = svg.append("g").selectAll("line").data(links).join("line")
.attr("stroke","#555").attr("stroke-width",1.5).attr("stroke-opacity",0.6);
const node = svg.append("g").selectAll("circle").data(nodes).join("circle")
.attr("r", d => d.group==="center"?16:10)
.attr("fill", d => d.group==="center"?"var(--color-saffron)":"var(--accent-primary)")
.attr("stroke","var(--bg-primary)").attr("stroke-width",2)
.style("cursor","pointer")
.on("click", (e,d) => { if(d.id!==centerId) EvidencePanel.open(d.id, d.name); });
const label = svg.append("g").selectAll("text").data(nodes).join("text")
.text(d => (d.name||d.id).slice(0,18))
.attr("font-size","10px").attr("fill","var(--text-primary)")
.attr("text-anchor","middle").attr("dy","26px");
sim.on("tick", () => {
link.attr("x1",d=>d.source.x).attr("y1",d=>d.source.y)
.attr("x2",d=>d.target.x).attr("y2",d=>d.target.y);
node.attr("cx",d=>d.x).attr("cy",d=>d.y);
label.attr("x",d=>d.x).attr("y",d=>d.y);
});
},
about: () => {
const main = document.getElementById("app-main");
main.innerHTML = `
<div style="padding:var(--space-12) 0">
<div class="container container--narrow">
<h1 class="section-title">About BharatGraph</h1>
<p class="section-subtitle">
A public transparency platform built on official government data.
</p>
<div style="display:flex;flex-direction:column;gap:var(--space-6)">
${[
["Legal Notice",
"BharatGraph processes only publicly available government records published by the Election Commission of India, Ministry of Corporate Affairs, Government e-Marketplace, Comptroller and Auditor General, Press Information Bureau, and Parliament of India. All outputs are statistical observations derived from official sources. No claim made by this platform constitutes a legal finding or an accusation of wrongdoing."],
["Right to Correction",
"Any individual or organisation featured in this platform has the right to submit corrections. Contact us through the GitHub repository issue tracker with documentary evidence. Verified corrections will be incorporated within 14 days."],
["Data Sources",
"21 official Indian government data sources plus international datasets from OpenSanctions (sanctions and PEP screening), ICIJ Offshore Leaks (Panama, Pandora, Paradise Papers), and Wikidata (entity enrichment)."],
["Methodology",
"The BharatGraph Multi-Investigator Engine runs 15 specialist AI investigators in parallel. Each queries the knowledge graph independently. Findings confirmed by two or more investigators are marked as high-confidence (MODERATE when 2, HIGH when 3+). All language output is validated against a forbidden-words list that prohibits accusatory terminology."],
].map(([title, body]) => `
<div class="card">
<div class="card__header"><div class="card__title">${title}</div></div>
<div class="card__body">
<p style="font-size:var(--font-size-sm);color:var(--text-secondary);
line-height:var(--line-height-relaxed)">${body}</p>
</div>
</div>
`).join("")}
</div>
</div>
</div>
`;
},
notFound: (params) => {
// BUG-11 FIX: render a proper 404 page instead of leaving frozen spinner
const main = document.getElementById("app-main");
if (!main) return;
const badPath = sanitize((params && params.path) || window.location.hash || "/");
main.innerHTML = `
<div style="display:flex;flex-direction:column;align-items:center;
justify-content:center;min-height:60vh;text-align:center;
padding:40px 20px;color:var(--text-muted)">
<div style="font-size:64px;margin-bottom:16px;opacity:0.3">404</div>
<div style="font-size:20px;font-weight:700;color:var(--text-primary);margin-bottom:8px">
Page not found
</div>
<div style="font-size:14px;margin-bottom:24px">
The path <code style="background:var(--bg-tertiary);padding:2px 8px;border-radius:4px">
${badPath}
</code> is not registered.
</div>
<button class="btn btn--primary" onclick="Router.navigate('/')">Go Home</button>
</div>`;
},
};
function toggleTheme() {
State.theme = State.theme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", State.theme);
localStorage.setItem("bg-theme", State.theme);
const btn = document.getElementById("theme-btn");
if (btn) btn.textContent = State.theme === "dark" ? "Light" : "Dark";
}
// ?? Language Application ??????????????????????????????????????????????????????
// BUG-10 FIX: full DOM translation via data-i18n attributes
// M-05 FIX: cache language labels to avoid re-fetching on every change
const _langLabelCache = {};
// M-05 FIX: cache language labels -- applyLanguage() was re-fetching on every change
// causing multiple concurrent API calls during rapid language switching
// M-05 FIX: cache language labels -- applyLanguage() was re-fetching on every change
// causing multiple concurrent API calls during rapid language switching
async function applyLanguage(lang) {
// LOGIC-4 FIX: save language preference so it survives page refresh
if (lang) localStorage.setItem("bg-lang", lang);
const badge = document.getElementById("lang-badge");
if (!lang || lang === "en") {
document.querySelectorAll("[data-i18n]").forEach(el => {
const def = el.getAttribute("data-i18n-default");
if (!def) return;
if (el.hasAttribute("placeholder")) el.placeholder = def;
else el.textContent = def;
});
if (badge) badge.textContent = "EN";
State.uiLabels = {};
return;
}
try {
// Return cached labels if we already fetched this language
const data = _langLabelCache[lang] || await Api.uiLabels(lang).then(d => {
_langLabelCache[lang] = d;
return d;
});
const labels = data.labels || {};
// Apply every translated label to every matching data-i18n DOM element.
// Auto-scales: new keys in languages.py are applied automatically.
document.querySelectorAll("[data-i18n]").forEach(el => {
const value = labels[el.getAttribute("data-i18n")];
if (!value) return;
if (el.hasAttribute("placeholder")) el.placeholder = value;
else el.textContent = value;
});
// Also update dynamic search input (rendered after page load)
const ph = document.querySelector(".search-bar__input, #search-input");
if (ph && labels.search_placeholder) ph.placeholder = labels.search_placeholder;
if (badge) { badge.textContent = lang.toUpperCase(); badge.title = data.native_name || lang; }
State.uiLabels = labels;
State.langName = data.language_name || lang;
State.nativeName = data.native_name || lang;
} catch (e) {
console.warn("[i18n] Could not load UI labels for", lang, e.message);
}
}
window.applyLanguage = applyLanguage;
function initApp() {
document.documentElement.setAttribute("data-theme", State.theme);
Router.register("/", Views.home);
Router.register("/search", Views.search);
Router.register("/entity/:id", Views.entity);
Router.register("/connection-map", Views["connection-map"]);
Router.register("/live-feed", Views.liveFeed);
Router.register("/about", Views.about);
// BUG-11 FIX: register wildcard so unknown URLs show 404 page
// instead of frozen spinner (router.js supports "*" but it was never called)
Router.register("/forensics", Views.forensics);
Router.register("*", Views.notFound);
// BUG-02 FIX: was single immediate check -- failed permanently on HF cold start.
// BUG-25 FIX: message said "Start: uvicorn" -- wrong for HuggingFace users.
// Fix: retry up to 5 times with exponential backoff (2s, 4s, 8s, 16s, 30s).
(function checkHealth(attempt) {
Api.health()
.then(data => {
State.apiConnected = true;
const dot = document.getElementById("api-status-dot");
const text = document.getElementById("api-status-text");
if (dot) dot.style.background = data.neo4j_connected ? "var(--color-risk-low)" : "var(--color-saffron)";
if (text) text.textContent = data.neo4j_connected ? "Connected" : "API Only";
})
.catch(() => {
if (attempt < 5) {
const delay = Math.min(2000 * Math.pow(2, attempt - 1), 30000);
setTimeout(() => checkHealth(attempt + 1), delay);
} else {
Components.Toast("API unavailable. Please refresh the page in a moment.", "error");
}
});
})(1);
Router.init();
// Apply language on load if not English
if (State.language && State.language !== "en") {
applyLanguage(State.language);
}
}
window.addEventListener("DOMContentLoaded", initApp);
window.toggleTheme = toggleTheme;