| |
| |
| |
|
|
| |
| const state = { |
| allData: [], |
| filteredData: [], |
| selectedDocName: null, |
| activeCloudWord: null, |
| filters: { |
| search: '', |
| category: 'all', |
| impactChannel: 'all', |
| specificity: 'named' |
| }, |
| pagination: { |
| currentPage: 1, |
| pageSize: 12 |
| }, |
| charts: { |
| topMentions: null |
| }, |
| theme: 'dark', |
| activeDocPages: [], |
| activePageNumber: 1, |
| activeSearchTerm: "", |
| activePdfUrl: "", |
| docPagesCache: {} |
| }; |
|
|
| |
| function getActiveDocMaxPage() { |
| if (!state.activeDocPages || state.activeDocPages.length === 0) return 1; |
| return state.activeDocPages.reduce((max, p) => p.page_number > max ? p.page_number : max, 1); |
| } |
|
|
| |
| function normalizeSearchTerm(term) { |
| if (!term) return ""; |
| return term |
| .replace(/[\u2013\u2014]/g, "-") |
| .replace(/[\u2018\u2019]/g, "'") |
| .replace(/[\u201c\u201d]/g, '"') |
| .replace(/\s+/g, " ") |
| .trim(); |
| } |
|
|
| |
| const ACRONYMS = new Set(["unhcr", "wbg", "khis", "idp", "idps", "gbvims", "seis", "esmap", "step", "steps", "covid", "covid-19", "un", "who", "ngo", "cimp", "r2p", "kisedp", "khssp", "sgbv", "ipc"]); |
|
|
| |
| const elements = { |
| themeToggle: document.getElementById('theme-toggle'), |
| globalSearch: document.getElementById('global-search'), |
| filterCategory: document.getElementById('filter-category'), |
| filterImpactChannel: document.getElementById('filter-impact-channel'), |
| filterSpecificity: document.getElementById('filter-specificity'), |
| documentList: document.getElementById('document-list'), |
| filteredDocCount: document.getElementById('filtered-doc-count'), |
| tabButtons: document.querySelectorAll('.tab-btn'), |
| tabContents: document.querySelectorAll('.tab-content'), |
| |
| |
| statTotalMentions: document.getElementById('stat-total-mentions'), |
| statTotalDocs: document.getElementById('stat-total-docs'), |
| statUniqueEntities: document.getElementById('stat-unique-entities'), |
| cardTotalMentions: document.getElementById('card-total-mentions'), |
| cardTotalDocs: document.getElementById('card-total-docs'), |
| cardUniqueEntities: document.getElementById('card-unique-entities'), |
| |
| |
| tableBody: document.getElementById('table-body'), |
| tableShowingCount: document.getElementById('table-showing-count'), |
| tableTotalCount: document.getElementById('table-total-count'), |
| paginationContainer: document.getElementById('table-pagination'), |
| btnExportCsv: document.getElementById('btn-export-csv'), |
| btnClearFilters: document.getElementById('btn-clear-filters'), |
| |
| |
| wordCloud: document.getElementById('word-cloud'), |
| |
| |
| diveBlankState: document.getElementById('dive-blank-state'), |
| diveDetail: document.getElementById('dive-detail'), |
| diveDocCategory: document.getElementById('dive-doc-category'), |
| diveDocTitle: document.getElementById('dive-doc-title'), |
| diveDocId: document.getElementById('dive-doc-id'), |
| diveDocMentionCount: document.getElementById('dive-doc-mention-count'), |
| diveMentionsList: document.getElementById('dive-mentions-list'), |
| tabDocDive: document.getElementById('tab-doc-dive'), |
| |
| |
| pdfViewerFrameContainer: document.getElementById('pdf-viewer-frame-container'), |
| btnPrevPage: document.getElementById('btn-prev-page'), |
| btnNextPage: document.getElementById('btn-next-page'), |
| currentViewerPage: document.getElementById('current-viewer-page'), |
| totalViewerPages: document.getElementById('total-viewer-pages') |
| }; |
|
|
| |
| document.addEventListener('DOMContentLoaded', () => { |
| initTheme(); |
| loadData(); |
| setupEventListeners(); |
| }); |
|
|
| |
| function initTheme() { |
| const savedTheme = localStorage.getItem('fcv-dashboard-theme'); |
| if (savedTheme) { |
| state.theme = savedTheme; |
| } else if (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) { |
| state.theme = 'light'; |
| } |
| |
| document.documentElement.setAttribute('data-theme', state.theme); |
| } |
|
|
| function toggleTheme() { |
| state.theme = state.theme === 'dark' ? 'light' : 'dark'; |
| document.documentElement.setAttribute('data-theme', state.theme); |
| localStorage.setItem('fcv-dashboard-theme', state.theme); |
| |
| updateChartsThemes(); |
| } |
|
|
| function getThemeColors() { |
| const isDark = state.theme === 'dark'; |
| return { |
| text: isDark ? '#9ca3af' : '#475569', |
| border: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)', |
| grid: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)' |
| }; |
| } |
|
|
| |
| async function loadData() { |
| try { |
| const response = await fetch('data/all_mined_data_mentions.json'); |
| if (!response.ok) { |
| throw new Error(`HTTP error! status: ${response.status}`); |
| } |
| const rawData = await response.json(); |
| |
| |
| state.allData = rawData.map(item => { |
| const normalized = getNormalizedMentionName(item); |
| return { |
| ...item, |
| normalized_name: normalized |
| }; |
| }); |
| |
| state.filteredData = [...state.allData]; |
| |
| |
| populateFilters(); |
| applyFilters(); |
| initCharts(); |
| |
| |
| lucide.createIcons(); |
|
|
| |
| setTimeout(startBackgroundPrefetch, 1500); |
| } catch (error) { |
| console.error("Could not load dashboard data mentions:", error); |
| elements.documentList.innerHTML = ` |
| <div class="loading-placeholder"> |
| <i data-lucide="alert-triangle" class="text-warning" style="width:32px;height:32px;"></i> |
| <p>Failed to load dashboard data mentions. Make sure you are running a local web server (e.g. <code>python -m http.server</code>).</p> |
| </div> |
| `; |
| lucide.createIcons(); |
| } |
| } |
|
|
| |
| async function startBackgroundPrefetch() { |
| state.docPagesCache = state.docPagesCache || {}; |
| |
| |
| const docCounts = {}; |
| state.allData.forEach(item => { |
| if (item.document_name) { |
| docCounts[item.document_name] = (docCounts[item.document_name] || 0) + 1; |
| } |
| }); |
| const sortedDocs = Object.keys(docCounts).sort((a, b) => docCounts[b] - docCounts[a]); |
| |
| |
| for (const docName of sortedDocs) { |
| if (state.docPagesCache[docName]) continue; |
| try { |
| const res = await fetch(`data/validated/${docName}_validated.json`); |
| if (res.ok) { |
| const data = await res.json(); |
| state.docPagesCache[docName] = data.sort((a, b) => a.page_number - b.page_number); |
| } |
| } catch (e) { |
| console.warn(`Background prefetch failed for JSON ${docName}:`, e); |
| } |
| } |
| console.log("Preloaded all document text validation files."); |
|
|
| |
| const topPdfs = sortedDocs.slice(0, 5); |
| for (const docName of topPdfs) { |
| try { |
| fetch(`pdfs/${docName}.pdf`, { priority: 'low' }) |
| .then(res => { |
| if (res.ok) console.log(`Preloaded PDF in background: ${docName}.pdf`); |
| }) |
| .catch(() => {}); |
| } catch (e) {} |
| } |
| } |
|
|
| |
| function getNormalizedMentionName(item) { |
| let name = item.corrected_name || item.mention_text; |
| if (!name) return "Unspecified Dataset"; |
| |
| name = name.trim(); |
| const words = name.split(/\s+/); |
| |
| const normalizedWords = words.map(w => { |
| const clean = w.toLowerCase().replace(/[^a-z0-9-]/g, ""); |
| if (ACRONYMS.has(clean) || (w === w.toUpperCase() && w.length > 1 && isNaN(w))) { |
| return w.toUpperCase(); |
| } |
| return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase(); |
| }); |
| |
| return normalizedWords.join(" "); |
| } |
|
|
| |
| function populateFilters() { |
| const categories = new Set(); |
| const channels = new Set(); |
| |
| state.allData.forEach(item => { |
| if (item.corpus_category) categories.add(item.corpus_category.trim()); |
| if (item.downstream_impact_channel) channels.add(item.downstream_impact_channel.trim()); |
| }); |
| |
| |
| Array.from(categories).sort().forEach(cat => { |
| const option = document.createElement('option'); |
| option.value = cat; |
| option.textContent = cat; |
| elements.filterCategory.appendChild(option); |
| }); |
| |
| |
| elements.filterImpactChannel.innerHTML = '<option value="all">All Impact Channels</option>'; |
| Array.from(channels).sort().forEach(channel => { |
| if (!channel) return; |
| const option = document.createElement('option'); |
| option.value = channel; |
| option.textContent = channel; |
| elements.filterImpactChannel.appendChild(option); |
| }); |
| } |
|
|
| |
| function setupEventListeners() { |
| elements.themeToggle.addEventListener('click', toggleTheme); |
| |
| |
| elements.globalSearch.addEventListener('input', (e) => { |
| state.filters.search = e.target.value.toLowerCase(); |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| }); |
| |
| elements.filterCategory.addEventListener('change', (e) => { |
| state.filters.category = e.target.value; |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| }); |
| |
| elements.filterImpactChannel.addEventListener('change', (e) => { |
| state.filters.impactChannel = e.target.value; |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| }); |
| |
| elements.filterSpecificity.addEventListener('change', (e) => { |
| state.filters.specificity = e.target.value; |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| }); |
| |
| elements.btnClearFilters.addEventListener('click', resetAllFilters); |
| elements.btnExportCsv.addEventListener('click', exportToCSV); |
| |
| elements.tabButtons.forEach(btn => { |
| btn.addEventListener('click', () => { |
| const tabId = btn.getAttribute('data-tab'); |
| switchTab(tabId); |
| }); |
| }); |
|
|
|
|
| |
| elements.btnPrevPage.addEventListener('click', () => { |
| if (state.activePageNumber > 1) { |
| state.activePageNumber--; |
| renderViewerContent(); |
| |
| |
| const pageMentions = state.allData.filter(item => item.document_name === state.selectedDocName && item.page_number === state.activePageNumber); |
| if (pageMentions.length > 0) { |
| state.activeSearchTerm = pageMentions[0].mention_text; |
| highlightActiveMentionCard(state.activeSearchTerm); |
| } |
| } |
| }); |
| |
| elements.btnNextPage.addEventListener('click', () => { |
| const maxPage = getActiveDocMaxPage(); |
| if (state.activePageNumber < maxPage) { |
| state.activePageNumber++; |
| renderViewerContent(); |
| |
| |
| const pageMentions = state.allData.filter(item => item.document_name === state.selectedDocName && item.page_number === state.activePageNumber); |
| if (pageMentions.length > 0) { |
| state.activeSearchTerm = pageMentions[0].mention_text; |
| highlightActiveMentionCard(state.activeSearchTerm); |
| } |
| } |
| }); |
| } |
|
|
| |
| function resetAllFilters() { |
| state.filters.search = ''; |
| state.filters.category = 'all'; |
| state.filters.impactChannel = 'all'; |
| state.filters.specificity = 'named'; |
| state.activeCloudWord = null; |
| |
| elements.globalSearch.value = ''; |
| elements.filterCategory.value = 'all'; |
| elements.filterImpactChannel.value = 'all'; |
| elements.filterSpecificity.value = 'named'; |
| |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| } |
|
|
| |
| function applyFilters() { |
| const f = state.filters; |
| |
| state.filteredData = state.allData.filter(item => { |
| |
| if (f.specificity !== 'all' && item.specificity !== f.specificity) return false; |
| |
| |
| if (f.category !== 'all' && item.corpus_category !== f.category) return false; |
| |
| |
| if (f.impactChannel !== 'all' && item.downstream_impact_channel !== f.impactChannel) return false; |
| |
| |
| if (state.activeCloudWord && item.normalized_name !== state.activeCloudWord) return false; |
| |
| |
| if (f.search) { |
| const textToSearch = [ |
| item.document_name, |
| item.document_title, |
| item.mention_text, |
| item.corrected_name, |
| item.normalized_name, |
| item.downstream_impact_channel, |
| item.data_use_impact, |
| item.context_sentence |
| ].join(' ').toLowerCase(); |
| |
| if (!textToSearch.includes(f.search)) return false; |
| } |
| |
| return true; |
| }); |
| |
| updateStatsSummary(); |
| renderSidebarDocuments(); |
| renderMasterTable(); |
| renderPagination(); |
| renderWordCloud(); |
| updateCharts(); |
| } |
|
|
| |
| function updateStatsSummary() { |
| const totalMentions = state.filteredData.length; |
| |
| const uniqueDocs = new Set(state.filteredData.map(item => item.document_name)); |
| const totalDocs = uniqueDocs.size; |
| |
| const uniqueEntities = new Set(state.filteredData.map(item => item.normalized_name)); |
| const totalUniqueEntities = uniqueEntities.size; |
| |
| |
| elements.statTotalMentions.textContent = totalMentions; |
| elements.statTotalDocs.textContent = totalDocs; |
| elements.statUniqueEntities.textContent = totalUniqueEntities; |
| |
| elements.cardTotalMentions.textContent = totalMentions; |
| elements.cardTotalDocs.textContent = totalDocs; |
| elements.cardUniqueEntities.textContent = totalUniqueEntities; |
| } |
|
|
| |
| function renderSidebarDocuments() { |
| const docGroups = {}; |
| state.filteredData.forEach(item => { |
| if (!docGroups[item.document_name]) { |
| docGroups[item.document_name] = { |
| name: item.document_name, |
| title: item.document_title || item.document_name, |
| category: item.corpus_category || 'Other', |
| mentionsCount: 0 |
| }; |
| } |
| docGroups[item.document_name].mentionsCount++; |
| }); |
| |
| const sortedDocs = Object.values(docGroups).sort((a, b) => b.mentionsCount - a.mentionsCount); |
| elements.filteredDocCount.textContent = sortedDocs.length; |
| |
| if (sortedDocs.length === 0) { |
| elements.documentList.innerHTML = ` |
| <div class="loading-placeholder"> |
| <i data-lucide="file-warning"></i> |
| <p>No matching documents found.</p> |
| </div> |
| `; |
| lucide.createIcons(); |
| return; |
| } |
| |
| elements.documentList.innerHTML = ''; |
| sortedDocs.forEach(doc => { |
| const card = document.createElement('div'); |
| card.className = `doc-card ${state.selectedDocName === doc.name ? 'active' : ''}`; |
| card.setAttribute('data-id', doc.name); |
| |
| card.innerHTML = ` |
| <div class="doc-card-title" title="${doc.title}">${doc.title}</div> |
| <div class="doc-card-category" title="${doc.category}">${doc.category}</div> |
| <div class="doc-card-footer"> |
| <span class="doc-card-name">${doc.name.substring(0, 18)}...</span> |
| <span class="doc-card-badge">${doc.mentionsCount} mentions</span> |
| </div> |
| `; |
| |
| card.addEventListener('click', () => { |
| selectDocument(doc.name); |
| }); |
| |
| elements.documentList.appendChild(card); |
| }); |
| } |
|
|
| |
| function switchTab(tabId) { |
| elements.tabButtons.forEach(btn => { |
| if (btn.getAttribute('data-tab') === tabId) { |
| btn.classList.add('active'); |
| } else { |
| btn.classList.remove('active'); |
| } |
| }); |
| |
| elements.tabContents.forEach(content => { |
| if (content.id === tabId) { |
| content.classList.add('active'); |
| } else { |
| content.classList.remove('active'); |
| } |
| }); |
| } |
|
|
| |
| function selectDocument(docName) { |
| state.selectedDocName = docName; |
| |
| document.querySelectorAll('.doc-card').forEach(card => { |
| if (card.getAttribute('data-id') === docName) { |
| card.classList.add('active'); |
| } else { |
| card.classList.remove('active'); |
| } |
| }); |
| |
| switchTab('document-dive-view'); |
| |
| const docMentions = state.allData.filter(item => item.document_name === docName); |
| if (docMentions.length === 0) return; |
| |
| const doc = docMentions[0]; |
| |
| elements.diveDocCategory.textContent = doc.corpus_category || 'Other Context'; |
| elements.diveDocTitle.textContent = doc.document_title || doc.document_name; |
| elements.diveDocId.textContent = doc.document_name; |
| elements.diveDocMentionCount.textContent = docMentions.length; |
| |
| |
| state.activePdfUrl = doc.pdf_url || ""; |
| |
| elements.diveMentionsList.innerHTML = ''; |
| docMentions.forEach((m, idx) => { |
| const hasCorrection = m.corrected_name && m.corrected_name.toLowerCase() !== m.mention_text.toLowerCase(); |
| const card = document.createElement('div'); |
| card.className = 'mention-card'; |
| card.setAttribute('data-mention-text', m.mention_text); |
| |
| card.innerHTML = ` |
| <div class="mention-card-header"> |
| <div class="mention-card-entity">${escapeHtml(m.normalized_name)}</div> |
| <div class="mention-card-meta"> |
| ${hasCorrection ? `<span class="badge badge-vague" style="text-decoration:line-through; font-family: monospace;">Raw: ${escapeHtml(m.mention_text)}</span>` : ''} |
| <span class="page-badge">Page ${m.page_number}</span> |
| </div> |
| </div> |
| <div class="mention-card-body"> |
| <p>${highlightEntityInSentence(m.context_sentence, m.mention_text, m.corrected_name)}</p> |
| ${m.data_use_impact ? ` |
| <div style="font-size: 0.8rem; margin-top: 8px; color: var(--text-muted); border-top: 1px solid var(--border-color); padding-top: 8px;"> |
| <i data-lucide="sparkles" style="width:14px; height:14px; margin-right:4px; display:inline-block; vertical-align:middle; color:var(--accent-primary);"></i> |
| <strong>Validated Impact:</strong> ${escapeHtml(m.data_use_impact)} |
| </div> |
| ` : ''} |
| </div> |
| <div class="mention-card-footer"> |
| <span><i data-lucide="info"></i> Impact Channel: <strong>${escapeHtml(m.downstream_impact_channel || 'Unspecified')}</strong></span> |
| <span><i data-lucide="tag"></i> Specificity: <strong style="text-transform: capitalize;">${escapeHtml(m.specificity || 'Unspecified')}</strong></span> |
| </div> |
| `; |
| |
| card.addEventListener('click', () => { |
| state.activePageNumber = m.page_number; |
| state.activeSearchTerm = m.mention_text; |
| renderViewerContent(); |
| |
| |
| document.querySelectorAll('.mention-card').forEach(c => c.classList.remove('active')); |
| card.classList.add('active'); |
| }); |
| |
| elements.diveMentionsList.appendChild(card); |
| }); |
| |
| |
| elements.diveBlankState.classList.add('hidden'); |
| elements.diveDetail.classList.remove('hidden'); |
| |
| |
| state.activeDocPages = []; |
| state.activePageNumber = docMentions[0].page_number; |
| state.activeSearchTerm = docMentions[0].mention_text; |
| |
| elements.pdfViewerFrameContainer.innerHTML = ` |
| <div style="display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-muted); gap:12px;"> |
| <div class="loading-spinner"></div> |
| <p>Loading document text pages...</p> |
| </div> |
| `; |
| |
| const handleLoadedPages = (data) => { |
| state.activeDocPages = data.sort((a, b) => a.page_number - b.page_number); |
| const maxPage = state.activeDocPages.reduce((max, p) => p.page_number > max ? p.page_number : max, 1); |
| elements.totalViewerPages.textContent = maxPage; |
| renderViewerContent(); |
| |
| |
| setTimeout(() => { |
| highlightActiveMentionCard(state.activeSearchTerm); |
| }, 100); |
| }; |
|
|
| if (state.docPagesCache && state.docPagesCache[docName]) { |
| handleLoadedPages(state.docPagesCache[docName]); |
| } else { |
| fetch(`data/validated/${docName}_validated.json`) |
| .then(response => { |
| if (!response.ok) throw new Error("Document validation data not found"); |
| return response.json(); |
| }) |
| .then(data => { |
| state.docPagesCache = state.docPagesCache || {}; |
| state.docPagesCache[docName] = data; |
| handleLoadedPages(data); |
| }) |
| .catch(err => { |
| console.error("Failed to load document validated JSON:", err); |
| elements.pdfViewerFrameContainer.innerHTML = ` |
| <div style="padding:2rem; text-align:center; color:var(--text-muted); display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; gap:8px;"> |
| <i data-lucide="alert-circle" style="width:36px;height:36px;color:var(--accent-primary);"></i> |
| <p>Failed to load full-text pages for this document.</p> |
| </div> |
| `; |
| lucide.createIcons(); |
| }); |
| } |
| |
| lucide.createIcons(); |
| } |
|
|
| |
| function renderMasterTable() { |
| const { currentPage, pageSize } = state.pagination; |
| const startIdx = (currentPage - 1) * pageSize; |
| const endIdx = startIdx + pageSize; |
| |
| const paginatedItems = state.filteredData.slice(startIdx, endIdx); |
| |
| elements.tableShowingCount.textContent = Math.min(endIdx, state.filteredData.length); |
| elements.tableTotalCount.textContent = state.filteredData.length; |
| |
| if (paginatedItems.length === 0) { |
| elements.tableBody.innerHTML = ` |
| <tr> |
| <td colspan="6" class="loading-placeholder"> |
| <i data-lucide="search-code" style="width:32px;height:32px;opacity:0.5;"></i> |
| <p>No dataset mentions match your active filter settings.</p> |
| </td> |
| </tr> |
| `; |
| lucide.createIcons(); |
| return; |
| } |
| |
| elements.tableBody.innerHTML = ''; |
| paginatedItems.forEach(item => { |
| const tr = document.createElement('tr'); |
| const docTitle = item.document_title || item.document_name; |
| |
| tr.innerHTML = ` |
| <td style="font-weight:600; max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(docTitle)}"> |
| <a href="#" class="table-doc-link" data-id="${item.document_name}">${escapeHtml(docTitle)}</a> |
| </td> |
| <td><strong>${item.page_number}</strong></td> |
| <td><span style="font-weight:700; color:var(--accent-primary);">${escapeHtml(item.normalized_name)}</span></td> |
| <td><code style="font-size:0.75rem; color:var(--text-muted);">${escapeHtml(item.mention_text)}</code></td> |
| <td><code style="font-size:0.8rem; padding: 2px 6px; background: var(--bg-surface); border-radius: 4px;">${escapeHtml(item.downstream_impact_channel || 'unspecified')}</code></td> |
| <td style="max-width: 320px; font-size: 0.8rem;"> |
| <div style="font-style: italic; color:var(--text-secondary);">${highlightEntityInSentence(item.context_sentence, item.mention_text, item.corrected_name)}</div> |
| ${item.data_use_impact ? ` |
| <div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 6px; padding-top: 4px; border-top: 1px dotted var(--border-color);"> |
| <i data-lucide="sparkles" style="width:12px; height:12px; margin-right:2px; display:inline-block; vertical-align:middle; color:var(--accent-primary);"></i> |
| <strong>Impact:</strong> ${escapeHtml(item.data_use_impact)} |
| </div> |
| ` : ''} |
| </td> |
| `; |
| |
| tr.querySelector('.table-doc-link').addEventListener('click', (e) => { |
| e.preventDefault(); |
| selectDocument(item.document_name); |
| }); |
| |
| elements.tableBody.appendChild(tr); |
| }); |
| |
| lucide.createIcons(); |
| } |
|
|
| |
| function renderPagination() { |
| const { currentPage, pageSize } = state.pagination; |
| const totalPages = Math.ceil(state.filteredData.length / pageSize); |
| |
| elements.paginationContainer.innerHTML = ''; |
| if (totalPages <= 1) return; |
| |
| const prevBtn = document.createElement('button'); |
| prevBtn.className = 'page-btn'; |
| prevBtn.disabled = currentPage === 1; |
| prevBtn.innerHTML = '<i data-lucide="chevron-left" style="width:14px;height:14px;"></i>'; |
| prevBtn.addEventListener('click', () => { |
| if (state.pagination.currentPage > 1) { |
| state.pagination.currentPage--; |
| renderMasterTable(); |
| renderPagination(); |
| } |
| }); |
| elements.paginationContainer.appendChild(prevBtn); |
| |
| const range = 2; |
| let startPage = Math.max(1, currentPage - range); |
| let endPage = Math.min(totalPages, currentPage + range); |
| |
| if (startPage > 1) { |
| const pageOne = document.createElement('button'); |
| pageOne.className = 'page-btn'; |
| pageOne.textContent = '1'; |
| pageOne.addEventListener('click', () => { |
| state.pagination.currentPage = 1; |
| renderMasterTable(); |
| renderPagination(); |
| }); |
| elements.paginationContainer.appendChild(pageOne); |
| |
| if (startPage > 2) { |
| const dots = document.createElement('span'); |
| dots.textContent = '...'; |
| dots.style.margin = '0 0.25rem'; |
| elements.paginationContainer.appendChild(dots); |
| } |
| } |
| |
| for (let i = startPage; i <= endPage; i++) { |
| const btn = document.createElement('button'); |
| btn.className = `page-btn ${currentPage === i ? 'active' : ''}`; |
| btn.textContent = i; |
| btn.addEventListener('click', () => { |
| state.pagination.currentPage = i; |
| renderMasterTable(); |
| renderPagination(); |
| }); |
| elements.paginationContainer.appendChild(btn); |
| } |
| |
| if (endPage < totalPages) { |
| if (endPage < totalPages - 1) { |
| const dots = document.createElement('span'); |
| dots.textContent = '...'; |
| dots.style.margin = '0 0.25rem'; |
| elements.paginationContainer.appendChild(dots); |
| } |
| |
| const lastPage = document.createElement('button'); |
| lastPage.className = 'page-btn'; |
| lastPage.textContent = totalPages; |
| lastPage.addEventListener('click', () => { |
| state.pagination.currentPage = totalPages; |
| renderMasterTable(); |
| renderPagination(); |
| }); |
| elements.paginationContainer.appendChild(lastPage); |
| } |
| |
| const nextBtn = document.createElement('button'); |
| nextBtn.className = 'page-btn'; |
| nextBtn.disabled = currentPage === totalPages; |
| nextBtn.innerHTML = '<i data-lucide="chevron-right" style="width:14px;height:14px;"></i>'; |
| nextBtn.addEventListener('click', () => { |
| if (state.pagination.currentPage < totalPages) { |
| state.pagination.currentPage++; |
| renderMasterTable(); |
| renderPagination(); |
| } |
| }); |
| elements.paginationContainer.appendChild(nextBtn); |
| |
| lucide.createIcons(); |
| } |
|
|
| |
| function renderWordCloud() { |
| const datasource = state.activeCloudWord ? state.allData : state.filteredData; |
| |
| const docSets = {}; |
| datasource.forEach(item => { |
| const name = item.normalized_name; |
| if (!name) return; |
| if (!docSets[name]) { |
| docSets[name] = new Set(); |
| } |
| docSets[name].add(item.document_name); |
| }); |
| |
| const freqs = {}; |
| Object.entries(docSets).forEach(([name, docSet]) => { |
| freqs[name] = docSet.size; |
| }); |
| |
| const sortedWords = Object.entries(freqs) |
| .sort((a, b) => b[1] - a[1]); |
| |
| if (sortedWords.length === 0) { |
| elements.wordCloud.innerHTML = ` |
| <div class="loading-placeholder"> |
| <i data-lucide="cloud-off"></i> |
| <p>No dataset names found to display.</p> |
| </div> |
| `; |
| lucide.createIcons(); |
| return; |
| } |
| |
| const counts = sortedWords.map(x => x[1]); |
| const maxVal = Math.max(...counts); |
| const minVal = Math.min(...counts); |
| |
| const shuffled = [...sortedWords]; |
| for (let i = shuffled.length - 1; i > 0; i--) { |
| const j = (i * 7 + 13) % shuffled.length; |
| const temp = shuffled[i]; |
| shuffled[i] = shuffled[j]; |
| shuffled[j] = temp; |
| } |
| |
| const darkColors = ['#009FDA', '#38bdf8', '#34d399', '#60a5fa', '#a78bfa', '#f472b6', '#fb7185']; |
| const lightColors = ['#0071BC', '#0284c7', '#059669', '#2563eb', '#7c3aed', '#db2777', '#e11d48']; |
| const colorsList = state.theme === 'dark' ? darkColors : lightColors; |
| |
| elements.wordCloud.innerHTML = ''; |
| shuffled.forEach(([word, count]) => { |
| const tag = document.createElement('span'); |
| tag.className = 'word-cloud-tag'; |
| if (state.activeCloudWord === word) { |
| tag.classList.add('active-filter'); |
| } |
| |
| let size = 1.1; |
| if (maxVal > minVal) { |
| size = 0.85 + ((count - minVal) / (maxVal - minVal)) * (2.1 - 0.85); |
| } |
| |
| tag.style.fontSize = `${size}rem`; |
| tag.style.fontWeight = count > (maxVal + minVal)/2 ? '700' : '500'; |
| |
| const colorIdx = word.length % colorsList.length; |
| tag.style.color = colorsList[colorIdx]; |
| |
| tag.textContent = word; |
| tag.title = `${count} occurrences`; |
| |
| tag.addEventListener('click', () => { |
| if (state.activeCloudWord === word) { |
| state.activeCloudWord = null; |
| } else { |
| state.activeCloudWord = word; |
| } |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| }); |
| |
| elements.wordCloud.appendChild(tag); |
| }); |
| } |
|
|
| |
| function initCharts() { |
| const mentionsCanvas = document.getElementById('chart-top-mentions'); |
| if (!mentionsCanvas) return; |
| |
| const tc = getThemeColors(); |
| |
| state.charts.topMentions = new Chart(mentionsCanvas, { |
| type: 'bar', |
| data: { |
| labels: [], |
| datasets: [{ |
| label: 'Documents', |
| data: [], |
| backgroundColor: 'rgba(0, 113, 188, 0.7)', |
| borderColor: '#0071BC', |
| borderWidth: 1, |
| borderRadius: 4 |
| }] |
| }, |
| options: { |
| indexAxis: 'y', |
| responsive: true, |
| maintainAspectRatio: false, |
| plugins: { |
| legend: { display: false } |
| }, |
| scales: { |
| x: { |
| ticks: { color: tc.text, font: { family: 'Inter', size: 10 } }, |
| grid: { color: tc.grid } |
| }, |
| y: { |
| ticks: { color: tc.text, font: { family: 'Inter', size: 10 } }, |
| grid: { display: false } |
| } |
| }, |
| onClick: (event, elements) => { |
| if (elements.length > 0) { |
| const idx = elements[0].index; |
| const label = state.charts.topMentions.data.labels[idx]; |
| |
| if (state.activeCloudWord === label) { |
| state.activeCloudWord = null; |
| } else { |
| state.activeCloudWord = label; |
| } |
| state.pagination.currentPage = 1; |
| applyFilters(); |
| } |
| } |
| } |
| }); |
| |
| updateCharts(); |
| } |
|
|
| |
| function updateChartsThemes() { |
| const tc = getThemeColors(); |
| const chart = state.charts.topMentions; |
| |
| if (chart) { |
| if (chart.options.scales) { |
| if (chart.options.scales.x) { |
| chart.options.scales.x.ticks.color = tc.text; |
| chart.options.scales.x.grid.color = tc.grid; |
| } |
| if (chart.options.scales.y) { |
| chart.options.scales.y.ticks.color = tc.text; |
| chart.options.scales.y.grid.color = tc.grid; |
| } |
| } |
| chart.update(); |
| } |
| |
| renderWordCloud(); |
| } |
|
|
| |
| function updateCharts() { |
| const chart = state.charts.topMentions; |
| if (!chart) return; |
| |
| const docSets = {}; |
| state.filteredData.forEach(item => { |
| const name = item.normalized_name; |
| if (!name) return; |
| if (!docSets[name]) { |
| docSets[name] = new Set(); |
| } |
| docSets[name].add(item.document_name); |
| }); |
| |
| const counts = {}; |
| Object.entries(docSets).forEach(([name, docSet]) => { |
| counts[name] = docSet.size; |
| }); |
| |
| const sorted = Object.entries(counts) |
| .sort((a, b) => b[1] - a[1]) |
| .slice(0, 15); |
| |
| chart.data.labels = sorted.map(x => x[0]); |
| chart.data.datasets[0].data = sorted.map(x => x[1]); |
| chart.update(); |
| } |
|
|
| |
| function highlightEntityInSentence(sentence, mention, corrected) { |
| if (!sentence) return ''; |
| |
| const escapeRegexStr = (str) => str.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'); |
| |
| const termsToTry = []; |
| if (corrected && corrected.trim()) termsToTry.push(corrected.trim()); |
| if (mention && mention.trim()) termsToTry.push(mention.trim()); |
| |
| let highlighted = escapeHtml(sentence); |
| |
| for (const term of termsToTry) { |
| if (!term) continue; |
| const cleanTerm = escapeRegexStr(term); |
| const matchRegex = new RegExp(`(${cleanTerm})`, 'gi'); |
| |
| const escapedTerm = escapeHtml(term); |
| const escapedMatchRegex = new RegExp(`(${escapeRegexStr(escapedTerm)})`, 'gi'); |
| |
| if (escapedMatchRegex.test(highlighted)) { |
| highlighted = highlighted.replace(escapedMatchRegex, '<mark>$1</mark>'); |
| return highlighted; |
| } |
| } |
| |
| if (mention) { |
| const firstWord = mention.split(/\s+/)[0]; |
| if (firstWord && firstWord.length > 2) { |
| const escFirstWord = escapeHtml(firstWord); |
| const regex = new RegExp(`(${escapeRegexStr(escFirstWord)})`, 'gi'); |
| if (regex.test(highlighted)) { |
| return highlighted.replace(regex, '<mark>$1</mark>'); |
| } |
| } |
| } |
| |
| return highlighted; |
| } |
|
|
| |
| function escapeHtml(unsafe) { |
| if (!unsafe) return ''; |
| return unsafe |
| .replace(/&/g, "&") |
| .replace(/</g, "<") |
| .replace(/>/g, ">") |
| .replace(/"/g, """) |
| .replace(/'/g, "'"); |
| } |
|
|
| |
| function exportToCSV() { |
| if (state.filteredData.length === 0) { |
| alert("There is no data to export."); |
| return; |
| } |
| |
| const headers = [ |
| "document_name", "document_title", "corpus_category", "page_number", |
| "mention_text", "corrected_name", "normalized_name", |
| "downstream_impact_channel", "data_use_impact", "context_sentence" |
| ]; |
| |
| const csvRows = [headers.join(",")]; |
| |
| state.filteredData.forEach(item => { |
| const values = headers.map(header => { |
| const val = item[header] === undefined || item[header] === null ? '' : String(item[header]); |
| const escaped = val.replace(/"/g, '""'); |
| return `"${escaped}"`; |
| }); |
| csvRows.push(values.join(",")); |
| }); |
| |
| const csvContent = "\ufeff" + csvRows.join("\n"); |
| const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); |
| |
| const link = document.createElement("a"); |
| if (link.download !== undefined) { |
| const url = URL.createObjectURL(blob); |
| link.setAttribute("href", url); |
| link.setAttribute("download", `fcv_data_mentions_export_${Date.now()}.csv`); |
| link.style.visibility = 'hidden'; |
| document.body.appendChild(link); |
| link.click(); |
| document.body.removeChild(link); |
| } |
| } |
|
|
| |
| function renderViewerContent() { |
| if (state.activeDocPages.length === 0) return; |
| |
| |
| const maxPage = getActiveDocMaxPage(); |
| elements.currentViewerPage.textContent = state.activePageNumber; |
| elements.btnPrevPage.disabled = state.activePageNumber <= 1; |
| elements.btnNextPage.disabled = state.activePageNumber >= maxPage; |
| |
| if (state.activePdfUrl) { |
| |
| let pdfFileUrl = state.activePdfUrl; |
| if (pdfFileUrl && !pdfFileUrl.startsWith('/') && !pdfFileUrl.startsWith('http')) { |
| pdfFileUrl = '/' + pdfFileUrl; |
| } |
| |
| const cleanSearch = normalizeSearchTerm(state.activeSearchTerm); |
| const hash = `#page=${state.activePageNumber}${cleanSearch ? `&search=${encodeURIComponent(cleanSearch)}&phrase=true` : ''}`; |
| const viewerUrl = `pdfjs/web/viewer.html?file=${encodeURIComponent(pdfFileUrl)}${hash}`; |
| |
| const existingIframe = document.getElementById('pdf-viewer'); |
| if (existingIframe && existingIframe.getAttribute('data-pdf-url') === pdfFileUrl) { |
| |
| try { |
| existingIframe.contentWindow.location.hash = hash; |
| } catch (e) { |
| existingIframe.src = viewerUrl; |
| } |
| } else { |
| |
| elements.pdfViewerFrameContainer.innerHTML = ` |
| <iframe |
| id="pdf-viewer" |
| data-pdf-url="${pdfFileUrl}" |
| src="${viewerUrl}" |
| class="pdf-frame" |
| title="PDF Viewer Pane" |
| allow="fullscreen" |
| ></iframe> |
| `; |
| } |
| } else { |
| |
| const pageData = state.activeDocPages.find(p => p.page_number === state.activePageNumber); |
| if (!pageData) { |
| elements.pdfViewerFrameContainer.innerHTML = `<div class="fallback-text-viewer">Page data not found for Page ${state.activePageNumber}.</div>`; |
| return; |
| } |
| |
| let text = pageData.input_text_preprocessed || pageData.input_text || ""; |
| |
| |
| const docMentions = state.allData.filter(item => item.document_name === state.selectedDocName && item.page_number === state.activePageNumber); |
| |
| |
| const sortedMentions = [...docMentions].sort((a, b) => b.mention_text.length - a.mention_text.length); |
| |
| let escapedText = escapeHtml(text); |
| |
| |
| const replacements = []; |
| sortedMentions.forEach((m, idx) => { |
| const term = m.mention_text; |
| const isCurrent = term.toLowerCase() === state.activeSearchTerm.toLowerCase(); |
| const placeholder = `___MENTION_TOKEN_VAL_${idx}___`; |
| |
| const escapedTerm = escapeHtml(term); |
| const regex = new RegExp(escapeRegExp(escapedTerm), 'gi'); |
| |
| escapedText = escapedText.replace(regex, (match) => { |
| replacements.push({ |
| placeholder: placeholder, |
| replacement: `<mark class="${isCurrent ? 'selected-highlight' : 'other-mention-highlight'}" onclick="window.setActiveSearchTerm('${escapeJsString(term)}')">${match}</mark>` |
| }); |
| return placeholder; |
| }); |
| }); |
| |
| |
| replacements.forEach(rep => { |
| escapedText = escapedText.replace(rep.placeholder, rep.replacement); |
| }); |
| |
| elements.pdfViewerFrameContainer.innerHTML = ` |
| <div class="fallback-text-viewer" id="fallback-text-viewer"> |
| ${escapedText} |
| </div> |
| `; |
| |
| |
| setTimeout(() => { |
| const selectedMark = document.querySelector('.selected-highlight'); |
| if (selectedMark) { |
| selectedMark.scrollIntoView({ behavior: 'smooth', block: 'center' }); |
| } |
| }, 80); |
| } |
| } |
|
|
| |
| function highlightActiveMentionCard(term) { |
| document.querySelectorAll('.mention-card').forEach(card => { |
| if (card.getAttribute('data-mention-text') === term) { |
| card.classList.add('active'); |
| card.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| } else { |
| card.classList.remove('active'); |
| } |
| }); |
| } |
|
|
| |
| window.setActiveSearchTerm = function(term) { |
| state.activeSearchTerm = term; |
| renderViewerContent(); |
| highlightActiveMentionCard(term); |
| }; |
|
|
| |
| function escapeRegExp(string) { |
| return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
| } |
|
|
| |
| function escapeJsString(str) { |
| return str.replace(/'/g, "\\'").replace(/"/g, '\\"'); |
| } |
|
|