IFSv8BRCGSv9 / app.js
MMOON's picture
Upload 16 files
73774bf verified
document.addEventListener('DOMContentLoaded', () => {
const ifsScrollable = document.getElementById('ifsList');
const brcScrollable = document.getElementById('brcList');
const searchInput = document.getElementById('searchInput');
let appData = null;
let currentLang = 'fr';
let comparisonMode = false;
let selectedIfsIds = new Set();
// ─── Translations ────────────────────────────────────────────────────────────
const translations = {
fr: {
mainTitle: 'Correspondance IFS v8 ↔ BRC v9',
subTitle: "Outil d'analyse comparative des rΓ©fΓ©rentiels de sΓ©curitΓ© des aliments",
searchPh: 'Rechercher...',
guideBtn: '? Guide',
guideBtnClose: 'βœ• Guide',
help1Title: 'Cliquez sur une exigence',
help1Text: 'pour voir sa correspondance',
help2Title: 'Correspondance approximative',
help2Text: 'pas de correspondance directe',
help3Title: 'Pas de correspondance',
help3Text: 'exigence non cartographiΓ©e',
help4Text: 'Exigence Γ©liminatoire',
help5Title: 'Mode comparaison',
help5Text: 'multi-sΓ©lection IFS',
ifsTitle: 'IFS Food v8',
brcTitle: 'BRCGS Food v9',
expandTip: 'Tout dΓ©plier',
collapseTip:'Tout rΓ©duire',
compareModeOn: 'Mode comparaison multi-sΓ©lection',
compareModeOff: 'Quitter le mode comparaison',
selected: 'sΓ©lectionnΓ©(s)',
clearSel: 'Effacer la sΓ©lection',
noMatchTitle: 'Aucune correspondance dans la norme liΓ©e',
approxTitle: 'Correspondance approximative – pas de correspondance directe',
},
en: {
mainTitle: 'IFS v8 ↔ BRC v9 Mapping',
subTitle: 'Comparative analysis tool for food safety standards',
searchPh: 'Search...',
guideBtn: '? Guide',
guideBtnClose: 'βœ• Guide',
help1Title: 'Click on a requirement',
help1Text: 'to see its correspondence',
help2Title: 'Approximate correspondence',
help2Text: 'no direct correspondence',
help3Title: 'No correspondence',
help3Text: 'unmapped requirement',
help4Text: 'Knock-out requirement',
help5Title: 'Comparison mode',
help5Text: 'multi-select IFS items',
ifsTitle: 'IFS Food v8',
brcTitle: 'BRCGS Food v9',
expandTip: 'Expand all',
collapseTip:'Collapse all',
compareModeOn: 'Multi-select comparison mode',
compareModeOff: 'Exit comparison mode',
selected: 'selected',
clearSel: 'Clear selection',
noMatchTitle: 'No correspondence in the linked standard',
approxTitle: 'Approximate correspondence – no direct match in the linked standard',
}
};
// ─── Helper: safely set text / title on an element ───────────────────────────
function setText(id, value) {
const el = document.getElementById(id);
if (el) el.textContent = value;
}
function setTitle(id, value) {
const el = document.getElementById(id);
if (el) el.title = value;
}
function setPlaceholder(id, value) {
const el = document.getElementById(id);
if (el) el.placeholder = value;
}
function qs(sel) { return document.querySelector(sel); }
// ─── Language toggle ─────────────────────────────────────────────────────────
document.getElementById('langToggle')?.addEventListener('click', () => {
currentLang = currentLang === 'fr' ? 'en' : 'fr';
updateLanguage();
});
function updateLanguage() {
const t = translations[currentLang];
// Header
setText('mainTitle', t.mainTitle);
setText('subTitle', t.subTitle);
setPlaceholder('searchInput', t.searchPh);
// Guide panel content
setText('help1Title', t.help1Title);
setText('help1Text', t.help1Text);
setText('help2Title', t.help2Title);
setText('help2Text', t.help2Text);
setText('help3Title', t.help3Title);
setText('help3Text', t.help3Text);
setText('help4Text', t.help4Text);
setText('help5Title', t.help5Title);
setText('help5Text', t.help5Text);
// Guide button label (changes based on panel state)
const isOpen = qs('#guidePanel')?.classList.contains('open');
setText('guideBtnLabel', isOpen ? t.guideBtnClose : t.guideBtn);
// Column headers
const ifsH2 = qs('.ifs-column .column-header h2');
const brcH2 = qs('.brc-column .column-header h2');
if (ifsH2) ifsH2.textContent = t.ifsTitle;
if (brcH2) brcH2.textContent = t.brcTitle;
// Buttons
setTitle('expandIfs', t.expandTip);
setTitle('collapseIfs', t.collapseTip);
setTitle('expandBrc', t.expandTip);
setTitle('collapseBrc', t.collapseTip);
setTitle('compareMode', comparisonMode ? t.compareModeOff : t.compareModeOn);
setText('clearCompare', 'βœ• ' + t.clearSel);
// Visual indicator on FR|EN button
const frEl = qs('.lang-fr');
const enEl = qs('.lang-en');
if (frEl) frEl.style.opacity = currentLang === 'fr' ? '1' : '0.4';
if (enEl) enEl.style.opacity = currentLang === 'en' ? '1' : '0.4';
updateComparisonUI();
}
// ─── Guide panel ─────────────────────────────────────────────────────────────
const guideToggleBtn = document.getElementById('guideToggle');
const guidePanel = document.getElementById('guidePanel');
guideToggleBtn?.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = guidePanel.classList.toggle('open');
guidePanel.setAttribute('aria-hidden', String(!isOpen));
const t = translations[currentLang];
setText('guideBtnLabel', isOpen ? t.guideBtnClose : t.guideBtn);
guideToggleBtn.classList.toggle('active-mode', isOpen);
});
// Close guide panel when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.guide-wrapper')) {
guidePanel?.classList.remove('open');
guidePanel?.setAttribute('aria-hidden', 'true');
setText('guideBtnLabel', translations[currentLang].guideBtn);
guideToggleBtn?.classList.remove('active-mode');
}
});
// ─── Data loading ────────────────────────────────────────────────────────────
if (window.APP_DATA) {
appData = window.APP_DATA;
renderLists(appData);
} else {
console.error('APP_DATA not found. Ensure data.js is loaded.');
}
// ─── Render ──────────────────────────────────────────────────────────────────
function renderLists(data) {
renderGroupedList(ifsScrollable, groupData(data.ifs_standards, 'chapter', 'section'), 'ifs');
renderGroupedList(brcScrollable, groupData(data.brc_standards, 'chapter', 'subsection'), 'brc');
}
function groupData(items, key1, key2) {
const groups = {};
items.forEach(item => {
const k1 = (item[key1] && item[key1] !== 'undefined') ? item[key1] : 'GΓ©nΓ©ral';
const k2 = (item[key2] && item[key2] !== 'undefined') ? item[key2] : 'Section GΓ©nΓ©rale';
if (!groups[k1]) groups[k1] = {};
if (!groups[k1][k2]) groups[k1][k2] = [];
groups[k1][k2].push(item);
});
return groups;
}
function renderGroupedList(container, groups, type) {
container.innerHTML = '';
// Build chapter-number map
const chNumMap = {};
[...(appData?.ifs_standards || []), ...(appData?.brc_standards || [])].forEach(item => {
if (item.chapter && item.chapter_num) chNumMap[item.chapter] = item.chapter_num;
});
Object.keys(groups).forEach(l1Key => {
const detailsL1 = document.createElement('details');
detailsL1.className = 'acc-l1';
const summaryL1 = document.createElement('summary');
summaryL1.className = 'acc-summary-l1';
let title = l1Key;
if (chNumMap[l1Key]) {
title = `${type === 'ifs' ? 'Chapitre' : 'Section'} ${chNumMap[l1Key]}: ${l1Key}`;
}
summaryL1.innerHTML = `<span>${title}</span>`;
detailsL1.appendChild(summaryL1);
const contentL1 = document.createElement('div');
contentL1.className = 'acc-content-l1';
Object.keys(groups[l1Key]).forEach(l2Key => {
const detailsL2 = document.createElement('details');
detailsL2.className = 'acc-l2';
const summaryL2 = document.createElement('summary');
summaryL2.className = 'acc-summary-l2';
summaryL2.innerHTML = `<span>${l2Key}</span>`;
detailsL2.appendChild(summaryL2);
const contentL2 = document.createElement('div');
contentL2.className = 'acc-content-l2';
groups[l1Key][l2Key].forEach(item => contentL2.appendChild(createCard(item, type)));
detailsL2.appendChild(contentL2);
contentL1.appendChild(detailsL2);
});
detailsL1.appendChild(contentL1);
container.appendChild(detailsL1);
});
}
function createCard(item, type) {
const div = document.createElement('div');
div.className = `item-card ${type}-card`;
div.dataset.id = cleanId(item.id);
div.dataset.type = type;
const isKO = item.ko === true;
const koBadge = isKO ? '<span class="ko-tag">KO</span>' : '';
div.innerHTML = `
<div class="meta-info">
<span class="ref-id">${item.id}</span>
${koBadge}
</div>
<div class="item-text">${truncateText(item.text, 300)}</div>
`;
// Pre-build search index so innerText (which ignores collapsed <details>) is never used
div.dataset.search = (item.id + ' ' + (item.text || '')).toLowerCase();
div.addEventListener('click', () => handleCardClick(div, item.id, type));
return div;
}
// ─── Click handling ──────────────────────────────────────────────────────────
function handleCardClick(element, id, type) {
if (comparisonMode && type === 'ifs') {
handleComparisonClick(element, id);
return;
}
const cleanID = cleanId(id);
// Reset
document.querySelectorAll('.item-card.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.approximate-indicator, .no-match-indicator').forEach(el => el.remove());
document.querySelectorAll('.approximate-active, .no-match-active').forEach(el => {
el.classList.remove('approximate-active', 'no-match-active');
});
element.classList.add('active');
openParents(element);
scrollToInContainer(element, type === 'ifs' ? ifsScrollable : brcScrollable);
// Resolve mapping
let targetIds = [];
let isApproximate = false;
let hasNoMatch = false;
if (type === 'ifs') {
const mapping = appData.mappings.find(m => cleanId(m.source_id) === cleanID);
if (mapping && mapping.target_ids && mapping.target_ids.length > 0) {
targetIds = mapping.target_ids;
isApproximate = mapping.approximate === true;
} else if (mapping && mapping.approximate) {
isApproximate = true;
} else {
hasNoMatch = true;
}
} else {
const related = appData.mappings.filter(m =>
m.target_ids && m.target_ids.some(tid => cleanId(tid) === cleanID)
);
targetIds = related.map(m => m.source_id);
hasNoMatch = related.length === 0;
isApproximate = related.some(m => m.approximate === true);
}
const targetContainer = type === 'ifs' ? brcScrollable : ifsScrollable;
highlightTargets(targetIds, type === 'ifs' ? 'brc' : 'ifs', isApproximate, hasNoMatch, targetContainer);
// Mobile: auto-switch column
if (window.innerWidth <= 768 && targetIds.length > 0) {
switchMobileTab(type === 'ifs' ? 'brc' : 'ifs');
}
}
function highlightTargets(ids, targetType, isApproximate, hasNoMatch, container) {
if (!ids || ids.length === 0) {
const activeEl = qs('.item-card.active');
if (activeEl) {
if (hasNoMatch) addNoMatchIndicator(activeEl);
else if (isApproximate) addApproximateIndicator(activeEl);
}
return;
}
let firstFound = null;
const foundTargets = [];
ids.forEach(id => {
const el = qs(`.${targetType}-card[data-id="${cleanId(id)}"]`);
if (el) {
el.classList.add('active');
openParents(el);
foundTargets.push(el);
if (!firstFound) firstFound = el;
}
});
if (isApproximate) {
// Mark the source card (already .active)
const sourceEl = qs('.item-card.active:not(.' + targetType + '-card)');
if (sourceEl) addApproximateIndicator(sourceEl);
// Mark each target card with the same ⚠ indicator
foundTargets.forEach(el => addApproximateIndicator(el));
}
if (firstFound && container) {
setTimeout(() => scrollToInContainer(firstFound, container), 120);
}
}
// ─── Scroll sync ─────────────────────────────────────────────────────────────
function scrollToInContainer(element, container) {
const cRect = container.getBoundingClientRect();
const eRect = element.getBoundingClientRect();
const target = container.scrollTop + (eRect.top - cRect.top) - (cRect.height / 2) + (eRect.height / 2);
container.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}
// ─── Comparison mode ─────────────────────────────────────────────────────────
document.getElementById('compareMode')?.addEventListener('click', () => {
comparisonMode = !comparisonMode;
if (!comparisonMode) clearComparison();
updateComparisonUI();
});
document.getElementById('clearCompare')?.addEventListener('click', clearComparison);
function clearComparison() {
selectedIfsIds.clear();
document.querySelectorAll('.ifs-card.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.item-card.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.approximate-indicator, .no-match-indicator').forEach(el => el.remove());
document.querySelectorAll('.approximate-active, .no-match-active').forEach(el => {
el.classList.remove('approximate-active', 'no-match-active');
});
updateComparisonUI();
}
function updateComparisonUI() {
const t = translations[currentLang];
const btn = document.getElementById('compareMode');
const bar = document.getElementById('compareBar');
const cnt = document.getElementById('compareCount');
if (btn) {
btn.classList.toggle('active-mode', comparisonMode);
btn.title = comparisonMode ? t.compareModeOff : t.compareModeOn;
}
if (bar) bar.classList.toggle('visible', comparisonMode);
if (cnt) cnt.textContent = selectedIfsIds.size + ' ' + t.selected;
}
function handleComparisonClick(element, id) {
const cleanID = cleanId(id);
if (selectedIfsIds.has(cleanID)) {
selectedIfsIds.delete(cleanID);
element.classList.remove('selected');
} else {
selectedIfsIds.add(cleanID);
element.classList.add('selected');
}
updateComparisonUI();
recomputeComparisonHighlights();
}
function recomputeComparisonHighlights() {
document.querySelectorAll('.brc-card.active').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.approximate-indicator, .no-match-indicator').forEach(el => el.remove());
if (selectedIfsIds.size === 0) return;
const union = new Set();
selectedIfsIds.forEach(ifsId => {
const m = appData.mappings.find(m => cleanId(m.source_id) === ifsId);
if (m && m.target_ids) m.target_ids.forEach(tid => union.add(cleanId(tid)));
});
let first = null;
union.forEach(brcId => {
const el = qs(`.brc-card[data-id="${brcId}"]`);
if (el) {
el.classList.add('active');
openParents(el);
if (!first) first = el;
}
});
if (first) setTimeout(() => scrollToInContainer(first, brcScrollable), 120);
}
// ─── Mobile tabs ──────────────────────────────────────────────────────────────
document.getElementById('tabIfs')?.addEventListener('click', () => switchMobileTab('ifs'));
document.getElementById('tabBrc')?.addEventListener('click', () => switchMobileTab('brc'));
function switchMobileTab(tab) {
const ifsCol = document.getElementById('ifsColumn');
const brcCol = document.getElementById('brcColumn');
const tabIfs = document.getElementById('tabIfs');
const tabBrc = document.getElementById('tabBrc');
ifsCol?.classList.toggle('mobile-hidden', tab !== 'ifs');
brcCol?.classList.toggle('mobile-hidden', tab !== 'brc');
tabIfs?.classList.toggle('active', tab === 'ifs');
tabBrc?.classList.toggle('active', tab === 'brc');
}
// ─── Search ──────────────────────────────────────────────────────────────────
searchInput.addEventListener('input', (e) => {
const term = e.target.value.toLowerCase().trim();
// 1. Show/hide individual cards using the pre-built index (works on closed accordions too)
document.querySelectorAll('.item-card').forEach(card => {
const match = !term || card.dataset.search.includes(term);
card.style.display = match ? '' : 'none';
if (match && term.length > 1) openParents(card);
});
// 2. Hide L2 accordions whose every card is hidden
document.querySelectorAll('details.acc-l2').forEach(l2 => {
const hasVisible = [...l2.querySelectorAll('.item-card')]
.some(c => c.style.display !== 'none');
l2.style.display = hasVisible ? '' : 'none';
});
// 3. Hide L1 accordions whose every L2 is hidden
document.querySelectorAll('details.acc-l1').forEach(l1 => {
const hasVisible = [...l1.querySelectorAll('details.acc-l2')]
.some(l2 => l2.style.display !== 'none');
l1.style.display = hasVisible ? '' : 'none';
});
});
// ─── Expand / Collapse ───────────────────────────────────────────────────────
document.getElementById('expandIfs')?.addEventListener('click', () => toggleAll('ifsList', true));
document.getElementById('collapseIfs')?.addEventListener('click', () => toggleAll('ifsList', false));
document.getElementById('expandBrc')?.addEventListener('click', () => toggleAll('brcList', true));
document.getElementById('collapseBrc')?.addEventListener('click', () => toggleAll('brcList', false));
function toggleAll(containerId, expand) {
document.getElementById(containerId)?.querySelectorAll('details').forEach(d => { d.open = expand; });
}
// ─── Utilities ───────────────────────────────────────────────────────────────
function cleanId(id) {
return id ? id.replace(/\*$/, '').trim() : '';
}
function openParents(el) {
let p = el.parentElement;
while (p) {
if (p.tagName === 'DETAILS') p.open = true;
p = p.parentElement;
}
}
function truncateText(text, len) {
if (!text) return '';
return text.length <= len ? text : text.substring(0, len) + '...';
}
function addNoMatchIndicator(el) {
if (el.querySelector('.no-match-indicator')) return;
const span = document.createElement('span');
span.className = 'no-match-indicator';
span.innerHTML = 'β›”';
span.title = translations[currentLang].noMatchTitle;
const ref = el.querySelector('.ref-id');
ref ? ref.parentNode.insertBefore(span, ref.nextSibling) : el.appendChild(span);
el.classList.add('no-match-active');
}
function addApproximateIndicator(el) {
if (el.querySelector('.approximate-indicator')) return;
const span = document.createElement('span');
span.className = 'approximate-indicator';
span.innerHTML = '⚠';
span.title = translations[currentLang].approxTitle;
const ref = el.querySelector('.ref-id');
ref ? ref.parentNode.insertBefore(span, ref.nextSibling) : el.appendChild(span);
el.classList.add('approximate-active');
}
});