|
|
<!DOCTYPE html> |
|
|
<html lang="fr"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>FDA Risk Analyzer - Outil d'Analyse des Risques Alimentaires</title> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
|
<style> |
|
|
:root { |
|
|
--primary-color: #003366; |
|
|
--primary-light: #0055a4; |
|
|
--accent-color: #fdb81e; |
|
|
--danger-color: #d93025; |
|
|
--warning-color: #f59e0b; |
|
|
--success-color: #1e8e3e; |
|
|
--bg-color: #f8f9fa; |
|
|
--surface-color: #ffffff; |
|
|
--text-primary: #202124; |
|
|
--text-secondary: #5f6368; |
|
|
--border-color: #dee2e6; |
|
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); |
|
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); |
|
|
--border-radius: 8px; |
|
|
--transition: all 0.3s ease-in-out; |
|
|
} |
|
|
|
|
|
* { |
|
|
box-sizing: border-box; |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
|
|
background-color: var(--bg-color); |
|
|
color: var(--text-primary); |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
.header { |
|
|
background: var(--surface-color); |
|
|
border-bottom: 1px solid var(--border-color); |
|
|
padding: 1rem 0; |
|
|
position: sticky; |
|
|
top: 0; |
|
|
z-index: 1000; |
|
|
box-shadow: var(--shadow-sm); |
|
|
} |
|
|
|
|
|
.header-content { |
|
|
max-width: 1600px; |
|
|
margin: 0 auto; |
|
|
padding: 0 2rem; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: space-between; |
|
|
} |
|
|
|
|
|
.logo { |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.75rem; |
|
|
color: var(--primary-color); |
|
|
font-weight: 600; |
|
|
font-size: 1.25rem; |
|
|
} |
|
|
|
|
|
.logo-icon { |
|
|
width: 32px; |
|
|
height: 32px; |
|
|
background-color: var(--primary-color); |
|
|
border-radius: 6px; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
color: white; |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
.guide-btn { |
|
|
background-color: var(--primary-color); |
|
|
color: white; |
|
|
border: none; |
|
|
padding: 0.6rem 1.2rem; |
|
|
border-radius: var(--border-radius); |
|
|
cursor: pointer; |
|
|
font-size: 0.9rem; |
|
|
transition: var(--transition); |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.guide-btn:hover { |
|
|
background-color: var(--primary-light); |
|
|
} |
|
|
|
|
|
.container { |
|
|
max-width: 1600px; |
|
|
margin: 2rem auto; |
|
|
padding: 0 2rem; |
|
|
} |
|
|
|
|
|
.welcome-guide { |
|
|
background-color: #e7f3ff; |
|
|
border: 1px solid #a8c7fa; |
|
|
border-radius: var(--border-radius); |
|
|
padding: 1.5rem; |
|
|
margin-bottom: 2rem; |
|
|
position: relative; |
|
|
animation: fadeIn 0.5s; |
|
|
} |
|
|
|
|
|
.methodology-info { |
|
|
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%); |
|
|
border: 1px solid #0ea5e9; |
|
|
border-radius: var(--border-radius); |
|
|
margin-bottom: 2rem; |
|
|
overflow: hidden; |
|
|
box-shadow: var(--shadow-sm); |
|
|
} |
|
|
|
|
|
.methodology-header { |
|
|
background: var(--primary-color); |
|
|
color: white; |
|
|
padding: 1rem 1.5rem; |
|
|
cursor: pointer; |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
transition: var(--transition); |
|
|
} |
|
|
|
|
|
.methodology-header:hover { |
|
|
background-color: var(--primary-light); |
|
|
} |
|
|
|
|
|
.methodology-title { |
|
|
font-size: 1.1rem; |
|
|
font-weight: 600; |
|
|
margin: 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.methodology-toggle { |
|
|
font-size: 1.2rem; |
|
|
transition: transform 0.3s ease; |
|
|
} |
|
|
|
|
|
.methodology-toggle.expanded { |
|
|
transform: rotate(180deg); |
|
|
} |
|
|
|
|
|
.methodology-content { |
|
|
max-height: 0; |
|
|
overflow: hidden; |
|
|
transition: max-height 0.5s ease-in-out, padding 0.5s ease-in-out; |
|
|
padding: 0 2rem; |
|
|
} |
|
|
|
|
|
.methodology-content.expanded { |
|
|
max-height: 3000px; |
|
|
padding: 2rem; |
|
|
} |
|
|
|
|
|
.methodology-inner { |
|
|
|
|
|
} |
|
|
|
|
|
.methodology-section { |
|
|
margin-bottom: 2rem; |
|
|
} |
|
|
|
|
|
.methodology-section:last-child { |
|
|
margin-bottom: 0; |
|
|
} |
|
|
|
|
|
.methodology-section h4 { |
|
|
color: var(--primary-color); |
|
|
font-size: 1.1rem; |
|
|
margin: 0 0 1rem 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.methodology-section p { |
|
|
margin-bottom: 1rem; |
|
|
line-height: 1.6; |
|
|
} |
|
|
|
|
|
.methodology-section ul { |
|
|
padding-left: 1.5rem; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.methodology-section li { |
|
|
margin-bottom: 0.5rem; |
|
|
line-height: 1.5; |
|
|
} |
|
|
|
|
|
.highlight-box { |
|
|
background: rgba(251, 191, 36, 0.1); |
|
|
border-left: 4px solid #f59e0b; |
|
|
padding: 1rem; |
|
|
margin: 1rem 0; |
|
|
border-radius: 0 var(--border-radius) var(--border-radius) 0; |
|
|
} |
|
|
|
|
|
.reference-section { |
|
|
background: var(--bg-color); |
|
|
border-radius: var(--border-radius); |
|
|
padding: 1.5rem; |
|
|
margin-top: 1.5rem; |
|
|
} |
|
|
|
|
|
.reference-section h5 { |
|
|
color: var(--primary-color); |
|
|
margin: 0 0 1rem 0; |
|
|
font-size: 1rem; |
|
|
} |
|
|
|
|
|
.reference-list { |
|
|
list-style: none; |
|
|
padding: 0; |
|
|
margin: 0; |
|
|
} |
|
|
|
|
|
.reference-list li { |
|
|
margin-bottom: 0.75rem; |
|
|
padding-left: 1.5rem; |
|
|
position: relative; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
.reference-list li:before { |
|
|
content: "📄"; |
|
|
position: absolute; |
|
|
left: 0; |
|
|
} |
|
|
|
|
|
.reference-list a { |
|
|
color: var(--primary-light); |
|
|
text-decoration: none; |
|
|
} |
|
|
|
|
|
.reference-list a:hover { |
|
|
text-decoration: underline; |
|
|
} |
|
|
|
|
|
.haccp-integration { |
|
|
background: rgba(30, 142, 62, 0.1); |
|
|
border: 2px solid var(--success-color); |
|
|
border-radius: var(--border-radius); |
|
|
padding: 1.5rem; |
|
|
margin: 1.5rem 0; |
|
|
} |
|
|
|
|
|
.haccp-integration h5 { |
|
|
color: var(--success-color); |
|
|
margin: 0 0 1rem 0; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.welcome-guide h3 { |
|
|
margin: 0 0 1rem 0; |
|
|
color: var(--primary-color); |
|
|
} |
|
|
|
|
|
.guide-steps { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); |
|
|
gap: 1.5rem; |
|
|
} |
|
|
|
|
|
.guide-step { |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
.guide-step strong { |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.close-guide { |
|
|
position: absolute; |
|
|
top: 0.5rem; |
|
|
right: 0.5rem; |
|
|
background: none; |
|
|
border: none; |
|
|
font-size: 1.5rem; |
|
|
cursor: pointer; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.controls-panel { |
|
|
background: var(--surface-color); |
|
|
border-radius: var(--border-radius); |
|
|
padding: 1.5rem; |
|
|
margin-bottom: 2rem; |
|
|
box-shadow: var(--shadow-md); |
|
|
border: 1px solid var(--border-color); |
|
|
} |
|
|
|
|
|
.view-selector { |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
gap: 0.5rem; |
|
|
border-bottom: 1px solid var(--border-color); |
|
|
padding-bottom: 1rem; |
|
|
margin-bottom: 1rem; |
|
|
} |
|
|
|
|
|
.view-btn { |
|
|
padding: 0.6rem 1.2rem; |
|
|
border: 1px solid transparent; |
|
|
background: none; |
|
|
color: var(--text-secondary); |
|
|
cursor: pointer; |
|
|
font-size: 0.95rem; |
|
|
font-weight: 500; |
|
|
border-bottom: 3px solid transparent; |
|
|
border-radius: 6px; |
|
|
transition: var(--transition); |
|
|
} |
|
|
|
|
|
.view-btn.active { |
|
|
color: var(--primary-light); |
|
|
background-color: #e7f3ff; |
|
|
border-color: var(--primary-light); |
|
|
} |
|
|
|
|
|
.view-btn:hover:not(.active) { |
|
|
background-color: #f0f7ff; |
|
|
} |
|
|
|
|
|
#main-view-controls { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr auto; |
|
|
gap: 1rem; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
#comparison-view-controls { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 1rem; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.search-container { |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
.search-input { |
|
|
width: 100%; |
|
|
padding: 0.75rem 1rem 0.75rem 2.5rem; |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: var(--border-radius); |
|
|
font-size: 1rem; |
|
|
transition: var(--transition); |
|
|
} |
|
|
|
|
|
.search-input:focus { |
|
|
outline: none; |
|
|
border-color: var(--primary-light); |
|
|
box-shadow: 0 0 0 3px rgba(0, 85, 164, 0.1); |
|
|
} |
|
|
|
|
|
.search-icon { |
|
|
position: absolute; |
|
|
left: 0.75rem; |
|
|
top: 50%; |
|
|
transform: translateY(-50%); |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.filter-select { |
|
|
padding: 0.75rem; |
|
|
border: 1px solid var(--border-color); |
|
|
border-radius: var(--border-radius); |
|
|
background: var(--surface-color); |
|
|
color: var(--text-primary); |
|
|
font-size: 0.9rem; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.dashboard { |
|
|
display: grid; |
|
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); |
|
|
gap: 1.5rem; |
|
|
margin-bottom: 2rem; |
|
|
} |
|
|
|
|
|
.metric-card { |
|
|
background: var(--surface-color); |
|
|
padding: 1.5rem; |
|
|
border-radius: var(--border-radius); |
|
|
box-shadow: var(--shadow-sm); |
|
|
border: 1px solid var(--border-color); |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
.metric-label { |
|
|
font-size: 0.85rem; |
|
|
color: var(--text-secondary); |
|
|
margin-bottom: 0.5rem; |
|
|
} |
|
|
|
|
|
.metric-value { |
|
|
font-size: 1.75rem; |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.metric-card.high-risk { |
|
|
border-top: 4px solid var(--danger-color); |
|
|
} |
|
|
|
|
|
.metric-card.medium-risk { |
|
|
border-top: 4px solid var(--warning-color); |
|
|
} |
|
|
|
|
|
.metric-card.low-risk { |
|
|
border-top: 4px solid var(--success-color); |
|
|
} |
|
|
|
|
|
#detail-panel { |
|
|
max-height: 0; |
|
|
overflow: hidden; |
|
|
transition: max-height 0.5s ease-in-out, margin 0.5s ease-in-out; |
|
|
margin-bottom: 0; |
|
|
} |
|
|
|
|
|
#detail-panel.visible { |
|
|
max-height: 1000px; |
|
|
margin-bottom: 2rem; |
|
|
} |
|
|
|
|
|
.detail-card { |
|
|
background-color: var(--surface-color); |
|
|
border-radius: var(--border-radius); |
|
|
border: 1px solid var(--border-color); |
|
|
box-shadow: var(--shadow-md); |
|
|
padding: 1.5rem; |
|
|
} |
|
|
|
|
|
.detail-layout { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 2rem; |
|
|
align-items: start; |
|
|
} |
|
|
|
|
|
.chart-title { |
|
|
font-weight: 600; |
|
|
color: var(--text-primary); |
|
|
margin: 0 0 1rem 0; |
|
|
font-size: 1.1rem; |
|
|
word-break: break-word; |
|
|
} |
|
|
|
|
|
.chart-canvas-container { |
|
|
position: relative; |
|
|
height: 320px; |
|
|
} |
|
|
|
|
|
#comparison-chart-container { |
|
|
min-height: 600px; |
|
|
height: auto; |
|
|
} |
|
|
|
|
|
#takeaways-section h4 { |
|
|
margin: 0 0 1rem 0; |
|
|
color: var(--primary-color); |
|
|
} |
|
|
|
|
|
#takeaways-list { |
|
|
list-style: none; |
|
|
padding: 0; |
|
|
margin: 0; |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
gap: 0.75rem; |
|
|
} |
|
|
|
|
|
#takeaways-list li { |
|
|
display: flex; |
|
|
align-items: flex-start; |
|
|
gap: 0.75rem; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
#takeaways-list li .icon { |
|
|
color: var(--warning-color); |
|
|
font-weight: bold; |
|
|
margin-top: 2px; |
|
|
} |
|
|
|
|
|
.table-container { |
|
|
background: var(--surface-color); |
|
|
border-radius: var(--border-radius); |
|
|
box-shadow: var(--shadow-md); |
|
|
border: 1px solid var(--border-color); |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
.table-header { |
|
|
padding: 1rem 1.5rem; |
|
|
border-bottom: 1px solid var(--border-color); |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
} |
|
|
|
|
|
.table-title { |
|
|
font-weight: 600; |
|
|
margin: 0; |
|
|
} |
|
|
|
|
|
.results-count { |
|
|
color: var(--text-secondary); |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
.table-wrapper { |
|
|
overflow-x: auto; |
|
|
} |
|
|
|
|
|
table { |
|
|
width: 100%; |
|
|
border-collapse: collapse; |
|
|
} |
|
|
|
|
|
th, td { |
|
|
padding: 1rem 1.5rem; |
|
|
text-align: left; |
|
|
border-bottom: 1px solid var(--border-color); |
|
|
white-space: nowrap; |
|
|
} |
|
|
|
|
|
tbody tr:last-child td { |
|
|
border-bottom: none; |
|
|
} |
|
|
|
|
|
th { |
|
|
background: var(--bg-color); |
|
|
font-weight: 600; |
|
|
color: var(--text-secondary); |
|
|
font-size: 0.85rem; |
|
|
text-transform: uppercase; |
|
|
} |
|
|
|
|
|
th.sortable { |
|
|
cursor: pointer; |
|
|
position: relative; |
|
|
} |
|
|
|
|
|
th.sortable:hover { |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
th.sortable::after { |
|
|
content: '↕'; |
|
|
position: absolute; |
|
|
right: 0.75rem; |
|
|
opacity: 0.3; |
|
|
} |
|
|
|
|
|
th.sort-asc::after { |
|
|
content: '↑'; |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
th.sort-desc::after { |
|
|
content: '↓'; |
|
|
opacity: 1; |
|
|
} |
|
|
|
|
|
tbody tr.clickable { |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
tbody tr:hover { |
|
|
background-color: #e9ecef; |
|
|
} |
|
|
|
|
|
tbody tr.selected { |
|
|
background-color: #dbeafe !important; |
|
|
font-weight: 500; |
|
|
} |
|
|
|
|
|
.score-badge { |
|
|
display: inline-block; |
|
|
padding: 0.2rem 0.6rem; |
|
|
border-radius: 1rem; |
|
|
font-size: 0.8rem; |
|
|
font-weight: 700; |
|
|
} |
|
|
|
|
|
.score-high { |
|
|
background-color: #fdeded; |
|
|
color: #a50e0e; |
|
|
} |
|
|
|
|
|
.score-medium { |
|
|
background-color: #fff8e1; |
|
|
color: #6f4f12; |
|
|
} |
|
|
|
|
|
.score-low { |
|
|
background-color: #e6f4ea; |
|
|
color: #1b5e20; |
|
|
} |
|
|
|
|
|
#loading-overlay { |
|
|
position: fixed; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
background: rgba(255, 255, 255, 0.9); |
|
|
z-index: 3000; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
text-align: center; |
|
|
font-size: 1.2rem; |
|
|
color: var(--primary-color); |
|
|
transition: opacity 0.3s; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
width: 50px; |
|
|
height: 50px; |
|
|
border: 5px solid rgba(0, 51, 102, 0.2); |
|
|
border-top: 5px solid var(--primary-color); |
|
|
border-radius: 50%; |
|
|
animation: spin 1s linear infinite; |
|
|
margin: 0 auto 1rem; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
0% { transform: rotate(0deg); } |
|
|
100% { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
.modal { |
|
|
display: none; |
|
|
position: fixed; |
|
|
z-index: 2000; |
|
|
left: 0; |
|
|
top: 0; |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
overflow: auto; |
|
|
background-color: rgba(0,0,0,0.5); |
|
|
backdrop-filter: blur(5px); |
|
|
} |
|
|
|
|
|
.modal-content { |
|
|
background-color: var(--surface-color); |
|
|
margin: 5% auto; |
|
|
padding: 2.5rem; |
|
|
border: 1px solid var(--border-color); |
|
|
width: 90%; |
|
|
max-width: 800px; |
|
|
border-radius: var(--border-radius); |
|
|
animation: fadeIn 0.3s; |
|
|
} |
|
|
|
|
|
.modal-header { |
|
|
display: flex; |
|
|
justify-content: space-between; |
|
|
align-items: center; |
|
|
border-bottom: 1px solid var(--border-color); |
|
|
padding-bottom: 1rem; |
|
|
margin-bottom: 1.5rem; |
|
|
} |
|
|
|
|
|
.modal-title { |
|
|
font-size: 1.5rem; |
|
|
color: var(--primary-color); |
|
|
margin: 0; |
|
|
} |
|
|
|
|
|
.close-modal { |
|
|
font-size: 2rem; |
|
|
font-weight: bold; |
|
|
color: var(--text-secondary); |
|
|
cursor: pointer; |
|
|
} |
|
|
|
|
|
.close-modal:hover { |
|
|
color: var(--text-primary); |
|
|
} |
|
|
|
|
|
.criteria-grid { |
|
|
display: grid; |
|
|
grid-template-columns: 1fr 1fr; |
|
|
gap: 1rem; |
|
|
margin-top: 1rem; |
|
|
} |
|
|
|
|
|
.criteria-card { |
|
|
background: var(--bg-color); |
|
|
padding: 1rem; |
|
|
border-radius: var(--border-radius); |
|
|
} |
|
|
|
|
|
.criteria-title { |
|
|
font-weight: 600; |
|
|
display: block; |
|
|
margin-bottom: 0.25rem; |
|
|
} |
|
|
|
|
|
.criteria-desc { |
|
|
font-size: 0.9rem; |
|
|
color: var(--text-secondary); |
|
|
} |
|
|
|
|
|
.modal-footer { |
|
|
margin-top: 2rem; |
|
|
padding-top: 1rem; |
|
|
border-top: 1px solid var(--border-color); |
|
|
text-align: center; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
.modal-footer a { |
|
|
color: var(--primary-light); |
|
|
} |
|
|
|
|
|
@keyframes fadeIn { |
|
|
from { opacity: 0; transform: translateY(-20px); } |
|
|
to { opacity: 1; transform: translateY(0); } |
|
|
} |
|
|
|
|
|
@media (max-width: 1200px) { |
|
|
#main-view-controls, #comparison-view-controls { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
.detail-layout { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
} |
|
|
|
|
|
@media (max-width: 768px) { |
|
|
.header-content, .container { |
|
|
padding: 0 1rem; |
|
|
} |
|
|
.view-selector { |
|
|
flex-direction: column; |
|
|
} |
|
|
.dashboard { |
|
|
grid-template-columns: 1fr; |
|
|
} |
|
|
} |
|
|
|
|
|
.risk-indicator { |
|
|
display: inline-block; |
|
|
width: 12px; |
|
|
height: 12px; |
|
|
border-radius: 50%; |
|
|
margin-right: 8px; |
|
|
} |
|
|
|
|
|
.risk-high { |
|
|
background-color: var(--danger-color); |
|
|
} |
|
|
|
|
|
.risk-medium { |
|
|
background-color: var(--warning-color); |
|
|
} |
|
|
|
|
|
.risk-low { |
|
|
background-color: var(--success-color); |
|
|
} |
|
|
|
|
|
.footer { |
|
|
text-align: center; |
|
|
padding: 2rem; |
|
|
color: var(--text-secondary); |
|
|
font-size: 0.9rem; |
|
|
border-top: 1px solid var(--border-color); |
|
|
margin-top: 2rem; |
|
|
} |
|
|
|
|
|
.error-message { |
|
|
color: var(--danger-color); |
|
|
text-align: center; |
|
|
padding: 2rem; |
|
|
font-weight: 500; |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<div id="loading-overlay"> |
|
|
<div> |
|
|
<div class="spinner"></div> |
|
|
<h2>Chargement des données...</h2> |
|
|
<p>Veuillez patienter.</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<header class="header"> |
|
|
<div class="header-content"> |
|
|
<div class="logo"> |
|
|
<div class="logo-icon">🛡️</div> |
|
|
<span>FDA Risk Analyzer</span> |
|
|
</div> |
|
|
<button class="guide-btn" id="guide-btn"> |
|
|
<i class="fas fa-book"></i> |
|
|
Guide d'Utilisation |
|
|
</button> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<div class="container"> |
|
|
<div class="welcome-guide" id="welcome-guide"> |
|
|
<button class="close-guide" id="close-guide">×</button> |
|
|
<h3>Guide Rapide de l'Outil</h3> |
|
|
<div class="guide-steps"> |
|
|
<div class="guide-step"> |
|
|
<strong>1. Choisissez une Vue</strong><br> |
|
|
Utilisez les boutons ci-dessous pour sélectionner votre niveau d'analyse. |
|
|
</div> |
|
|
<div class="guide-step"> |
|
|
<strong>2. Filtrez les Données</strong><br> |
|
|
Utilisez la recherche et les filtres pour affiner les résultats. |
|
|
</div> |
|
|
<div class="guide-step"> |
|
|
<strong>3. Analysez en Profondeur</strong><br> |
|
|
Cliquez sur les lignes du tableau ou utilisez la vue comparative. |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="methodology-info" id="methodology-info"> |
|
|
<div class="methodology-header" id="methodology-header"> |
|
|
<h3 class="methodology-title"> |
|
|
🧬 Comprendre la Méthodologie FDA de Classement des Risques |
|
|
</h3> |
|
|
<span class="methodology-toggle" id="methodology-toggle">▼</span> |
|
|
</div> |
|
|
<div class="methodology-content" id="methodology-content"> |
|
|
<div class="methodology-inner"> |
|
|
<div class="methodology-section"> |
|
|
<h4>🎯 Qu'est-ce que le Modèle de Classement des Risques de la FDA ?</h4> |
|
|
<p> |
|
|
Le <strong>FDA Risk-Ranking Model</strong> est un outil scientifique développé par l'administration américaine (FDA) pour <strong>prioriser les efforts de surveillance</strong> et de contrôle dans l'industrie alimentaire. Il permet d'identifier quels couples produit/danger représentent les risques les plus élevés pour la santé publique. |
|
|
</p> |
|
|
<div class="highlight-box"> |
|
|
<strong>🔍 Objectif principal :</strong> Aider les autorités sanitaires et les professionnels de l'alimentaire à concentrer leurs ressources limitées sur les risques qui comptent vraiment. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="methodology-section"> |
|
|
<h4>⚙️ Comment ça fonctionne : Les 7 Critères d'Évaluation</h4> |
|
|
<p>Chaque couple produit/pathogène est évalué selon 7 critères scientifiques, notés de 1 à 9 :</p> |
|
|
<ul> |
|
|
<li><strong>C1 - Gravité :</strong> Sévérité des effets sur la santé (mortalité, hospitalisation)</li> |
|
|
<li><strong>C2 - Exposition :</strong> Fréquence et quantité de consommation du produit</li> |
|
|
<li><strong>C3 - Dose-Réponse :</strong> Quantité minimale de pathogène nécessaire pour déclencher la maladie</li> |
|
|
<li><strong>C4 - Croissance :</strong> Capacité du pathogène à survivre/proliférer dans l'aliment</li> |
|
|
<li><strong>C5 - Traitement :</strong> Efficacité des procédés de décontamination</li> |
|
|
<li><strong>C6 - Contamination :</strong> Probabilité de contamination à la production</li> |
|
|
<li><strong>C7 - Consommation :</strong> Mode de préparation (cru, cuit, transformé)</li> |
|
|
</ul> |
|
|
<p>Le <strong>score final</strong> est calculé en multipliant ces 7 critères, créant un classement objectif des risques.</p> |
|
|
</div> |
|
|
|
|
|
<div class="methodology-section"> |
|
|
<h4>📊 Pourquoi c'est révolutionnaire ?</h4> |
|
|
<p>Avant ce modèle, les décisions étaient souvent basées sur :</p> |
|
|
<ul> |
|
|
<li>❌ L'intuition ou l'expérience personnelle</li> |
|
|
<li>❌ Les crises médiatiques du moment</li> |
|
|
<li>❌ Des approches fragmentées par produit OU par pathogène</li> |
|
|
</ul> |
|
|
<p>Le modèle FDA apporte :</p> |
|
|
<ul> |
|
|
<li>✅ Une approche <strong>scientifique et quantitative</strong></li> |
|
|
<li>✅ Une vision <strong>globale</strong> produit × pathogène</li> |
|
|
<li>✅ Une <strong>transparence</strong> dans les décisions de priorisation</li> |
|
|
<li>✅ Une <strong>comparabilité</strong> entre différents risques</li> |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
<div class="haccp-integration"> |
|
|
<h5>🛡️ Intégration avec votre Plan HACCP</h5> |
|
|
<p><strong>Ce modèle ne remplace pas HACCP, il le renforce !</strong></p> |
|
|
<ul> |
|
|
<li><strong>Analyse des Dangers :</strong> Utilisez les scores pour prioriser vos dangers significatifs (étape 1 HACCP)</li> |
|
|
<li><strong>Points Critiques :</strong> Concentrez vos CCP sur les couples à haut score</li> |
|
|
<li><strong>Surveillance :</strong> Adaptez la fréquence de monitoring selon les scores de risque</li> |
|
|
<li><strong>Validation :</strong> Justifiez scientifiquement vos choix de maîtrise</li> |
|
|
<li><strong>Audits :</strong> Orientez vos vérifications sur les zones à plus haut risque</li> |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
<div class="methodology-section"> |
|
|
<h4>💼 Applications Pratiques en Industrie</h4> |
|
|
<ul> |
|
|
<li><strong>Responsables Qualité :</strong> Priorisation des plans de surveillance et contrôles</li> |
|
|
<li><strong>Acheteurs :</strong> Évaluation et sélection des fournisseurs selon les risques</li> |
|
|
<li><strong>R&D :</strong> Conception de produits avec maîtrise des risques intégrée</li> |
|
|
<li><strong>Direction :</strong> Allocation des budgets sécurité alimentaire basée sur les risques réels</li> |
|
|
<li><strong>Auditeurs :</strong> Focus des audits sur les couples produit/danger critiques</li> |
|
|
</ul> |
|
|
</div> |
|
|
|
|
|
<div class="methodology-section"> |
|
|
<h4>🌍 Food Traceability List (FTL)</h4> |
|
|
<p> |
|
|
La <strong>Food Traceability List</strong> est une liste des aliments à plus haut risque, identifiés grâce à ce modèle. |
|
|
Ces produits sont soumis à des <strong>exigences de traçabilité renforcée</strong> aux États-Unis depuis janvier 2026. |
|
|
</p> |
|
|
<div class="highlight-box"> |
|
|
<strong>💡 Conseil :</strong> Même en Europe, surveiller ces produits FTL peut vous donner un avantage concurrentiel et anticiper les évolutions réglementaires. |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="reference-section"> |
|
|
<h5>📚 Sources et Références Officielles</h5> |
|
|
<ul class="reference-list"> |
|
|
<li> |
|
|
<a href="https://www.fda.gov/food/cfsan-constituent-updates/fda-releases-risk-ranking-model-food-tracing-final-rule" target="_blank"> |
|
|
FDA Risk-Ranking Model for Food Tracing Final Rule (2022) |
|
|
</a> |
|
|
</li> |
|
|
<li> |
|
|
<a href="https://hfpappexternal.fda.gov/scripts/FDARiskRankingModelforFoodTracingfinalrule/" target="_blank"> |
|
|
Outil Officiel FDA Risk-Ranking Model (Interface Interactive) |
|
|
</a> |
|
|
</li> |
|
|
<li> |
|
|
<a href="https://www.federalregister.gov/documents/2022/11/21/2022-24417/requirements-for-additional-traceability-records-for-certain-foods" target="_blank"> |
|
|
Federal Register - Règlement Traçabilité Alimentaire (21 CFR 204) |
|
|
</a> |
|
|
</li> |
|
|
<li> |
|
|
<a href="https://www.fda.gov/media/161908/download" target="_blank"> |
|
|
Document Technique FDA : "Risk-Ranking Model for Food Tracing" (PDF) |
|
|
</a> |
|
|
</li> |
|
|
<li> |
|
|
<strong>Publications Scientifiques :</strong> Journal of Food Protection, Risk Analysis, Food Control - rechercher "FDA risk-ranking model" |
|
|
</li> |
|
|
</ul> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="controls-panel"> |
|
|
<div class="view-selector"> |
|
|
<button class="view-btn active" id="btn-ftl">Vue FTL</button> |
|
|
<button class="view-btn" id="btn-all">Vue Globale</button> |
|
|
<button class="view-btn" id="btn-pairs">Analyse Détaillée</button> |
|
|
<button class="view-btn" id="btn-comparison">Analyse Comparative</button> |
|
|
</div> |
|
|
|
|
|
<div id="main-controls"> |
|
|
<div class="controls-grid"> |
|
|
<div class="search-container"> |
|
|
<span class="search-icon">🔍</span> |
|
|
<input type="search" class="search-input" id="searchInput" placeholder="Rechercher..."> |
|
|
</div> |
|
|
<div id="hazard-filter-container" style="display: none;"> |
|
|
<select class="filter-select" id="hazard-filter"> |
|
|
<option value="">Tous les Dangers</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="comparison-controls" style="display: none;"> |
|
|
<div class="controls-grid" style="grid-template-columns: 1fr 1fr;"> |
|
|
<select class="filter-select" id="commodity-filter"></select> |
|
|
<select class="filter-select" id="comparison-hazard-filter"> |
|
|
<option value="All">Tous les Dangers</option> |
|
|
</select> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="main-view-content" class="active"> |
|
|
<div class="dashboard"> |
|
|
<div class="metric-card"> |
|
|
<div class="metric-label">Produits Affichés</div> |
|
|
<div class="metric-value" id="total-products">-</div> |
|
|
</div> |
|
|
<div class="metric-card high-risk"> |
|
|
<div class="metric-label">Haut Risque (≥400)</div> |
|
|
<div class="metric-value" id="high-risk-count">-</div> |
|
|
</div> |
|
|
<div class="metric-card"> |
|
|
<div class="metric-label">Catégories Uniques</div> |
|
|
<div class="metric-value" id="categories-count">-</div> |
|
|
</div> |
|
|
<div class="metric-card"> |
|
|
<div class="metric-label">Score Moyen</div> |
|
|
<div class="metric-value" id="avg-score">-</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="detail-panel"> |
|
|
<div class="detail-card"> |
|
|
<div class="detail-layout"> |
|
|
<div> |
|
|
<h3 class="chart-title" id="criteria-chart-title"></h3> |
|
|
<div class="chart-canvas-container"> |
|
|
<canvas id="criteriaChart"></canvas> |
|
|
</div> |
|
|
</div> |
|
|
<div id="takeaways-section"> |
|
|
<h4>Points de Vigilance & Actions</h4> |
|
|
<ul id="takeaways-list"></ul> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="table-container"> |
|
|
<div class="table-header"> |
|
|
<h3 class="table-title" id="table-title"></h3> |
|
|
<div class="results-count" id="results-count"></div> |
|
|
</div> |
|
|
<div class="table-wrapper"> |
|
|
<table id="dataTable"> |
|
|
<thead id="tableHead"></thead> |
|
|
<tbody id="tableBody"></tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="comparison-view-content" style="display:none;"> |
|
|
<div class="chart-container"> |
|
|
<h3 class="chart-title" id="stacked-chart-title">Analyse des Critères Pondérés</h3> |
|
|
<div class="chart-canvas-container" id="comparison-chart-container"> |
|
|
<canvas id="stackedBarChart"></canvas> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="footer"> |
|
|
<p>FDA Risk Analyzer - Outil d'Analyse des Risques Alimentaires basé sur le modèle de la FDA</p> |
|
|
<p>© 2023 Tous droits réservés</p> |
|
|
</div> |
|
|
|
|
|
<div id="guide-modal" class="modal"> |
|
|
<div class="modal-content"> |
|
|
<div class="modal-header"> |
|
|
<h2 class="modal-title">Guide d'Utilisation</h2> |
|
|
<span class="close-modal" id="close-guide-btn">×</span> |
|
|
</div> |
|
|
|
|
|
<h3>Comment utiliser cet outil</h3> |
|
|
<p>Cet outil vous aide à explorer le modèle de classement des risques de la FDA pour prioriser vos efforts en sécurité alimentaire.</p> |
|
|
|
|
|
<h4>Les Vues d'Analyse</h4> |
|
|
<ul> |
|
|
<li><strong>Vue FTL</strong> Se concentre sur les produits à plus haut risque de la Food Traceability List. Idéal pour les audits.</li> |
|
|
<li><strong>Vue Globale</strong> Affiche tous les produits de la base de données pour une vue d'ensemble.</li> |
|
|
<li><strong>Analyse Détaillée</strong> Montre les couples produit/danger. Cliquez sur une ligne pour une analyse approfondie (graphique radar et actions recommandées).</li> |
|
|
<li><strong>Analyse Comparative</strong> Permet de comparer visuellement les contributions des différents facteurs de risque pour un produit et ses dangers.</li> |
|
|
</ul> |
|
|
|
|
|
<h3>Les 7 Critères d'Évaluation de la FDA</h3> |
|
|
<div class="criteria-grid"> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C1: Gravité</div> |
|
|
<div class="criteria-desc">Gravité des effets sur la santé (mortalité, morbidité, hospitalisation).</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C2: Exposition</div> |
|
|
<div class="criteria-desc">Probabilité d'exposition au danger (fréquence de consommation, quantité).</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C3: Dose-Réponse</div> |
|
|
<div class="criteria-desc">Dose infectieuse minimale requise pour provoquer la maladie.</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C4: Croissance</div> |
|
|
<div class="criteria-desc">Capacité du pathogène à croître/survivre dans l'aliment.</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C5: Traitement</div> |
|
|
<div class="criteria-desc">Efficacité des traitements de décontamination appliqués.</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C6: Contamination</div> |
|
|
<div class="criteria-desc">Probabilité de contamination à la production (sources, environnement).</div> |
|
|
</div> |
|
|
<div class="criteria-card"> |
|
|
<div class="criteria-title">C7: Consommation</div> |
|
|
<div class="criteria-desc">Mode de préparation/consommation (cru, cuit, transformé).</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="modal-footer"> |
|
|
Source officielle : |
|
|
<a href="https://hfpappexternal.fda.gov/scripts/FDARiskRankingModelforFoodTracingfinalrule/" target="_blank" rel="noopener noreferrer"> |
|
|
FDA Risk-Ranking Model |
|
|
</a>. |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', function() { |
|
|
|
|
|
const state = { |
|
|
currentDataset: 'ftl', |
|
|
searchTerm: '', |
|
|
hazardFilter: '', |
|
|
sortColumn: 'agg_score', |
|
|
sortDirection: 'desc', |
|
|
selectedCommodity: '', |
|
|
selectedHazard: 'All', |
|
|
charts: {} |
|
|
}; |
|
|
|
|
|
|
|
|
let fdaData = { |
|
|
aggregated_ftl: [], |
|
|
aggregated_all: [], |
|
|
pairs_all: [] |
|
|
}; |
|
|
|
|
|
const viewConfig = { |
|
|
'ftl': { |
|
|
dataKey: 'aggregated_ftl', |
|
|
title: 'Vue FTL (Produits à Traçabilité Renforcée)', |
|
|
columns: [ |
|
|
{ key: 'commodity', label: 'Produit', sortable: true }, |
|
|
{ key: 'commodity_category', label: 'Catégorie', sortable: true }, |
|
|
{ key: 'agg_score', label: 'Score', sortable: true, type: 'score' } |
|
|
], |
|
|
scoreColumn: 'agg_score' |
|
|
}, |
|
|
'all': { |
|
|
dataKey: 'aggregated_all', |
|
|
title: 'Vue Globale (Tous les Produits)', |
|
|
columns: [ |
|
|
{ key: 'commodity', label: 'Produit', sortable: true }, |
|
|
{ key: 'commodity_category', label: 'Catégorie', sortable: true }, |
|
|
{ key: 'agg_score', label: 'Score', sortable: true, type: 'score' }, |
|
|
{ key: 'ftl', label: 'FTL', sortable: true, type: 'boolean' } |
|
|
], |
|
|
scoreColumn: 'agg_score' |
|
|
}, |
|
|
'pairs': { |
|
|
dataKey: 'pairs_all', |
|
|
title: 'Analyse Détaillée (Produit x Danger)', |
|
|
columns: [ |
|
|
{ key: 'commodity', label: 'Produit', sortable: true }, |
|
|
{ key: 'hazardRef', label: 'Danger', sortable: true }, |
|
|
{ key: 'risk_score', label: 'Score', sortable: true, type: 'score' } |
|
|
], |
|
|
scoreColumn: 'risk_score' |
|
|
} |
|
|
}; |
|
|
|
|
|
const el = { |
|
|
loadingOverlay: document.getElementById('loading-overlay'), |
|
|
mainViewContent: document.getElementById('main-view-content'), |
|
|
comparisonViewContent: document.getElementById('comparison-view-content'), |
|
|
mainControls: document.getElementById('main-controls'), |
|
|
comparisonControls: document.getElementById('comparison-controls'), |
|
|
searchInput: document.getElementById('searchInput'), |
|
|
hazardFilter: document.getElementById('hazard-filter'), |
|
|
hazardFilterContainer: document.getElementById('hazard-filter-container'), |
|
|
commodityFilter: document.getElementById('commodity-filter'), |
|
|
comparisonHazardFilter: document.getElementById('comparison-hazard-filter'), |
|
|
buttons: { |
|
|
ftl: document.getElementById('btn-ftl'), |
|
|
all: document.getElementById('btn-all'), |
|
|
pairs: document.getElementById('btn-pairs'), |
|
|
comparison: document.getElementById('btn-comparison') |
|
|
}, |
|
|
metrics: { |
|
|
totalProducts: document.getElementById('total-products'), |
|
|
highRiskCount: document.getElementById('high-risk-count'), |
|
|
categoriesCount: document.getElementById('categories-count'), |
|
|
avgScore: document.getElementById('avg-score') |
|
|
}, |
|
|
detailPanel: document.getElementById('detail-panel'), |
|
|
criteriaChartTitle: document.getElementById('criteria-chart-title'), |
|
|
criteriaCanvas: document.getElementById('criteriaChart'), |
|
|
takeawaysSection: document.getElementById('takeaways-section'), |
|
|
takeawaysList: document.getElementById('takeaways-list'), |
|
|
tableTitle: document.getElementById('table-title'), |
|
|
tableHead: document.getElementById('tableHead'), |
|
|
tableBody: document.getElementById('tableBody'), |
|
|
resultsCount: document.getElementById('results-count'), |
|
|
stackedBarChartCanvas: document.getElementById('stackedBarChart'), |
|
|
stackedChartTitle: document.getElementById('stacked-chart-title'), |
|
|
welcomeGuide: document.getElementById('welcome-guide'), |
|
|
closeGuide: document.getElementById('close-guide'), |
|
|
guideModal: document.getElementById('guide-modal'), |
|
|
guideBtn: document.getElementById('guide-btn'), |
|
|
closeGuideBtn: document.getElementById('close-guide-btn') |
|
|
}; |
|
|
|
|
|
const formatCell = (value, type) => { |
|
|
if (value === undefined || value === null) return ''; |
|
|
const cleanValue = String(value); |
|
|
if (type === 'score') return `<span class="score-badge ${parseInt(cleanValue) >= 400 ? 'score-high' : parseInt(cleanValue) >= 200 ? 'score-medium' : 'score-low'}">${cleanValue}</span>`; |
|
|
if (type === 'boolean') return cleanValue === 'true' ? '<span style="color: var(--success-color); font-weight: 600;">✓</span>' : '<span style="color: var(--text-secondary);">-</span>'; |
|
|
return cleanValue; |
|
|
}; |
|
|
|
|
|
const filterAndSortData = () => { |
|
|
const config = viewConfig[state.currentDataset]; |
|
|
if (!config) return []; |
|
|
let data = [...fdaData[config.dataKey]]; |
|
|
|
|
|
if (state.searchTerm) { |
|
|
const search = state.searchTerm.toLowerCase(); |
|
|
data = data.filter(row => |
|
|
Object.values(row).some(v => |
|
|
String(v).toLowerCase().includes(search) |
|
|
) |
|
|
); |
|
|
} |
|
|
|
|
|
if (state.currentDataset === 'pairs' && state.hazardFilter) { |
|
|
data = data.filter(row => row.hazardRef === state.hazardFilter); |
|
|
} |
|
|
|
|
|
if (state.sortColumn) { |
|
|
const isNumeric = config.columns.find(c => c.key === state.sortColumn)?.type === 'score'; |
|
|
data.sort((a, b) => { |
|
|
const valA = a[state.sortColumn]; |
|
|
const valB = b[state.sortColumn]; |
|
|
|
|
|
if (isNumeric) { |
|
|
return state.sortDirection === 'asc' ? |
|
|
(valA || 0) - (valB || 0) : |
|
|
(valB || 0) - (valA || 0); |
|
|
} else { |
|
|
const strA = String(valA || '').toLowerCase(); |
|
|
const strB = String(valB || '').toLowerCase(); |
|
|
return state.sortDirection === 'asc' ? |
|
|
strA.localeCompare(strB) : |
|
|
strB.localeCompare(strA); |
|
|
} |
|
|
}); |
|
|
} |
|
|
|
|
|
return data; |
|
|
}; |
|
|
|
|
|
const render = () => { |
|
|
const isComparisonView = state.currentDataset === 'comparison'; |
|
|
el.mainViewContent.style.display = isComparisonView ? 'none' : 'block'; |
|
|
el.comparisonViewContent.style.display = isComparisonView ? 'block' : 'none'; |
|
|
el.mainControls.style.display = isComparisonView ? 'none' : 'block'; |
|
|
el.comparisonControls.style.display = isComparisonView ? 'block' : 'none'; |
|
|
|
|
|
Object.values(el.buttons).forEach(btn => btn.classList.remove('active')); |
|
|
el.buttons[state.currentDataset].classList.add('active'); |
|
|
|
|
|
if (isComparisonView) { |
|
|
renderComparisonView(); |
|
|
} else { |
|
|
renderMainView(); |
|
|
} |
|
|
}; |
|
|
|
|
|
const renderMainView = () => { |
|
|
const config = viewConfig[state.currentDataset]; |
|
|
const data = filterAndSortData(); |
|
|
|
|
|
el.tableTitle.textContent = config.title; |
|
|
el.resultsCount.textContent = `${data.length} résultat(s)`; |
|
|
|
|
|
|
|
|
el.tableHead.innerHTML = `<tr>${config.columns.map(c => |
|
|
`<th class="${c.sortable ? 'sortable' : ''}" data-column="${c.key}">${c.label}</th>` |
|
|
).join('')}</tr>`; |
|
|
|
|
|
|
|
|
document.querySelectorAll('#tableHead th').forEach(th => { |
|
|
th.classList.remove('sort-asc', 'sort-desc'); |
|
|
if (th.dataset.column === state.sortColumn) { |
|
|
th.classList.add(`sort-${state.sortDirection}`); |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
el.tableBody.innerHTML = data.length === 0 ? |
|
|
`<tr><td colspan="${config.columns.length}" style="text-align:center;">🚫 Aucun résultat</td></tr>` : |
|
|
data.map((row, index) => |
|
|
`<tr class="${state.currentDataset === 'pairs' ? 'clickable' : ''}" data-index="${index}"> |
|
|
${config.columns.map(col => |
|
|
`<td>${formatCell(row[col.key], col.type)}</td>` |
|
|
).join('')} |
|
|
</tr>` |
|
|
).join(''); |
|
|
|
|
|
updateDashboard(data); |
|
|
el.hazardFilterContainer.style.display = state.currentDataset === 'pairs' ? 'flex' : 'none'; |
|
|
|
|
|
if (state.currentDataset !== 'pairs') { |
|
|
el.detailPanel.classList.remove('visible'); |
|
|
} |
|
|
}; |
|
|
|
|
|
const renderComparisonView = () => { |
|
|
let data = fdaData.pairs_all.filter(p => p.commodity === state.selectedCommodity); |
|
|
if (state.selectedHazard !== 'All') { |
|
|
data = data.filter(p => p.hazardRef === state.selectedHazard); |
|
|
} |
|
|
|
|
|
el.stackedChartTitle.textContent = `Analyse des Critères Pondérés pour : ${state.selectedCommodity}`; |
|
|
|
|
|
if (state.charts.stackedBar) { |
|
|
state.charts.stackedBar.destroy(); |
|
|
} |
|
|
|
|
|
const criteria = ['C1w', 'C2w', 'C3w', 'C4w', 'C5w', 'C6w', 'C7w']; |
|
|
const labels = [ |
|
|
'C1: Gravité', |
|
|
'C2: Exposition', |
|
|
'C3: Dose-Réponse', |
|
|
'C4: Croissance', |
|
|
'C5: Traitement', |
|
|
'C6: Contamination', |
|
|
'C7: Consommation' |
|
|
]; |
|
|
const colors = [ |
|
|
'#C62828', '#AD1457', '#6A1B9A', |
|
|
'#4527A0', '#283593', '#1565C0', '#0277BD' |
|
|
]; |
|
|
|
|
|
if (data.length === 0) { |
|
|
document.getElementById('comparison-chart-container').innerHTML = '<p style="text-align:center; padding:2rem;">Aucune donnée disponible pour cette sélection</p>'; |
|
|
return; |
|
|
} |
|
|
|
|
|
state.charts.stackedBar = new Chart(el.stackedBarChartCanvas, { |
|
|
type: 'bar', |
|
|
data: { |
|
|
labels: data.map(d => d.hazardRef), |
|
|
datasets: criteria.map((c, i) => ({ |
|
|
label: labels[i], |
|
|
data: data.map(d => d[c]), |
|
|
backgroundColor: colors[i] |
|
|
})) |
|
|
}, |
|
|
options: { |
|
|
indexAxis: 'y', |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
scales: { |
|
|
x: { |
|
|
stacked: true, |
|
|
title: { |
|
|
display: true, |
|
|
text: 'Score Pondéré' |
|
|
} |
|
|
}, |
|
|
y: { |
|
|
stacked: true |
|
|
} |
|
|
}, |
|
|
plugins: { |
|
|
legend: { |
|
|
position: 'top' |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
}; |
|
|
|
|
|
const updateDashboard = (data) => { |
|
|
const scoreCol = viewConfig[state.currentDataset].scoreColumn; |
|
|
const scores = data.map(i => i[scoreCol]).filter(s => typeof s === 'number'); |
|
|
|
|
|
el.metrics.totalProducts.textContent = data.length; |
|
|
el.metrics.highRiskCount.textContent = scores.filter(s => s >= 400).length; |
|
|
el.metrics.categoriesCount.textContent = new Set(data.map(i => i.commodity_category).filter(Boolean)).size; |
|
|
el.metrics.avgScore.textContent = scores.length ? Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) : 'N/A'; |
|
|
}; |
|
|
|
|
|
const showDetailPanel = (rowIndex) => { |
|
|
const data = filterAndSortData()[rowIndex]; |
|
|
if (!data || !('C1' in data)) return; |
|
|
|
|
|
el.detailPanel.classList.add('visible'); |
|
|
el.criteriaCanvas.style.display = 'block'; |
|
|
el.takeawaysSection.style.display = 'block'; |
|
|
el.criteriaChartTitle.textContent = `Analyse de "${data.commodity}"`; |
|
|
|
|
|
if (state.charts.criteria) { |
|
|
state.charts.criteria.destroy(); |
|
|
} |
|
|
|
|
|
|
|
|
const cleanedProduct = data.commodity.replace(/<[^>]+>/g, ''); |
|
|
const cleanedHazard = data.hazardRef.replace(/<[^>]+>/g, ''); |
|
|
el.criteriaChartTitle.textContent = `Analyse "${cleanedProduct}" × Danger "${cleanedHazard}"`; |
|
|
|
|
|
state.charts.criteria = new Chart(el.criteriaCanvas, { |
|
|
type: 'radar', |
|
|
data: { |
|
|
labels: [ |
|
|
'C1-Gravité', |
|
|
'C2-Exposition', |
|
|
'C3-Dose-Réponse', |
|
|
'C4-Croissance', |
|
|
'C5-Traitement', |
|
|
'C6-Contamination', |
|
|
'C7-Consommation' |
|
|
], |
|
|
datasets: [{ |
|
|
label: `${cleanedHazard}`, |
|
|
data: [data.C1, data.C2, data.C3, data.C4, data.C5, data.C6, data.C7], |
|
|
backgroundColor: 'rgba(0, 51, 102, 0.2)', |
|
|
borderColor: 'rgba(0, 51, 102, 1)', |
|
|
pointBackgroundColor: 'rgba(0, 51, 102, 1)' |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
scales: { |
|
|
r: { |
|
|
beginAtZero: true, |
|
|
max: 9, |
|
|
ticks: { |
|
|
stepSize: 3 |
|
|
} |
|
|
} |
|
|
}, |
|
|
plugins: { |
|
|
legend: { |
|
|
display: false |
|
|
} |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
const takeaways = []; |
|
|
if (data.C1 > 6) takeaways.push("Le danger associé est <strong>sévère</strong>. La maîtrise du CCP est critique."); |
|
|
if (data.C2 > 6) takeaways.push("Le produit est <strong>largement consommé</strong>. L'impact d'un rappel serait majeur."); |
|
|
if (data.C4 > 6) takeaways.push("Le pathogène peut <strong>proliférer rapidement</strong>. <u>Action</u>: Valider la chaîne du froid et les barèmes de DLC/DDM."); |
|
|
if (data.C5 < 3) takeaways.push("Pas d'<strong>étape d'assainissement</strong> efficace. <u>Action</u>: Maîtrise des matières premières essentielle."); |
|
|
if (data.C6 > 6) takeaways.push("Risque de <strong>contamination à la source élevé</strong>. <u>Action</u>: Renforcer audits fournisseurs et contrôles à réception."); |
|
|
if (data.C7 > 6) takeaways.push("Souvent <strong>consommé cru</strong>. Contrôles microbiologiques du produit fini à considérer."); |
|
|
|
|
|
el.takeawaysList.innerHTML = takeaways.length ? |
|
|
takeaways.map(t => `<li><span class="icon">⚠️</span><span>${t}</span></li>`).join('') : |
|
|
"<li><span class='icon'>✓</span><span>Le risque est équilibré.</span></li>"; |
|
|
|
|
|
document.querySelectorAll('#tableBody tr').forEach(tr => tr.classList.remove('selected')); |
|
|
document.querySelector(`#tableBody tr[data-index="${rowIndex}"]`)?.classList.add('selected'); |
|
|
el.detailPanel.scrollIntoView({ behavior: 'smooth', block: 'start' }); |
|
|
}; |
|
|
|
|
|
function populateFilters() { |
|
|
const mainHazards = [...new Set(fdaData.pairs_all.map(item => item.hazardRef))].filter(Boolean).sort(); |
|
|
el.hazardFilter.innerHTML = '<option value="">Tous les Dangers</option>' + |
|
|
mainHazards.map(h => `<option value="${h}">${h}</option>`).join(''); |
|
|
|
|
|
const commodities = [...new Set(fdaData.pairs_all.map(item => item.commodity))].sort(); |
|
|
el.commodityFilter.innerHTML = commodities.map(c => `<option value="${c}">${c}</option>`).join(''); |
|
|
|
|
|
if (commodities.length > 0) { |
|
|
state.selectedCommodity = commodities[0]; |
|
|
el.commodityFilter.value = state.selectedCommodity; |
|
|
populateComparisonHazardFilter(); |
|
|
} |
|
|
} |
|
|
|
|
|
function populateComparisonHazardFilter(){ |
|
|
const hazards = [...new Set( |
|
|
fdaData.pairs_all |
|
|
.filter(p => p.commodity === state.selectedCommodity) |
|
|
.map(p => p.hazardRef) |
|
|
)].sort(); |
|
|
|
|
|
el.comparisonHazardFilter.innerHTML = '<option value="All">Tous les Dangers</option>' + |
|
|
hazards.map(h => `<option value="${h}">${h}</option>`).join(''); |
|
|
} |
|
|
|
|
|
function setupEventListeners() { |
|
|
|
|
|
if (!el.searchInput || !el.hazardFilter) { |
|
|
console.error('Certains éléments DOM ne sont pas trouvés'); |
|
|
return; |
|
|
} |
|
|
|
|
|
|
|
|
console.log('Methodology elements check:'); |
|
|
console.log('Header:', el.methodologyHeader); |
|
|
console.log('Content:', el.methodologyContent); |
|
|
console.log('Toggle:', el.methodologyToggle); |
|
|
|
|
|
el.searchInput.addEventListener('input', () => { |
|
|
state.searchTerm = el.searchInput.value; |
|
|
render(); |
|
|
}); |
|
|
|
|
|
el.hazardFilter.addEventListener('change', () => { |
|
|
state.hazardFilter = el.hazardFilter.value; |
|
|
render(); |
|
|
}); |
|
|
|
|
|
el.tableHead.addEventListener('click', (e) => { |
|
|
const th = e.target.closest('th[data-column]'); |
|
|
if (th) { |
|
|
state.sortColumn = th.dataset.column; |
|
|
state.sortDirection = state.sortDirection === 'asc' ? 'desc' : 'asc'; |
|
|
render(); |
|
|
} |
|
|
}); |
|
|
|
|
|
el.tableBody.addEventListener('click', (e) => { |
|
|
if (state.currentDataset === 'pairs') { |
|
|
const row = e.target.closest('tr'); |
|
|
if (row) showDetailPanel(row.dataset.index); |
|
|
} |
|
|
}); |
|
|
|
|
|
Object.keys(el.buttons).forEach(key => { |
|
|
el.buttons[key].addEventListener('click', () => { |
|
|
state.currentDataset = key; |
|
|
if (key !== 'comparison') { |
|
|
state.sortColumn = viewConfig[key].scoreColumn; |
|
|
} |
|
|
Object.assign(state, { |
|
|
searchTerm: '', |
|
|
hazardFilter: '', |
|
|
sortDirection: 'desc' |
|
|
}); |
|
|
el.searchInput.value = ''; |
|
|
el.hazardFilter.value = ''; |
|
|
el.detailPanel.classList.remove('visible'); |
|
|
render(); |
|
|
}); |
|
|
}); |
|
|
|
|
|
el.commodityFilter.addEventListener('change', (e) => { |
|
|
state.selectedCommodity = e.target.value; |
|
|
state.selectedHazard = 'All'; |
|
|
el.comparisonHazardFilter.value = 'All'; |
|
|
populateComparisonHazardFilter(); |
|
|
renderComparisonView(); |
|
|
}); |
|
|
|
|
|
el.comparisonHazardFilter.addEventListener('change', (e) => { |
|
|
state.selectedHazard = e.target.value; |
|
|
renderComparisonView(); |
|
|
}); |
|
|
|
|
|
el.guideBtn.addEventListener('click', () => { |
|
|
el.guideModal.style.display = 'block'; |
|
|
}); |
|
|
|
|
|
el.closeGuideBtn.addEventListener('click', () => { |
|
|
el.guideModal.style.display = 'none'; |
|
|
}); |
|
|
|
|
|
window.addEventListener('click', (event) => { |
|
|
if (event.target == el.guideModal) { |
|
|
el.guideModal.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
|
|
|
if (localStorage.getItem('hideWelcomeGuide')) { |
|
|
el.welcomeGuide.style.display = 'none'; |
|
|
} |
|
|
|
|
|
if (el.closeGuide) { |
|
|
el.closeGuide.addEventListener('click', () => { |
|
|
el.welcomeGuide.style.display = 'none'; |
|
|
try { |
|
|
localStorage.setItem('hideWelcomeGuide', 'true'); |
|
|
} catch (e) {} |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
if (el.methodologyHeader && el.methodologyContent && el.methodologyToggle) { |
|
|
console.log('Setting up methodology toggle'); |
|
|
el.methodologyHeader.addEventListener('click', () => { |
|
|
console.log('Methodology header clicked'); |
|
|
const isExpanded = el.methodologyContent.classList.contains('expanded'); |
|
|
console.log('Is expanded:', isExpanded); |
|
|
|
|
|
if (isExpanded) { |
|
|
el.methodologyContent.classList.remove('expanded'); |
|
|
el.methodologyToggle.classList.remove('expanded'); |
|
|
console.log('Collapsed'); |
|
|
} else { |
|
|
el.methodologyContent.classList.add('expanded'); |
|
|
el.methodologyToggle.classList.add('expanded'); |
|
|
console.log('Expanded'); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
console.error('Methodology elements missing:', { |
|
|
header: !!el.methodologyHeader, |
|
|
content: !!el.methodologyContent, |
|
|
toggle: !!el.methodologyToggle |
|
|
}); |
|
|
|
|
|
|
|
|
setTimeout(() => { |
|
|
const header = document.getElementById('methodology-header'); |
|
|
const content = document.getElementById('methodology-content'); |
|
|
const toggle = document.getElementById('methodology-toggle'); |
|
|
|
|
|
if (header && content && toggle) { |
|
|
console.log('Retry: Setting up methodology toggle'); |
|
|
header.addEventListener('click', () => { |
|
|
console.log('Methodology header clicked (retry)'); |
|
|
const isExpanded = content.classList.contains('expanded'); |
|
|
|
|
|
if (isExpanded) { |
|
|
content.classList.remove('expanded'); |
|
|
toggle.classList.remove('expanded'); |
|
|
} else { |
|
|
content.classList.add('expanded'); |
|
|
toggle.classList.add('expanded'); |
|
|
} |
|
|
}); |
|
|
} else { |
|
|
console.error('Methodology elements still not found after retry'); |
|
|
} |
|
|
}, 100); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function loadAllData() { |
|
|
try { |
|
|
|
|
|
const [ftlResponse, allResponse, pairsResponse] = await Promise.all([ |
|
|
fetch('fda_aggregated_ftl_20250813_164803.json'), |
|
|
fetch('fda_aggregated_all_20250813_164803.json'), |
|
|
fetch('fda_pairs_all_20250813_164803.json') |
|
|
]); |
|
|
|
|
|
if (!ftlResponse.ok || !allResponse.ok || !pairsResponse.ok) { |
|
|
throw new Error('Erreur lors du chargement des fichiers JSON'); |
|
|
} |
|
|
|
|
|
fdaData.aggregated_ftl = await ftlResponse.json(); |
|
|
fdaData.aggregated_all = await allResponse.json(); |
|
|
fdaData.pairs_all = await pairsResponse.json(); |
|
|
|
|
|
|
|
|
populateFilters(); |
|
|
render(); |
|
|
|
|
|
|
|
|
el.loadingOverlay.style.display = 'none'; |
|
|
} catch (error) { |
|
|
console.error('Erreur lors du chargement des données:', error); |
|
|
el.loadingOverlay.innerHTML = ` |
|
|
<div class="error-message"> |
|
|
<h2>Erreur de chargement</h2> |
|
|
<p>Impossible de charger les fichiers de données.</p> |
|
|
<p>Vérifiez que les fichiers suivants sont présents dans le répertoire:</p> |
|
|
<ul style="text-align: left; margin: 1rem auto; max-width: 400px;"> |
|
|
<li>fda_aggregated_ftl_20250813_164803.json</li> |
|
|
<li>fda_aggregated_all_20250813_164803.json</li> |
|
|
<li>fda_pairs_all_20250813_164803.json</li> |
|
|
</ul> |
|
|
<p>Message d'erreur : ${error.message}</p> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
} |
|
|
|
|
|
setupEventListeners(); |
|
|
loadAllData(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|