Spaces:
Running
Running
| 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'); | |
| } | |
| }); | |