jaeswift-website/js/crimescene.js

1023 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ═══════════════════════════════════════════════════════
CRIME SCENE — Criminal Case Archive
SPA Engine with hash routing & PDF.js viewer
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
const ROOT = document.getElementById('crimesceneRoot');
const PDF_BASE = '/crimescene/docs';
let DATA = null;
// ─── Data Loading ────────────────────────────────────
async function loadData() {
try {
const r = await fetch('/api/crimescene');
if (!r.ok) throw new Error(`HTTP ${r.status}`);
DATA = await r.json();
} catch (e) {
console.error('CRIMESCENE: data load failed', e);
ROOT.innerHTML = `<div class="prop-empty">
<div class="prop-empty-icon">⚠️</div>
<div class="prop-empty-title">CASE DATABASE OFFLINE</div>
<div class="prop-empty-text">Failed to load criminal case index. ${e.message}</div>
</div>`;
}
}
// ─── Helpers ──────────────────────────────────────────
function countDocuments(obj) {
if (!obj) return 0;
if (Array.isArray(obj.documents)) return obj.documents.length;
if (Array.isArray(obj.collections)) {
return obj.collections.reduce((s, c) => s + (c.documents ? c.documents.length : 0), 0);
}
if (Array.isArray(obj.countries)) {
return obj.countries.reduce((s, cn) => s + countDocuments(cn), 0);
}
return 0;
}
function countCollections(obj) {
if (!obj) return 0;
if (Array.isArray(obj.collections)) return obj.collections.length;
if (Array.isArray(obj.countries)) {
return obj.countries.reduce((s, cn) => s + (cn.collections ? cn.collections.length : 0), 0);
}
return 0;
}
function countCountries(cat) {
return cat.countries ? cat.countries.length : 0;
}
function totalStats() {
if (!DATA || !DATA.categories) return { cats: 0, countries: 0, collections: 0, docs: 0 };
let countries = new Set();
let collections = 0;
let docs = 0;
DATA.categories.forEach(cat => {
(cat.countries || []).forEach(cn => {
countries.add(cn.code);
(cn.collections || []).forEach(col => {
collections++;
docs += (col.documents || []).length;
});
});
});
return { cats: DATA.categories.length, countries: countries.size, collections, docs };
}
function findCategory(id) {
return DATA.categories.find(c => c.id === id);
}
function findCountry(cat, code) {
return (cat.countries || []).find(c => c.code === code);
}
function findCollection(country, colId) {
return (country.collections || []).find(c => c.id === colId);
}
function findDocument(collection, docId) {
return (collection.documents || []).find(d => d.id === docId);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// ─── Breadcrumb Builder ───────────────────────────────
function breadcrumb(parts) {
// parts: [{label, hash?}, ...]
let html = '<div class="prop-breadcrumb">';
parts.forEach((p, i) => {
if (i > 0) html += '<span class="sep">/</span>';
if (p.hash) {
html += `<a onclick="location.hash='${p.hash}'">${esc(p.label)}</a>`;
} else {
html += `<span class="current">${esc(p.label)}</span>`;
}
});
html += '</div>';
return html;
}
// ─── View: Categories (Home) ──────────────────────────
function renderCategories() {
const stats = totalStats();
let html = '';
html += `<div class="prop-classification">CRIMINAL CASE ARCHIVE // ACCESS GRANTED</div>`;
html += `<div class="prop-stats-bar">
<div class="prop-stat"><span class="prop-stat-label">CATEGORIES</span><span class="prop-stat-value">${stats.cats}</span></div>
<div class="prop-stat"><span class="prop-stat-label">COUNTRIES</span><span class="prop-stat-value amber">${stats.countries}</span></div>
<div class="prop-stat"><span class="prop-stat-label">COLLECTIONS</span><span class="prop-stat-value">${stats.collections}</span></div>
<div class="prop-stat"><span class="prop-stat-label">DOCUMENTS</span><span class="prop-stat-value red">${stats.docs}</span></div>
</div>`;
html += breadcrumb([{ label: 'CRIME SCENE' }]);
// ─── Global Search Bar ────────────────────────────────
html += `<div class="ur-search-container">
<div class="ur-search-bar">
<span class="ur-search-icon">⌕</span>
<input type="text" id="csSearchInput" class="ur-search-input" placeholder="SEARCH CRIMINAL ARCHIVES..." autocomplete="off" spellcheck="false">
<span class="ur-search-clear" id="csSearchClear" style="display:none;">✕</span>
</div>
<div class="ur-search-status" id="csSearchStatus"></div>
<div class="ur-search-results" id="csSearchResults"></div>
</div>`;
html += '<div id="csCategorySection">';
html += '<div class="prop-section-label">SELECT CATEGORY</div>';
DATA.categories.forEach(cat => {
const nCountries = countCountries(cat);
const nDocs = countDocuments(cat);
const nCol = countCollections(cat);
html += `<div class="prop-card" onclick="location.hash='category/${cat.id}'">
<div class="prop-card-header">
<div class="prop-card-name">${esc(cat.name)}</div>
<div class="prop-card-icon">${cat.icon || '📁'}</div>
</div>
<div class="prop-card-desc">${esc(cat.description)}</div>
<div class="prop-card-meta">
<span>${nCountries} COUNTR${nCountries === 1 ? 'Y' : 'IES'} · ${nCol} COLLECTION${nCol === 1 ? '' : 'S'}</span>
<span class="prop-card-count">${nDocs} DOC${nDocs === 1 ? '' : 'S'}</span>
</div>
</div>`;
});
html += '</div>';
html += '</div>'; // close #csCategorySection
ROOT.innerHTML = html;
// ─── Bind search events ────────────────────────────────
initSearchListeners();
}
// ─── View: Category (Country Selector + Collections) ──
function renderCategory(catId, activeCountry) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
let html = '';
html += `<div class="prop-classification">CASE FILE // ${esc(cat.name)} // ACCESS GRANTED</div>`;
html += breadcrumb([
{ label: 'CRIME SCENE', hash: '' },
{ label: cat.name }
]);
// Country selector
if (cat.countries && cat.countries.length > 0) {
html += '<div class="prop-section-label">SELECT COUNTRY</div>';
html += '<div class="prop-country-grid">';
cat.countries.forEach(cn => {
const nCol = cn.collections ? cn.collections.length : 0;
const isActive = activeCountry === cn.code;
html += `<div class="prop-country-btn${isActive ? ' active' : ''}" onclick="location.hash='country/${catId}/${cn.code}'">
<span class="prop-country-flag">${cn.flag || ''}</span>
<span class="prop-country-label">${esc(cn.name)}</span>
<span class="prop-country-count">[${nCol}]</span>
</div>`;
});
html += '</div>';
// If a country is selected, show its collections
if (activeCountry) {
const country = findCountry(cat, activeCountry);
if (country) {
html += renderCountryCollections(cat, country);
}
} else {
// Show all collections from all countries
html += '<div class="prop-section-label">ALL COLLECTIONS</div>';
let hasCols = false;
cat.countries.forEach(cn => {
if (cn.collections && cn.collections.length > 0) {
hasCols = true;
}
});
if (hasCols) {
html += '<div class="prop-collection-grid">';
cat.countries.forEach(cn => {
(cn.collections || []).forEach(col => {
html += renderCollectionCard(catId, cn.code, col, cn.flag);
});
});
html += '</div>';
} else {
html += renderEmpty('NO COLLECTIONS YET', 'Collections will appear here once documents are added to this category.');
}
}
} else {
html += renderEmpty('NO COUNTRIES REGISTERED', 'No country data available for this category yet.');
}
ROOT.innerHTML = html;
}
function renderCountryCollections(cat, country) {
let html = '';
html += `<div class="prop-section-label">${country.flag || ''} ${esc(country.name)} — COLLECTIONS</div>`;
if (country.collections && country.collections.length > 0) {
html += '<div class="prop-collection-grid">';
country.collections.forEach(col => {
html += renderCollectionCard(cat.id, country.code, col);
});
html += '</div>';
} else {
html += renderEmpty('NO COLLECTIONS', 'No document collections available for this country yet.');
}
return html;
}
function renderCollectionCard(catId, countryCode, col, flag) {
const nDocs = col.documents ? col.documents.length : 0;
return `<div class="prop-collection-card" onclick="location.hash='collection/${catId}/${countryCode}/${col.id}'">
<div class="prop-collection-name">${flag ? flag + ' ' : ''}${esc(col.name)}</div>
<div class="prop-collection-year">${esc(col.year)}</div>
<div class="prop-collection-desc">${esc(col.description)}</div>
<div class="prop-collection-footer">
<span class="prop-collection-source">SRC: ${esc(col.source)}</span>
<span class="prop-collection-docs">${nDocs} DOCUMENT${nDocs === 1 ? '' : 'S'}</span>
</div>
</div>`;
}
// ─── View: Collection (Documents) ─────────────────────
function renderCollection(catId, countryCode, colId) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
const country = findCountry(cat, countryCode);
if (!country) return renderNotFound('Country not found');
const col = findCollection(country, colId);
if (!col) return renderNotFound('Collection not found');
let html = '';
html += `<div class="prop-classification">CASE FILE // ${esc(col.name).toUpperCase()} // ACCESS GRANTED</div>`;
html += breadcrumb([
{ label: 'CRIME SCENE', hash: '' },
{ label: cat.name, hash: `category/${catId}` },
{ label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` },
{ label: col.name }
]);
// Detail header
html += `<div class="prop-detail-header">
<div class="prop-detail-title">${esc(col.name)}</div>
<div class="prop-detail-year">${esc(col.year)}</div>
<div class="prop-detail-desc">${esc(col.description)}</div>
<div class="prop-detail-source">SOURCE: <span>${esc(col.source)}</span></div>
</div>`;
// Documents
if (col.documents && col.documents.length > 0) {
html += `<div class="prop-section-label">DOCUMENTS [${col.documents.length}]</div>`;
html += '<div class="prop-doc-list">';
col.documents.forEach(doc => {
html += `<div class="prop-doc-item" onclick="location.hash='doc/${catId}/${countryCode}/${colId}/${doc.id}'">
<div class="prop-doc-icon">📄</div>
<div class="prop-doc-info">
<div class="prop-doc-title">${esc(doc.title)}</div>
<div class="prop-doc-desc">${esc(doc.description)}</div>
<div class="prop-doc-meta">
<span>PAGES: ${doc.pages || '?'}</span>
<span>RELEASED: ${esc(doc.date_released || 'UNKNOWN')}</span>
<span>${esc(doc.filename)}</span>
</div>
</div>
<div class="prop-doc-badge">VIEW</div>
</div>`;
});
html += '</div>';
} else {
html += renderEmpty('NO DOCUMENTS UPLOADED YET', 'This collection has been catalogued but documents have not yet been uploaded to the archive.');
}
ROOT.innerHTML = html;
}
// ─── View: Document (PDF Viewer) ──────────────────────
function renderDocument(catId, countryCode, colId, docId) {
const cat = findCategory(catId);
if (!cat) return renderNotFound('Category not found');
const country = findCountry(cat, countryCode);
if (!country) return renderNotFound('Country not found');
const col = findCollection(country, colId);
if (!col) return renderNotFound('Collection not found');
const doc = findDocument(col, docId);
if (!doc) return renderNotFound('Document not found');
const pdfUrl = `${PDF_BASE}/${catId}/${countryCode}/${colId}/${doc.filename}`;
let html = '';
html += `<div class="prop-classification">CASE FILE // ${esc(doc.title).toUpperCase()}</div>`;
html += breadcrumb([
{ label: 'CRIME SCENE', hash: '' },
{ label: cat.name, hash: `category/${catId}` },
{ label: `${country.flag || ''} ${country.name}`, hash: `country/${catId}/${countryCode}` },
{ label: col.name, hash: `collection/${catId}/${countryCode}/${colId}` },
{ label: doc.title }
]);
// Viewer
html += `<div class="prop-viewer-container" id="pdfViewerContainer">
<div class="prop-viewer-toolbar">
<div class="prop-viewer-title">${esc(doc.title)}</div>
<div class="prop-viewer-controls">
<button class="prop-viewer-btn" id="pdfPrev" title="Previous page">◂ PREV</button>
<span class="prop-viewer-page-info" id="pdfPageInfo">— / —</span>
<button class="prop-viewer-btn" id="pdfNext" title="Next page">NEXT ▸</button>
<button class="prop-viewer-btn" id="pdfZoomOut" title="Zoom out"></button>
<button class="prop-viewer-btn" id="pdfZoomIn" title="Zoom in">+</button>
<button class="prop-viewer-btn" id="pdfFit" title="Fit width">FIT</button>
<button class="prop-viewer-btn" id="pdfSearchToggle" title="Search (Ctrl+F)">🔍 FIND</button>
<a class="prop-viewer-download" href="${pdfUrl}" target="_blank" download>⬇ DOWNLOAD</a>
</div>
</div>
<div class="prop-search-bar" id="pdfSearchBar">
<div class="prop-search-inner">
<input type="text" class="prop-search-input" id="pdfSearchInput" placeholder="SEARCH DOCUMENT..." autocomplete="off" spellcheck="false">
<button class="prop-search-btn" id="pdfSearchCaseBtn" title="Case sensitive">Aa</button>
<span class="prop-search-info" id="pdfSearchInfo"></span>
<button class="prop-search-btn" id="pdfSearchPrev" title="Previous match (Shift+Enter)">▲</button>
<button class="prop-search-btn" id="pdfSearchNext" title="Next match (Enter)">▼</button>
<button class="prop-search-btn prop-search-close" id="pdfSearchClose" title="Close search (Esc)">✕</button>
</div>
</div>
<div class="prop-viewer-canvas-wrap" id="pdfCanvasWrap">
<div class="prop-viewer-loading" id="pdfLoading">RETRIEVING CASE FILE...</div>
</div>
</div>`;
// Doc info
html += `<div class="prop-detail-header">
<div class="prop-detail-title">${esc(doc.title)}</div>
<div class="prop-detail-desc">${esc(doc.description)}</div>
<div class="prop-doc-meta" style="margin-top:0.5rem">
<span>PAGES: ${doc.pages || '?'}</span>
<span>RELEASED: ${esc(doc.date_released || 'UNKNOWN')}</span>
<span>FILE: ${esc(doc.filename)}</span>
</div>
</div>`;
ROOT.innerHTML = html;
// Init PDF viewer
initPdfViewer(pdfUrl);
}
// ─── PDF.js Viewer ───────────────────────────────────
let pdfDoc = null;
let pdfPage = 1;
let pdfScale = 1.5;
let pdfRendering = false;
let pdfPending = null;
let pdfAllText = [];
let pdfSearchActive = false;
let pdfSearchQuery = '';
let pdfSearchCaseSensitive = false;
let pdfSearchMatches = [];
let pdfSearchCurrentIdx = -1;
async function initPdfViewer(url) {
const canvasWrap = document.getElementById('pdfCanvasWrap');
const loadingEl = document.getElementById('pdfLoading');
const pageInfo = document.getElementById('pdfPageInfo');
// Check if pdf.js is loaded
if (typeof pdfjsLib === 'undefined') {
canvasWrap.innerHTML = `<div class="prop-viewer-error">
<div class="prop-viewer-error-icon">⚠️</div>
<div class="prop-viewer-error-text">PDF.js library not loaded</div>
<a class="prop-viewer-download" href="${url}" target="_blank" download>⬇ DOWNLOAD PDF DIRECTLY</a>
</div>`;
return;
}
try {
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
const loadingTask = pdfjsLib.getDocument(url);
pdfDoc = await loadingTask.promise;
pdfPage = 1;
loadingEl.remove();
// Create page container with canvas
const pageContainer = document.createElement('div');
pageContainer.id = 'pdfPageContainer';
pageContainer.className = 'prop-page-container';
const canvas = document.createElement('canvas');
canvas.id = 'pdfCanvas';
pageContainer.appendChild(canvas);
canvasWrap.appendChild(pageContainer);
updatePageInfo();
renderPdfPage();
// Controls
document.getElementById('pdfPrev').addEventListener('click', () => {
if (pdfPage <= 1) return;
pdfPage--;
queueRender();
});
document.getElementById('pdfNext').addEventListener('click', () => {
if (pdfPage >= pdfDoc.numPages) return;
pdfPage++;
queueRender();
});
document.getElementById('pdfZoomIn').addEventListener('click', () => {
pdfScale = Math.min(pdfScale + 0.25, 4);
queueRender();
});
document.getElementById('pdfZoomOut').addEventListener('click', () => {
pdfScale = Math.max(pdfScale - 0.25, 0.5);
queueRender();
});
document.getElementById('pdfFit').addEventListener('click', () => {
pdfScale = 1.5;
queueRender();
});
// Extract all page text for search
extractAllText();
// Initialize search controls
initSearchControls();
} catch (e) {
console.error('PDF load failed:', e);
canvasWrap.innerHTML = `<div class="prop-viewer-error">
<div class="prop-viewer-error-icon">⚠️</div>
<div class="prop-viewer-error-text">DOCUMENT LOAD FAILED — ${esc(e.message || 'File may not exist yet')}</div>
<a class="prop-viewer-download" href="${url}" target="_blank" download>⬇ ATTEMPT DIRECT DOWNLOAD</a>
</div>`;
}
}
function updatePageInfo() {
const el = document.getElementById('pdfPageInfo');
if (el && pdfDoc) {
el.textContent = `${pdfPage} / ${pdfDoc.numPages}`;
}
}
async function renderPdfPage() {
if (!pdfDoc) return;
pdfRendering = true;
const page = await pdfDoc.getPage(pdfPage);
const viewport = page.getViewport({ scale: pdfScale });
const canvas = document.getElementById('pdfCanvas');
if (!canvas) { pdfRendering = false; return; }
const ctx = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
// Update page container size to match viewport
const pageContainer = document.getElementById('pdfPageContainer');
if (pageContainer) {
pageContainer.style.width = viewport.width + 'px';
pageContainer.style.height = viewport.height + 'px';
}
await page.render({ canvasContext: ctx, viewport: viewport }).promise;
// Render text layer for search and text selection
await renderPageTextLayer(page, viewport);
// Highlight search matches on this page
highlightCurrentPageMatches();
pdfRendering = false;
updatePageInfo();
if (pdfPending !== null) {
renderPdfPage();
pdfPending = null;
}
}
function queueRender() {
if (pdfRendering) {
pdfPending = pdfPage;
} else {
renderPdfPage();
}
}
// ─── Text Layer Rendering ────────────────────────────
async function renderPageTextLayer(page, viewport) {
const existing = document.getElementById('pdfTextLayer');
if (existing) existing.remove();
const pageContainer = document.getElementById('pdfPageContainer');
if (!pageContainer) return;
const textContent = await page.getTextContent();
const textLayerDiv = document.createElement('div');
textLayerDiv.id = 'pdfTextLayer';
textLayerDiv.className = 'prop-text-layer';
textLayerDiv.style.width = viewport.width + 'px';
textLayerDiv.style.height = viewport.height + 'px';
// Try pdf.js built-in renderTextLayer, fallback to manual
if (typeof pdfjsLib.renderTextLayer === 'function') {
try {
const task = pdfjsLib.renderTextLayer({
textContentSource: textContent,
container: textLayerDiv,
viewport: viewport,
});
await task.promise;
} catch (err) {
console.warn('renderTextLayer failed, using fallback:', err);
buildTextLayerFallback(textContent, viewport, textLayerDiv);
}
} else {
buildTextLayerFallback(textContent, viewport, textLayerDiv);
}
// Tag each span with its item index for search highlighting
let itemIdx = 0;
for (const child of textLayerDiv.childNodes) {
if (child.nodeName === 'SPAN') {
child.dataset.itemIdx = itemIdx;
itemIdx++;
}
}
pageContainer.appendChild(textLayerDiv);
}
function buildTextLayerFallback(textContent, viewport, container) {
textContent.items.forEach((item, idx) => {
if (!item.str) return;
const span = document.createElement('span');
span.textContent = item.str;
const tx = item.transform;
const fontHeight = Math.sqrt(tx[2] * tx[2] + tx[3] * tx[3]);
const s = viewport.scale;
span.style.fontSize = (fontHeight * s) + 'px';
span.style.fontFamily = 'sans-serif';
span.style.left = (tx[4] * s) + 'px';
span.style.top = (viewport.height - tx[5] * s - fontHeight * s) + 'px';
span.style.position = 'absolute';
span.style.color = 'transparent';
span.style.whiteSpace = 'pre';
container.appendChild(span);
});
}
// ─── Text Extraction for Search ──────────────────────
async function extractAllText() {
pdfAllText = [];
if (!pdfDoc) return;
for (let i = 1; i <= pdfDoc.numPages; i++) {
const page = await pdfDoc.getPage(i);
const tc = await page.getTextContent();
let fullText = '';
const itemOffsets = [];
tc.items.forEach((item, idx) => {
const start = fullText.length;
fullText += item.str;
itemOffsets.push({ start: start, end: fullText.length, itemIdx: idx });
if (item.hasEOL) fullText += '\n';
});
pdfAllText.push({ pageNum: i, text: fullText, items: tc.items, itemOffsets: itemOffsets });
}
console.log('CRIMESCENE: Extracted text from ' + pdfAllText.length + ' pages');
}
// ─── Search Controls Init ────────────────────────────
function initSearchControls() {
const searchInput = document.getElementById('pdfSearchInput');
const searchPrev = document.getElementById('pdfSearchPrev');
const searchNext = document.getElementById('pdfSearchNext');
const searchClose = document.getElementById('pdfSearchClose');
const searchToggle = document.getElementById('pdfSearchToggle');
const searchCaseBtn = document.getElementById('pdfSearchCaseBtn');
if (!searchInput) return;
searchToggle.addEventListener('click', () => toggleSearchBar());
searchClose.addEventListener('click', () => closeSearchBar());
let searchTimeout = null;
searchInput.addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
pdfSearchQuery = searchInput.value;
performSearch();
}, 200);
});
searchInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (e.shiftKey) prevMatch(); else nextMatch();
}
if (e.key === 'Escape') {
e.preventDefault();
closeSearchBar();
}
});
searchPrev.addEventListener('click', () => prevMatch());
searchNext.addEventListener('click', () => nextMatch());
searchCaseBtn.addEventListener('click', () => {
pdfSearchCaseSensitive = !pdfSearchCaseSensitive;
searchCaseBtn.classList.toggle('active', pdfSearchCaseSensitive);
performSearch();
});
// Ctrl+F shortcut — only when PDF viewer is present
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
if (document.getElementById('pdfViewerContainer')) {
e.preventDefault();
toggleSearchBar(true);
}
}
});
}
function toggleSearchBar(forceOpen) {
const searchBar = document.getElementById('pdfSearchBar');
const searchInput = document.getElementById('pdfSearchInput');
if (!searchBar) return;
if (forceOpen || !pdfSearchActive) {
searchBar.classList.add('active');
pdfSearchActive = true;
setTimeout(() => searchInput && searchInput.focus(), 50);
} else {
closeSearchBar();
}
}
function closeSearchBar() {
const searchBar = document.getElementById('pdfSearchBar');
if (!searchBar) return;
searchBar.classList.remove('active');
pdfSearchActive = false;
pdfSearchQuery = '';
pdfSearchMatches = [];
pdfSearchCurrentIdx = -1;
const searchInput = document.getElementById('pdfSearchInput');
if (searchInput) searchInput.value = '';
updateSearchInfo();
clearHighlights();
}
function performSearch() {
pdfSearchMatches = [];
pdfSearchCurrentIdx = -1;
const query = pdfSearchQuery.trim();
if (!query || query.length === 0) {
updateSearchInfo();
clearHighlights();
return;
}
const cs = pdfSearchCaseSensitive;
const q = cs ? query : query.toLowerCase();
pdfAllText.forEach(pageData => {
const text = cs ? pageData.text : pageData.text.toLowerCase();
let pos = 0;
while ((pos = text.indexOf(q, pos)) !== -1) {
const matchStart = pos;
const matchEnd = pos + q.length;
const affectedItems = pageData.itemOffsets.filter(
io => io.end > matchStart && io.start < matchEnd
);
pdfSearchMatches.push({
page: pageData.pageNum,
charStart: matchStart,
charEnd: matchEnd,
itemIndices: affectedItems.map(io => io.itemIdx)
});
pos += 1;
}
});
pdfSearchCurrentIdx = pdfSearchMatches.length > 0 ? 0 : -1;
updateSearchInfo();
if (pdfSearchMatches.length > 0) {
goToMatch(0);
} else {
clearHighlights();
}
}
function goToMatch(idx) {
if (idx < 0 || idx >= pdfSearchMatches.length) return;
pdfSearchCurrentIdx = idx;
const match = pdfSearchMatches[idx];
if (match.page !== pdfPage) {
pdfPage = match.page;
queueRender();
} else {
highlightCurrentPageMatches();
}
updateSearchInfo();
}
function nextMatch() {
if (pdfSearchMatches.length === 0) return;
goToMatch((pdfSearchCurrentIdx + 1) % pdfSearchMatches.length);
}
function prevMatch() {
if (pdfSearchMatches.length === 0) return;
goToMatch((pdfSearchCurrentIdx - 1 + pdfSearchMatches.length) % pdfSearchMatches.length);
}
function highlightCurrentPageMatches() {
clearHighlights();
const textLayer = document.getElementById('pdfTextLayer');
if (!textLayer) return;
const pageMatches = pdfSearchMatches.filter(m => m.page === pdfPage);
if (pageMatches.length === 0) return;
pageMatches.forEach(match => {
const globalIdx = pdfSearchMatches.indexOf(match);
const isActive = globalIdx === pdfSearchCurrentIdx;
match.itemIndices.forEach(itemIdx => {
const span = textLayer.querySelector('span[data-item-idx="' + itemIdx + '"]');
if (span) {
span.classList.add('prop-search-hl');
if (isActive) {
span.classList.add('prop-search-hl-active');
const canvasWrap = document.getElementById('pdfCanvasWrap');
if (canvasWrap) {
const sr = span.getBoundingClientRect();
const wr = canvasWrap.getBoundingClientRect();
const offset = sr.top - wr.top - wr.height / 3;
canvasWrap.scrollBy({ top: offset, behavior: 'smooth' });
}
}
}
});
});
}
function clearHighlights() {
const tl = document.getElementById('pdfTextLayer');
if (!tl) return;
tl.querySelectorAll('.prop-search-hl').forEach(el => {
el.classList.remove('prop-search-hl', 'prop-search-hl-active');
});
}
function updateSearchInfo() {
const info = document.getElementById('pdfSearchInfo');
if (!info) return;
if (pdfSearchMatches.length === 0) {
if (pdfSearchQuery && pdfSearchQuery.trim().length > 0) {
info.textContent = 'NO MATCHES';
info.className = 'prop-search-info no-match';
} else {
info.textContent = '';
info.className = 'prop-search-info';
}
} else {
info.textContent = (pdfSearchCurrentIdx + 1) + ' OF ' + pdfSearchMatches.length;
info.className = 'prop-search-info';
}
}
// ─── Global Archive Search ─────────────────────────────
let searchDebounceTimer = null;
function initSearchListeners() {
const input = document.getElementById('csSearchInput');
const clearBtn = document.getElementById('csSearchClear');
if (!input) return;
input.addEventListener('input', () => {
clearTimeout(searchDebounceTimer);
const val = input.value.trim();
clearBtn.style.display = val.length > 0 ? 'inline-block' : 'none';
searchDebounceTimer = setTimeout(() => {
globalArchiveSearch(val);
}, 300);
});
clearBtn.addEventListener('click', () => {
input.value = '';
clearBtn.style.display = 'none';
globalArchiveSearch('');
input.focus();
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
input.value = '';
clearBtn.style.display = 'none';
globalArchiveSearch('');
}
});
}
function globalArchiveSearch(query) {
const resultsEl = document.getElementById('csSearchResults');
const statusEl = document.getElementById('csSearchStatus');
const catSection = document.getElementById('csCategorySection');
if (!resultsEl || !statusEl) return;
if (!query || query.length === 0) {
resultsEl.innerHTML = '';
statusEl.innerHTML = '';
if (catSection) catSection.style.display = '';
return;
}
const q = query.toLowerCase();
const results = [];
if (!DATA || !DATA.categories) return;
DATA.categories.forEach(cat => {
(cat.countries || []).forEach(cn => {
(cn.collections || []).forEach(col => {
(col.documents || []).forEach(doc => {
const fields = [
doc.title || '',
doc.description || '',
col.name || '',
cat.name || '',
cn.name || '',
doc.year ? String(doc.year) : '',
doc.classification || ''
];
const combined = fields.join(' ').toLowerCase();
if (combined.includes(q)) {
results.push({
doc,
catName: cat.name,
catId: cat.id,
catIcon: cat.icon || '📁',
countryName: cn.name,
countryCode: cn.code,
countryFlag: cn.flag || '',
colName: col.name,
colId: col.id
});
}
});
// Also match collection-level (even if no docs match)
const colFields = [col.name || '', col.description || '', cat.name || '', cn.name || ''].join(' ').toLowerCase();
if (colFields.includes(q) && !results.find(r => r.colId === col.id && r.catId === cat.id && r.countryCode === cn.code && !r.doc.id)) {
// Check if we already added docs from this collection
const hasDocResults = results.some(r => r.colId === col.id && r.catId === cat.id && r.countryCode === cn.code);
if (!hasDocResults) {
results.push({
doc: { title: col.name, description: col.description || 'Collection in ' + cat.name, id: '__col__' },
catName: cat.name,
catId: cat.id,
catIcon: cat.icon || '📁',
countryName: cn.name,
countryCode: cn.code,
countryFlag: cn.flag || '',
colName: col.name,
colId: col.id,
isCollection: true
});
}
}
});
});
});
// Hide categories when searching
if (catSection) catSection.style.display = 'none';
if (results.length === 0) {
statusEl.innerHTML = '<span class="ur-search-none">NO MATCHING CASE FILES // REFINE SEARCH PARAMETERS</span>';
resultsEl.innerHTML = '';
return;
}
statusEl.innerHTML = `<span class="ur-search-count">FOUND ${results.length} MATCHING CASE FILE${results.length === 1 ? '' : 'S'}</span>`;
let html = '<div class="ur-results-grid">';
results.forEach(r => {
const title = highlightMatch(esc(r.doc.title || 'Untitled'), q);
const desc = r.doc.description ? highlightMatch(esc(truncateText(r.doc.description, 160)), q) : '';
const hash = r.isCollection
? `country/${r.catId}/${r.countryCode}`
: `doc/${r.catId}/${r.countryCode}/${r.colId}/${r.doc.id}`;
html += `<div class="ur-result-card" onclick="location.hash='${hash}'">
<div class="ur-result-header">
<span class="ur-result-icon">${r.catIcon}</span>
<span class="ur-result-title">${title}</span>
</div>
<div class="ur-result-meta">
<span class="ur-result-badge">${esc(r.catName)}</span>
<span class="ur-result-badge ur-result-badge-country">${r.countryFlag} ${esc(r.countryName)}</span>
<span class="ur-result-badge ur-result-badge-col">${esc(r.colName)}</span>
</div>
${desc ? `<div class="ur-result-desc">${desc}</div>` : ''}
</div>`;
});
html += '</div>';
resultsEl.innerHTML = html;
}
function highlightMatch(text, query) {
if (!query) return text;
const regex = new RegExp('(' + query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return text.replace(regex, '<mark class="ur-highlight">$1</mark>');
}
function truncateText(text, maxLen) {
if (text.length <= maxLen) return text;
return text.substring(0, maxLen).replace(/\s+\S*$/, '') + '...';
}
// ─── Not Found / Empty ────────────────────────────────
function renderNotFound(msg) {
ROOT.innerHTML = `<div class="prop-empty">
<div class="prop-empty-icon">⚠️</div>
<div class="prop-empty-title">${esc(msg)}</div>
<div class="prop-empty-text">The requested case file could not be located in the criminal archive.</div>
</div>
<div style="text-align:center;margin-top:1rem">
<a class="prop-back-btn" onclick="location.hash=''">◂ RETURN TO CASE INDEX</a>
</div>`;
}
function renderEmpty(title, text) {
return `<div class="prop-empty">
<div class="prop-empty-icon">◎</div>
<div class="prop-empty-title">${esc(title)}</div>
<div class="prop-empty-text">${esc(text)}</div>
</div>`;
}
// ─── Router ───────────────────────────────────────────
function route() {
if (!DATA) return;
// Reset PDF state
pdfDoc = null;
pdfPage = 1;
pdfRendering = false;
pdfPending = null;
pdfAllText = [];
pdfSearchActive = false;
pdfSearchQuery = '';
pdfSearchMatches = [];
pdfSearchCurrentIdx = -1;
const hash = (location.hash || '').replace(/^#\/?/, '');
const parts = hash.split('/').filter(Boolean);
if (parts.length === 0 || hash === '') {
renderCategories();
} else if (parts[0] === 'category' && parts[1]) {
renderCategory(parts[1], null);
} else if (parts[0] === 'country' && parts[1] && parts[2]) {
renderCategory(parts[1], parts[2]);
} else if (parts[0] === 'collection' && parts.length >= 4) {
renderCollection(parts[1], parts[2], parts[3]);
} else if (parts[0] === 'doc' && parts.length >= 5) {
renderDocument(parts[1], parts[2], parts[3], parts[4]);
} else {
renderNotFound('INVALID ROUTE');
}
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
}
// ─── Init ─────────────────────────────────────────────
async function init() {
ROOT.innerHTML = '<div class="prop-loading">INITIALISING CRIME SCENE ARCHIVE...</div>';
await loadData();
if (DATA) {
window.addEventListener('hashchange', route);
route();
}
}
// Wait for DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();