masked_name, e.id).toLowerCase();
const real = utils.safe(e.display?.real_name, '').toLowerCase();
const tagsStr = (e.tags || []).join(' ').toLowerCase();
const haystack = `${masked} ${real} ${e.id} ${tagsStr} ${e.type} ${e.layer}`.toLowerCase();
return haystack.includes(q);
}).slice(0, 10);
if (!matches.length) {
resultsContainer.innerHTML = '
Aucun résultat trouvé.
';
return;
}
const showRealNames = state.get('showRealNames');
resultsContainer.innerHTML = `
${matches.length} résultat${matches.length > 1 ? 's' : ''}
${matches.map(e => {
const name = showRealNames && e.display?.real_name
? e.display.real_name
: e.display?.masked_name || e.id;
const conf = Number.isFinite(+e.confidence) ? (+e.confidence).toFixed(2) : '—';
const confColor = getColorForConfidence(+e.confidence || 0.6);
return `
${utils.escapeHtml(name)}
${utils.escapeHtml(e.type)} • conf: ${conf}
`;
}).join('')}
`;
// Bind click events
resultsContainer.querySelectorAll('[data-select-id]').forEach(el => {
el.addEventListener('click', () => {
const id = el.getAttribute('data-select-id');
if (id) {
selectEntity(id, { push: true });
setDrawer(true);
}
});
});
}
// ============================================
// KEYBOARD SHORTCUTS
// ============================================
document.addEventListener('keydown', (ev) => {
// Escape: Close drawer
if (ev.key === 'Escape' && state.get('drawerOpen')) {
setDrawer(false);
return;
}
// Home: Recenter view
if (ev.key === 'Home') {
ev.preventDefault();
state.set('view', { x: 0, y: 0, z: 1 });
document.getElementById('zoomSlider').value = 100;
toast.success('Vue recentrée');
return;
}
// R: Regenerate positions
if (ev.key === 'r' || ev.key === 'R') {
if (!ev.ctrlKey && !ev.metaKey) {
seedPositionsToZones(true);
toast.success('Positions régénérées');
}
return;
}
// M: Toggle mode
if (ev.key === 'm' || ev.key === 'M') {
const currentMode = state.get('mode');
const newMode = currentMode === 'ZONES' ? 'GRAPH' : 'ZONES';
state.set('mode', newMode);
document.getElementById('modeBtnText').textContent = newMode;
toast.success(`Mode ${newMode} activé`);
return;
}
// Ctrl/Cmd + E: Export
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'e') {
ev.preventDefault();
document.getElementById('exportBtn').click();
return;
}
// Alt + Left Arrow: Back in history
if (ev.altKey && ev.key === 'ArrowLeft') {
ev.preventDefault();
goHistory(-1);
return;
}
// Alt + Right Arrow: Forward in history
if (ev.altKey && ev.key === 'ArrowRight') {
ev.preventDefault();
goHistory(+1);
return;
}
// Ctrl/Cmd + K: Focus search
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'k') {
ev.preventDefault();
setDrawer(true);
setTimeout(() => {
document.getElementById('quickSearch').focus();
}, 100);
return;
}
});
// ============================================
// STATE SUBSCRIPTIONS & REACTIVITY
// ============================================
state.subscribe('mode', () => {
renderCanvas();
});
state.subscribe('view', () => {
renderCanvas();
});
state.subscribe('showLabels', () => {
renderCanvas();
});
state.subscribe('showOverlap', () => {
renderCanvas();
});
state.subscribe('showRealNames', () => {
renderCanvas();
});
state.subscribe('selectedId', () => {
renderCanvas();
});
state.subscribe('hoverId', () => {
renderCanvas();
});
state.subscribe('filter', () => {
renderCanvas();
});
state.subscribe('quickSearch', () => {
renderCanvas();
});
state.subscribe('drawerOpen', (open) => {
if (!open) {
renderCanvas();
}
});
// ============================================
// PERSISTENCE & SESSION RESTORE
// ============================================
function saveSession() {
try {
const sessionData = {
savedAt: new Date().toISOString(),
state: state.toJSON()
};
localStorage.setItem(CONFIG.STORAGE_KEY, JSON.stringify(sessionData));
} catch (err) {
console.warn('Could not save session:', err);
}
}
function restoreSession() {
try {
const saved = localStorage.getItem(CONFIG.STORAGE_KEY);
if (!saved) return false;
const sessionData = JSON.parse(saved);
const savedState = sessionData.state || {};
// Restore UI state
if (savedState.mode) {
state.set('mode', savedState.mode);
document.getElementById('modeBtnText').textContent = savedState.mode;
}
if (savedState.theme) {
document.documentElement.setAttribute('data-theme', savedState.theme);
document.getElementById('themeSelect').value = savedState.theme;
}
if (savedState.locale) {
document.getElementById('langSelect').value = savedState.locale;
}
if (savedState.view) {
state.set('view', savedState.view);
document.getElementById('zoomSlider').value = Math.round(savedState.view.z * 100);
}
if (savedState.showLabels !== undefined) {
state.set('showLabels', savedState.showLabels);
document.getElementById('labelsCheck').checked = savedState.showLabels;
}
if (savedState.showOverlap !== undefined) {
state.set('showOverlap', savedState.showOverlap);
document.getElementById('overlapCheck').checked = savedState.showOverlap;
}
if (savedState.showRealNames !== undefined) {
state.set('showRealNames', savedState.showRealNames);
document.getElementById('revealCheck').checked = savedState.showRealNames;
}
if (savedState.filter) {
state.set('filter', savedState.filter);
document.getElementById('filterInput').value = savedState.filter;
}
if (savedState.selectedId) {
state.set('selectedId', savedState.selectedId);
}
if (savedState.navHistory) {
state.set('navHistory', savedState.navHistory);
}
if (savedState.navIdx !== undefined) {
state.set('navIdx', savedState.navIdx);
}
console.log('✅ Session restored');
updateNavButtons();
renderDetailPane();
return true;
} catch (err) {
console.warn('Could not restore session:', err);
return false;
}
}
// Autosave every 5 seconds
setInterval(() => {
saveSession();
}, 5000);
// Save on unload
window.addEventListener('beforeunload', () => {
saveSession();
});
// ============================================
// INITIALIZATION
// ============================================
async function init() {
console.log('🚀 Initializing NYXO Portal v2.0...');
// Restore UI preferences from localStorage
const savedTheme = localStorage.getItem('nyxo.theme');
if (savedTheme) {
document.documentElement.setAttribute('data-theme', savedTheme);
document.getElementById('themeSelect').value = savedTheme;
state.set('theme', savedTheme);
}
const savedLocale = localStorage.getItem('nyxo.locale');
if (savedLocale) {
document.getElementById('langSelect').value = savedLocale;
state.set('locale', savedLocale);
}
// Restore session state
restoreSession();
// Load data from IndexedDB
await loadData();
// Initial render
renderCanvas();
renderDetailPane();
console.log('✅ NYXO Portal ready!');
// Show welcome toast
setTimeout(() => {
toast.success('🌟 Bienvenue sur NYXO Portal');
}, 500);
}
// Start the app
init().catch(err => {
console.error('Fatal error during initialization:', err);
toast.error('Erreur fatale lors de l\'initialisation');
});
// ============================================
// SERVICE WORKER (PWA)
// ============================================
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('✅ Service Worker registered:', registration);
})
.catch(err => {
console.warn('Service Worker registration failed:', err);
});
});
}
// ============================================
// ANALYTICS (Privacy-first, opt-in only)
// ============================================
// TODO: Implement privacy-respecting analytics
// - No tracking by default
// - Opt-in only
// - Anonymized
// - Self-hosted (Plausible or Umami)
console.log('%cBuilt with ❤️ for Brussels mental health community', 'color: #d7a8ff; font-style: italic;');
console.log('%cContribute: https://github.com/nyxo-brussels', 'color: #86fbd6;');