${d.name}
${d.share} %
${d.desc}
`; } nodes.on('mouseenter', function(e,d){ svg.selectAll('.country').classed('dim',true); d3.select(this).classed('dim',false); updateInfo(d); }).on('mouseleave', function(){ svg.selectAll('.country').classed('dim',false); }); ScrollTrigger.create({ trigger:'#ssqCarto', start:'top 75%', onEnter:()=>{ nodes.transition().duration(800).delay((d,i)=>i*60) .attr('opacity',1); } }); })(); /* ============================================================ VIZ 03 — ANIMATED HORIZONTAL BARS ============================================================ */ (function(){ const container = document.getElementById('ssqBars'); if(!container) return; const rowsEl = document.getElementById('barsRows'); const ctx = document.getElementById('barsContext'); const tabs = document.querySelectorAll('#barsTabs .viz-bars-year-tab'); const flags = { us: '', ru: '', ua: '', eu: '', ca: '', au: '', ar: '' }; const data = { 1975: { rows: [ {key:'us', name:'United States', val:48, color:C.sky}, {key:'ca', name:'Canada', val:18, color:C.wheatDeep}, {key:'eu', name:'France & EU', val:11, color:C.olive}, {key:'au', name:'Australia', val:9, color:C.earth}, {key:'ar', name:'Argentina', val:6, color:C.skySoft}, {key:'ru', name:'USSR/Russia', val:4, color:C.rust}, {key:'ua', name:'Ukraine', val:1, color:C.wheat} ], ctx:'American hegemonyIn the aftermath of the oil shock, the United States dominates nearly half of the world market. Wheat is a diplomatic weapon of the Cold War.' }, 1990: { rows: [ {key:'us', name:'United States', val:35, color:C.sky}, {key:'eu', name:'France & EU', val:18, color:C.olive}, {key:'ca', name:'Canada', val:17, color:C.wheatDeep}, {key:'au', name:'Australia', val:11, color:C.earth}, {key:'ar', name:'Argentina', val:8, color:C.skySoft}, {key:'ru', name:'USSR/Russia', val:5, color:C.rust}, {key:'ua', name:'Ukraine', val:2, color:C.wheat} ], ctx:'Europe emergesThe European CAP transforms the EU into the world\'s second exporter. The American share erodes but remains dominant.' }, 2005: { rows: [ {key:'us', name:'United States', val:25, color:C.sky}, {key:'eu', name:'France & EU', val:16, color:C.olive}, {key:'ca', name:'Canada', val:14, color:C.wheatDeep}, {key:'ru', name:'Russia', val:13, color:C.rust}, {key:'au', name:'Australia', val:11, color:C.earth}, {key:'ar', name:'Argentina', val:9, color:C.skySoft}, {key:'ua', name:'Ukraine', val:6, color:C.wheat} ], ctx:'The Black Sea emergesPost-Soviet Russia reinvests in agriculture and becomes a major player. Ukraine follows the same upward trajectory.' }, 2015: { rows: [ {key:'ru', name:'Russia', val:18, color:C.rust}, {key:'eu', name:'France & EU', val:17, color:C.olive}, {key:'us', name:'United States', val:14, color:C.sky}, {key:'ca', name:'Canada', val:13, color:C.wheatDeep}, {key:'ua', name:'Ukraine', val:11, color:C.wheat}, {key:'au', name:'Australia', val:10, color:C.earth}, {key:'ar', name:'Argentina', val:7, color:C.skySoft} ], ctx:'The shift happensRussia takes the lead. For the first time since the postwar era, the United States is no longer the world\'s leading wheat exporter.' }, 2024: { rows: [ {key:'ru', name:'Russia', val:25, color:C.rust}, {key:'eu', name:'France & EU', val:17, color:C.olive}, {key:'au', name:'Australia', val:13, color:C.earth}, {key:'ca', name:'Canada', val:13, color:C.wheatDeep}, {key:'us', name:'United States', val:11, color:C.sky}, {key:'ua', name:'Ukraine', val:8, color:C.wheat}, {key:'ar', name:'Argentina', val:7, color:C.skySoft} ], ctx:'The Black Sea at the heart of the worldRussia consolidates its leadership with a quarter of global trade. The food security of North Africa and the Middle East now passes through its ports.' } }; let currentYear = 1975; let initialized = false; function render(year, animate){ const d = data[year]; const maxVal = 50; ctx.innerHTML = d.ctx; rowsEl.innerHTML = ''; d.rows.forEach((r, i)=>{ const row = document.createElement('div'); row.className = 'viz-bars-row'; row.innerHTML = ` `; rowsEl.appendChild(row); const fill = row.querySelector('.viz-bars-fill'); setTimeout(()=>{ fill.style.width = (r.val/maxVal*100) + '%'; }, animate ? 80 + i*60 : 50); }); } tabs.forEach(tab=>{ tab.addEventListener('click', ()=>{ tabs.forEach(t=>t.classList.remove('active')); tab.classList.add('active'); currentYear = +tab.dataset.y; render(currentYear, true); }); }); ScrollTrigger.create({ trigger:'#ssqBars', start:'top 75%', onEnter:()=>{ if(!initialized){ render(1975, true); initialized = true; } } }); render(1975, false); })(); /* ============================================================ VIZ 04 — CHORD DIAGRAM ============================================================ */ (function(){ const svg = d3.select('#ssqChord'); if(svg.empty()) return; const W=800, H=700, cx=W/2, cy=H/2, outerR=240, innerR=220; const names = ['Gas', 'Urea / Nitrogen', 'Oil', 'Phosphate', 'Sulfur', 'Wheat', 'Currencies', 'Maritime freight']; const colors = [C.sky, C.olive, C.ink, C.earthSoft, C.wheatDeep, C.wheat, C.rust, C.skySoft]; const matrix = [ [ 0, 28, 0, 0, 0, 0, 12, 4], [ 0, 0, 0, 0, 0, 30, 8, 6], [ 0, 0, 0, 0, 0, 0, 18, 22], [ 0, 0, 0, 0, 14, 0, 6, 4], [ 0, 0, 0, 0, 0, 0, 4, 3], [ 0, 0, 0, 0, 0, 0, 22, 14], [10, 10, 14, 8, 6, 20, 0, 6], [ 6, 4, 18, 4, 3, 12, 0, 0] ]; const chord = d3.chord().padAngle(0.05).sortSubgroups(d3.descending); const chords = chord(matrix); const g = svg.append('g').attr('transform',`translate(${cx},${cy})`); const arc = d3.arc().innerRadius(innerR).outerRadius(outerR); const ribbon = d3.ribbon().radius(innerR); const arcs = g.append('g').selectAll('path').data(chords.groups).enter().append('path') .attr('class','chord-arc') .attr('d',arc) .attr('fill',(d,i)=>colors[i]) .attr('stroke',C.paper).attr('stroke-width',1.5) .attr('opacity',0); const labels = g.append('g').selectAll('text').data(chords.groups).enter().append('text') .each(d=>{ d.angle = (d.startAngle + d.endAngle)/2; }) .attr('dy','.35em') .attr('transform', d=> `rotate(${d.angle*180/Math.PI - 90}) translate(${outerR+14}) ${d.angle>Math.PI?'rotate(180)':''}`) .attr('text-anchor', d=>d.angle>Math.PI?'end':'start') .attr('fill',C.ink) .attr('opacity',0) .text((d,i)=>names[i]); const bands = g.append('g').selectAll('path').data(chords).enter().append('path') .attr('class','chord-band') .attr('d',ribbon) .attr('fill',(d)=>colors[d.source.index]) .attr('fill-opacity',0.55) .attr('stroke',C.paper).attr('stroke-width',0.5) .attr('opacity',0); arcs.on('click', function(e, d){ const idx = d.index; bands.classed('fade', b => b.source.index !== idx && b.target.index !== idx); }); svg.on('click', function(e){ if(e.target.tagName === 'svg') bands.classed('fade', false); }); ScrollTrigger.create({ trigger:'#ssqChord', start:'top 75%', onEnter:()=>{ arcs.transition().duration(800).delay((d,i)=>i*80).attr('opacity',1); labels.transition().duration(600).delay((d,i)=>500+i*80).attr('opacity',1); bands.transition().duration(1000).delay(1200).attr('opacity',1); } }); })(); /* ============================================================ VIZ 05 — STEPS CARDS + CASH MACHINE ROLLER Each digit of the target number scrolls vertically ============================================================ */ (function(){ const cards = document.querySelectorAll('.viz-step-card'); if(!cards.length) return; // Builds the roller for a target number function buildRoller(el){ const target = String(parseInt(el.dataset.target, 10)); const prefix = el.dataset.prefix || ''; const suffix = el.dataset.suffix || ''; el.innerHTML = ''; // Prefix (static) if(prefix){ const ps = document.createElement('span'); ps.className = 'vsm-static'; ps.textContent = prefix; el.appendChild(ps); } // One roller per digit const rollers = []; for(let i=0;i{ const s = document.createElement('span'); s.textContent = n; inner.appendChild(s); }); wrap.appendChild(inner); el.appendChild(wrap); rollers.push({wrap, inner, digit, position: 30 + digit, totalSteps: 40 + digit, colIndex: i}); } // Suffix (static) if(suffix){ const ss = document.createElement('span'); ss.className = 'vsm-static'; ss.textContent = suffix; el.appendChild(ss); } return rollers; } // Launches the roller animation (cash machine effect) function spinRollers(rollers){ rollers.forEach((r, idx)=>{ // Initial position: all at the top (0) r.inner.style.transition = 'none'; r.inner.style.transform = 'translateY(0)'; // Force reflow void r.inner.offsetHeight; // Start offset: left columns start first, // right columns take longer to stop (roller effect) const duration = 1.6 + idx * 0.45; // s const delay = idx * 0.08; // s r.inner.style.transition = `transform ${duration}s cubic-bezier(.18,.65,.2,1) ${delay}s`; // We translate to the final digit // Height of one character = 1em of the container // The final digit is at index r.position r.inner.style.transform = `translateY(-${r.position}em)`; }); } // Initialize all rollers const allRollers = []; document.querySelectorAll('.vsm-num[data-roll]').forEach(el=>{ const rollers = buildRoller(el); allRollers.push({card: el.closest('.viz-step-card'), rollers}); }); const observer = new IntersectionObserver((es)=>{ es.forEach(e=>{ if(e.isIntersecting){ cards.forEach(c=>{ const delay = parseInt(c.dataset.delay||'0', 10); setTimeout(()=>{ c.classList.add('in'); // Launch this card's roller after the appearance const item = allRollers.find(a=>a.card === c); if(item){ setTimeout(()=> spinRollers(item.rollers), 650); } }, delay); }); observer.disconnect(); } }); },{threshold:.15}); observer.observe(cards[0]); })(); /* ============================================================ VIZ 06 — GHOST LINES ============================================================ */ (function(){ const svg = d3.select('#ssqGhost'); if(svg.empty()) return; const W=1100, H=480, m={t:30,r:140,b:50,l:60}; const years = d3.range(2000, 2026); const realPoints = [ [2000,13.5],[2001,11.2],[2002,9.5],[2003,16.8],[2004,15.2], [2005,12.8],[2006,14.5],[2007,11.6],[2008,7.2],[2009,18.3], [2010,15.5],[2011,15.8],[2012,12.4],[2013,17.1],[2014,11.9], [2015,14.8],[2016,8.2],[2017,12.5],[2018,17.6],[2019,15.4], [2020,11.8],[2021,9.7],[2022,8.5],[2023,7.8],[2024,13.2],[2025,14.5] ]; const morocco = years.map((y,i)=> [y, 11 + i*0.28 + Math.sin(i*0.4)*1.2]); const egypt = years.map((y,i)=> [y, 20 + i*0.45 + Math.sin(i*0.3)*0.8]); const irr = years.map((y,i)=> [y, 14 + i*0.48 + Math.sin(i*0.5)*0.7]); const datasets = { real: {data:realPoints, color:C.rust, label:'Actual Tunisia ~14 q/ha'}, ma: {data:morocco, color:C.wheatDeep, label:'Moroccan trajectory ~18'}, eg: {data:egypt, color:C.sky, label:'Egyptian trajectory ~31'}, irr: {data:irr, color:C.olive, label:'Moderate irrigation ~26'} }; const active = new Set(['real']); const x = d3.scaleLinear().domain([2000, 2025]).range([m.l, W-m.r]); const y = d3.scaleLinear().domain([5, 35]).range([H-m.b, m.t]); svg.append('g').attr('transform',`translate(0,${H-m.b})`) .call(d3.axisBottom(x).tickFormat(d3.format('d')).ticks(6)) .selectAll('text').attr('font-family','JetBrains Mono, monospace').attr('font-size',10).attr('fill',C.mute); svg.append('g').attr('transform',`translate(${m.l},0)`) .call(d3.axisLeft(y).ticks(5).tickFormat(d=>d+' q/ha')) .selectAll('text').attr('font-family','JetBrains Mono, monospace').attr('font-size',10).attr('fill',C.mute); svg.selectAll('.tick line, .domain').attr('stroke',C.ink).attr('stroke-opacity',.15); y.ticks(5).forEach(t=>{ svg.append('line').attr('x1',m.l).attr('x2',W-m.r).attr('y1',y(t)).attr('y2',y(t)) .attr('stroke',C.ink).attr('stroke-opacity',.06); }); const line = d3.line().x(d=>x(d[0])).y(d=>y(d[1])).curve(d3.curveMonotoneX); const paths = {}; const labels = {}; Object.entries(datasets).forEach(([k, ds])=>{ const isReal = k === 'real'; const path = svg.append('path').datum(ds.data) .attr('fill','none').attr('stroke',ds.color) .attr('stroke-width', isReal ? 3 : 2) .attr('stroke-dasharray', isReal ? null : '4 5') .attr('opacity', isReal ? 0.95 : 0) .attr('d', line); const len = path.node().getTotalLength(); paths[k] = {path, len, isReal}; const last = ds.data[ds.data.length-1]; labels[k] = svg.append('text') .attr('x', x(last[0])+8).attr('y', y(last[1])+4) .attr('font-family','Cormorant Garamond, serif').attr('font-style','italic') .attr('font-size',12).attr('fill',ds.color).attr('font-weight',600) .attr('opacity', isReal ? 1 : 0).text(ds.label); }); ScrollTrigger.create({ trigger:'#ssqGhost', start:'top 75%', onEnter:()=>{ paths.real.path.attr('stroke-dasharray', paths.real.len+' '+paths.real.len) .attr('stroke-dashoffset', paths.real.len) .transition().duration(1800).attr('stroke-dashoffset', 0) .on('end',()=>paths.real.path.attr('stroke-dasharray', null)); } }); document.querySelectorAll('.viz-ghost-toggle').forEach(btn=>{ btn.addEventListener('click',()=>{ const k = btn.dataset.line; if(active.has(k)){ if(k==='real') return; active.delete(k); btn.classList.remove('active'); paths[k].path.transition().duration(400).attr('opacity',0); labels[k].transition().duration(400).attr('opacity',0); } else { active.add(k); btn.classList.add('active'); paths[k].path.attr('stroke-dasharray', '4 5') .attr('opacity',0) .transition().duration(900).attr('opacity',.75); labels[k].transition().duration(600).delay(400).attr('opacity',1); } }); }); })(); /* ============================================================ VIZ 07 — STRIP HEATMAP ============================================================ */ (function(){ const stripBox = document.getElementById('ssqStrip'); const rowsEl = document.getElementById('stripRows'); const yearsEl = document.getElementById('stripYears'); const tip = document.getElementById('stripTip'); const legendEl = document.getElementById('stripLegend'); if(!stripBox) return; const regions = [ {name:'Northwest', base:0.20, slope:0.025}, {name:'Cap Bon', base:0.30, slope:0.030}, {name:'Center', base:0.45, slope:0.035}, {name:'South', base:0.65, slope:0.020} ]; const years = d3.range(2010, 2026); const colorScale = d3.scaleSequential( t => d3.interpolateRgb.gamma(2.2)('#F4EFE6', '#A63D3D')(t) ).domain([0, 1]); function stress(region, year, idx){ const drought = (year===2016||year===2022||year===2023||year===2024) ? 0.18 : 0; const noise = (Math.sin(idx*1.7 + region.base*10)*0.5 + 0.5)*0.08; return Math.min(1, Math.max(0, region.base + region.slope*idx + drought + noise)); } const allCells = []; regions.forEach((reg, ri)=>{ const row = document.createElement('div'); row.className = 'viz-strip-row'; row.innerHTML = `${reg.name}
`;
rowsEl.appendChild(row);
const cellsBox = row.querySelector('.viz-strip-cells');
years.forEach((yr, yi)=>{
const s = stress(reg, yr, yi);
const cell = document.createElement('div');
cell.className = 'viz-strip-cell';
cell.style.background = colorScale(s);
cell.dataset.region = reg.name;
cell.dataset.year = yr;
cellsBox.appendChild(cell);
allCells.push(cell);
cell.addEventListener('mouseenter', ()=>{
const rect = stripBox.getBoundingClientRect();
const cr = cell.getBoundingClientRect();
tip.innerHTML = `${reg.name} · ${yr}Water stress: ${Math.round(s*100)}%`;
tip.style.left = (cr.left - rect.left + cr.width/2) + 'px';
tip.style.top = (cr.top - rect.top - 5) + 'px';
tip.style.opacity = 1;
});
cell.addEventListener('mouseleave', ()=>tip.style.opacity = 0);
});
});
years.forEach(y=>{
const yl = document.createElement('div');
yl.textContent = (y % 5 === 0 || y === 2025) ? y : '';
yearsEl.appendChild(yl);
});
[0, 0.25, 0.5, 0.75, 1].forEach(t=>{
const s = document.createElement('span');
s.style.background = colorScale(t);
legendEl.appendChild(s);
});
ScrollTrigger.create({
trigger:'#ssqStrip', start:'top 78%',
onEnter:()=>{
allCells.forEach((c,i)=>{
setTimeout(()=>c.classList.add('in'), i*10);
});
}
});
})();
/* ============================================================
VIZ 08 — WHAT-IF SIMULATOR
============================================================ */
(function(){
const sWheat = document.getElementById('sWheat');
const sGas = document.getElementById('sGas');
const sSub = document.getElementById('sSub');
const sHarv = document.getElementById('sHarv');
if(!sWheat) return;
const vWheat = document.getElementById('vWheat');
const vGas = document.getElementById('vGas');
const vSub = document.getElementById('vSub');
const vHarv = document.getElementById('vHarv');
const out = document.getElementById('simuBudget');
const lbl = document.getElementById('simuLbl');
const needle = document.getElementById('gaugeNeedle');
const flash = document.getElementById('simuFlash');
let prevBudget = null;
let prevTension = null;
function update(){
const wheat = +sWheat.value;
const gas = +sGas.value;
const sub = +sSub.value;
const harv = +sHarv.value;
vWheat.textContent = wheat;
vGas.textContent = gas;
vSub.textContent = sub;
vHarv.textContent = harv;
const deficit = Math.max(0, 37 - harv);
const importCost = deficit * wheat * 100 * 3.1 / 1000;
const fertCost = gas * 60;
const subBudget = Math.round((importCost + fertCost) * (sub/100) * 1.5 + 1200);
if(prevBudget !== null && Math.abs(subBudget - prevBudget) > 5){
const startVal = prevBudget;
const delta = subBudget - startVal;
const duration = 400;
const t0 = performance.now();
function step(t){
const k = Math.min(1, (t - t0)/duration);
const eased = 1 - Math.pow(1-k, 3);
const v = Math.round(startVal + delta*eased);
out.textContent = v.toLocaleString('fr-FR').replace(/,/g,' ') + ' M TND';
if(k < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
} else {
out.textContent = subBudget.toLocaleString('fr-FR').replace(/,/g,' ') + ' M TND';
}
prevBudget = subBudget;
const tension = Math.min(100, Math.max(0,
((wheat-180)/320)*38 + ((gas-3)/22)*30 + (sub/100)*15 + ((22-harv)/14)*17
));
const angle = -90 + (tension/100)*180;
needle.style.transform = `rotate(${angle}deg)`;
if(prevTension !== null && Math.abs(tension - prevTension) > 8){
flash.classList.add('on');
setTimeout(()=>flash.classList.remove('on'), 150);
}
prevTension = tension;
out.className = 'viz-simu-result';
if(tension < 35){
out.classList.add('safe');
lbl.textContent = 'Sustainable burden. Budgetary margins preserved.';
} else if(tension < 65){
out.classList.add('warn');
lbl.textContent = 'Significant tension. Budgetary trade-offs needed.';
} else {
out.classList.add('danger');
lbl.textContent = 'Crisis zone. High social and financial risk.';
}
}
[
{input:sWheat, val:vWheat},
{input:sGas, val:vGas},
{input:sSub, val:vSub},
{input:sHarv, val:vHarv}
].forEach(({input, val})=>{
input.addEventListener('input', ()=>{
val.parentElement.classList.add('pulse');
setTimeout(()=>val.parentElement.classList.remove('pulse'), 350);
update();
});
});
update();
ScrollTrigger.create({
trigger:'#ssqSimu', start:'top 75%',
onEnter:()=>{
if(reduced) return;
needle.style.transform = 'rotate(-90deg)';
setTimeout(()=>{ needle.style.transform = 'rotate(90deg)'; }, 400);
setTimeout(update, 1400);
}
});
})();
/* ============================================================
VIZ 09 — BEESWARM
→ WHITE tooltip title (already applied via CSS)
→ NO icons inside circles
============================================================ */
(function(){
const svg = d3.select('#ssqBee');
if(svg.empty()) return;
const W=1100, H=460, m={t:50,r:60,b:60,l:120};
const events = [
{date:2010.8, sector:'Cereals', amp:6, type:'Climate', name:'Russia drought',
desc:'Russian embargo on wheat exports. Surge in world prices.'},
{date:2011.0, sector:'Politics', amp:5, type:'Conflict', name:'Arab Spring',
desc:'Wave of food price increases, partial trigger of the revolts.'},
{date:2014.3, sector:'Energy', amp:5, type:'Geopolitics', name:'Crimea annexation',
desc:'First tensions in the Black Sea. Cereal freight disrupted.'},
{date:2018.5, sector:'Fertilizers',amp:3,type:'Market', name:'China urea tension',
desc:'Chinese restrictions on fertilizer exports.'},
{date:2020.2, sector:'Logistics', amp:8, type:'Pandemic', name:'Covid-19',
desc:'Collapse of global freight. Supply chain disruptions.'},
{date:2021.7, sector:'Energy', amp:7, type:'Market', name:'European gas shock',
desc:'The price of gas multiplied by 5. European urea collapses.'},
{date:2022.15,sector:'Cereals', amp:10,type:'Conflict', name:'Invasion of Ukraine',
desc:'Black Sea blocked. Wheat prices at historic highs.'},
{date:2022.6, sector:'Fertilizers',amp:8,type:'Conflict', name:'Global fertilizer crisis',
desc:'Urea and potash shortage. Farmers reduce applications.'},
{date:2023.4, sector:'Logistics', amp:6, type:'Geopolitics', name:'Red Sea / Houthis',
desc:'Attacks on cargo ships. Rerouting via the Cape.'},
{date:2024.1, sector:'Politics', amp:5, type:'Market', name:'India restrictions',
desc:'India restricts its cereal and fertilizer exports.'},
{date:2024.7, sector:'Energy', amp:6, type:'Geopolitics', name:'Iran tensions',
desc:'Renewed pressure on Hormuz. Risk premiums.'},
{date:2025.3, sector:'Fertilizers',amp:5,type:'Market', name:'Morocco phosphate restriction',
desc:'Renegotiation of phosphate export contracts.'},
{date:2026.05,sector:'Logistics', amp:9, type:'Conflict', name:'Hormuz closure',
desc:'Cumulative shock on gas, urea, freight, food prices.'}
];
const sectors = ['Cereals','Fertilizers','Energy','Logistics','Politics'];
const colorByType = {
'Climate':C.olive, 'Conflict':C.rust, 'Geopolitics':C.wheatDeep,
'Market':C.sky, 'Pandemic':C.earth
};
const x = d3.scaleLinear().domain([2010, 2026.5]).range([m.l, W-m.r]);
const y = d3.scalePoint().domain(sectors).range([m.t+20, H-m.b]).padding(0.5);
const r = d3.scaleSqrt().domain([1,10]).range([8, 30]);
svg.append('g').attr('transform',`translate(0,${H-m.b+10})`)
.call(d3.axisBottom(x).tickFormat(d3.format('d')).ticks(8))
.selectAll('text').attr('font-family','JetBrains Mono, monospace').attr('font-size',10).attr('fill',C.mute);
sectors.forEach(s=>{
svg.append('text').attr('x',m.l-15).attr('y',y(s)+5).attr('text-anchor','end')
.attr('font-family','Cormorant Garamond, serif').attr('font-style','italic')
.attr('font-size',13).attr('fill',C.ink).attr('font-weight',600).text(s);
svg.append('line').attr('x1',m.l).attr('x2',W-m.r).attr('y1',y(s)).attr('y2',y(s))
.attr('stroke',C.ink).attr('stroke-opacity',.06);
});
svg.selectAll('.tick line, .domain').attr('stroke',C.ink).attr('stroke-opacity',.15);
const legG = svg.append('g').attr('transform',`translate(${m.l},10)`);
let lx = 0;
Object.entries(colorByType).forEach(([k,v])=>{
legG.append('circle').attr('cx',lx).attr('cy',6).attr('r',5).attr('fill',v);
legG.append('text').attr('x',lx+10).attr('y',10)
.attr('font-family','Inter, sans-serif').attr('font-size',11).attr('fill',C.ink).text(k);
lx += k.length*7 + 30;
});
const nodes = events.map(e=>({
...e,
x0: x(e.date), y0: y(e.sector)
}));
const sim = d3.forceSimulation(nodes)
.force('x', d3.forceX(d=>d.x0).strength(0.7))
.force('y', d3.forceY(d=>d.y0).strength(0.4))
.force('collide', d3.forceCollide(d=>r(d.amp)+2))
.stop();
for(let i=0;i<200;i++) sim.tick();
// CIRCLES ONLY — no icons
const dotGroups = svg.selectAll('.bee-group').data(nodes).enter().append('g')
.attr('class','bee-group')
.attr('transform',d=>`translate(${d.x},${d.y})`);
const dots = dotGroups.append('circle')
.attr('class','bee-dot')
.attr('r',0)
.attr('fill',d=>colorByType[d.type])
.attr('fill-opacity',.85)
.attr('stroke',C.paper).attr('stroke-width',1.5);
const tip = document.getElementById('beeTip');
const beeBox = svg.node().parentNode;
dotGroups.on('mouseenter', function(e,d){
const rect = beeBox.getBoundingClientRect();
const transform = this.getAttribute('transform');
const match = transform.match(/translate\(([\d.-]+),([\d.-]+)\)/);
const cx_node = parseFloat(match[1]);
const cy_node = parseFloat(match[2]);
const svgEl = svg.node();
const svgRect = svgEl.getBoundingClientRect();
const scaleX = svgRect.width / W;
const scaleY = svgRect.height / H;
const px = svgRect.left - rect.left + cx_node * scaleX;
const py = svgRect.top - rect.top + cy_node * scaleY;
tip.innerHTML = `${d.name}${d.desc}${d.type} · amplitude ${d.amp}/10`;
tip.style.left = px + 'px';
tip.style.top = (py - r(d.amp) - 6) + 'px';
tip.style.opacity = 1;
}).on('mouseleave',()=>tip.style.opacity=0);
ScrollTrigger.create({
trigger:'#ssqBee', start:'top 75%',
onEnter:()=>{
dots.transition().duration(800).delay((d,i)=>i*70)
.attr('r', d=>r(d.amp));
}
});
})();
/* ============================================================
VIZ 10 — SUNBURST
============================================================ */
(function(){
const svg = d3.select('#ssqBurst');
if(svg.empty()) return;
const W = 1000, H = 1000, cx = W/2, cy = H/2;
const R_INNER = 130;
const R_MID = 270;
const R_OUTER = 460;
const data = {
name:'Resilience',
children:[
{name:'Strategic stocks', desc:'National storage capacities, sizing, rotation.', color:C.wheat, children:[
{name:'Modernized silos', desc:'Modernization and expansion of port storage capacities.'},
{name:'Buffer stock', desc:'Minimum anti-shock sizing for strategic cereals.'},
{name:'Regional network', desc:'Decentralization of stocks across the national territory.'},
{name:'Traceability', desc:'Real-time information system on public and private stocks.'}
]},
{name:'Diversification', desc:'Plurality of suppliers and trade routes.', color:C.olive, children:[
{name:'Multi-suppliers', desc:'Reducing dependence on a single corridor (Black Sea).'},
{name:'Alternative routes', desc:'Logistical diversification: Atlantic, Red Sea, Mediterranean.'},
{name:'Long-term contracts', desc:'Multi-year agreements with volume guarantees.'},
{name:'Hedging', desc:'Hedging tools on futures markets.'}
]},
{name:'Critical inputs', desc:'Fertilizers, sulfur, seeds, equipment.', color:C.earth, children:[
{name:'Fertilizer industry', desc:'Rebuilding Tunisia\'s fertilizer production capacities.'},
{name:'Sulfur & potash', desc:'Securing supplies of mining inputs.'},
{name:'Local seeds', desc:'Varieties resilient to semi-arid climate.'},
{name:'GCT maintenance', desc:'Investment in the Tunisian Chemical Group\'s facilities.'}
]},
{name:'Climate & water', desc:'Adaptation to water scarcity and droughts.', color:C.sky, children:[
{name:'Drip irrigation', desc:'Generalization of water-saving techniques.'},
{name:'Desalination', desc:'Combining energy transition and water security.'},
{name:'Resistant varieties', desc:'Agronomic research on drought-resilient wheats.'},
{name:'Climate insurance', desc:'Mechanisms for pooling climate risk.'}
]},
{name:'Agricultural energy', desc:'Decarbonization and energy autonomy of farms.', color:C.wheatDeep, children:[
{name:'Agricultural solar', desc:'Photovoltaic deployment on farms.'},
{name:'Green ammonia', desc:'Local production of low-carbon nitrogen fertilizers.'},
{name:'Energy efficiency', desc:'Reducing the energy intensity of agricultural chains.'},
{name:'Local storage', desc:'Batteries and decentralized self-consumption.'}
]},
{name:'Social justice', desc:'Purchasing power, subsidy targeting, territorial cohesion.', color:C.rust, children:[
{name:'Targeted subsidies', desc:'Reform of the compensation fund to benefit the most modest.'},
{name:'Direct transfers', desc:'Substitution with conditional cash assistance.'},
{name:'Purchasing power', desc:'Wage policy linked to food security.'},
{name:'Nutritional diversity', desc:'Protein diversification beyond subsidized cereals.'}
]}
]
};
const root = d3.hierarchy(data).sum(d=>d.children ? 0 : 1);
const partition = d3.partition().size([Math.PI*2, R_OUTER - R_INNER]);
partition(root);
root.descendants().forEach(d=>{
d.y0 += R_INNER;
d.y1 += R_INNER;
});
const arc = d3.arc()
.startAngle(d=>d.x0)
.endAngle(d=>d.x1)
.innerRadius(d=>{
if(d.depth === 0) return 0;
if(d.depth === 1) return R_INNER;
return R_MID;
})
.outerRadius(d=>{
if(d.depth === 0) return 0;
if(d.depth === 1) return R_MID;
return R_OUTER;
})
.padAngle(0.008)
.padRadius(R_INNER);
const g = svg.append('g').attr('transform',`translate(${cx},${cy})`);
function colorFor(d){
if(d.depth === 0) return 'transparent';
let p = d;
while(p.depth > 1) p = p.parent;
const base = p.data.color || C.wheat;
if(d.depth === 1) return base;
return d3.color(base).brighter(0.7).formatHex();
}
const arcs = g.selectAll('path').data(root.descendants().filter(d=>d.depth>0)).enter()
.append('path')
.attr('class','burst-arc')
.attr('d', arc)
.attr('fill', d=>colorFor(d))
.attr('stroke', C.paper).attr('stroke-width', 2)
.attr('opacity', 0);
const defs = svg.append('defs');
root.descendants().filter(d=>d.depth===1).forEach((d,i)=>{
const midR = (R_INNER + R_MID)/2;
const mid = (d.x0 + d.x1)/2;
const flip = mid > Math.PI*0.5 && mid < Math.PI*1.5;
const padA = 0.015;
const startA = (flip ? d.x1 - padA : d.x0 + padA) - Math.PI/2;
const endA = (flip ? d.x0 + padA : d.x1 - padA) - Math.PI/2;
const sx = Math.cos(startA)*midR;
const sy = Math.sin(startA)*midR;
const ex = Math.cos(endA)*midR;
const ey = Math.sin(endA)*midR;
const sweep = flip ? 0 : 1;
const pathId = `burstPath1_${i}`;
defs.append('path')
.attr('id', pathId)
.attr('d', `M ${sx} ${sy} A ${midR} ${midR} 0 0 ${sweep} ${ex} ${ey}`);
g.append('text')
.attr('class','burst-lbl-1')
.attr('font-family','Cormorant Garamond, serif')
.attr('font-style','italic').attr('font-weight',700)
.attr('font-size', 19)
.attr('fill', C.paper)
.attr('opacity', 0)
.style('letter-spacing','0.02em')
.append('textPath')
.attr('href', `#${pathId}`)
.attr('startOffset','50%')
.attr('text-anchor','middle')
.text(d.data.name);
});
const lvl2 = root.descendants().filter(d=>d.depth===2);
lvl2.forEach(d=>{
const midAngle = (d.x0 + d.x1)/2;
const r = (R_MID + R_OUTER)/2;
const flip = midAngle > Math.PI && midAngle < Math.PI*2;
const rotation = midAngle * 180/Math.PI - 90;
const txt = g.append('text')
.attr('class','burst-lbl-2')
.attr('transform', `rotate(${rotation}) translate(${r},0) ${flip?'rotate(180)':''}`)
.attr('text-anchor','middle')
.attr('font-family','Inter, sans-serif')
.attr('font-weight',500)
.attr('font-size', 12)
.attr('fill', C.ink)
.attr('opacity', 0);
const words = d.data.name.split(' ');
if(words.length > 1 && d.data.name.length > 12){
const mid = Math.ceil(words.length/2);
const l1 = words.slice(0, mid).join(' ');
const l2 = words.slice(mid).join(' ');
txt.append('tspan').attr('x', 0).attr('dy','-0.4em').text(l1);
txt.append('tspan').attr('x', 0).attr('dy','1.1em').text(l2);
} else {
txt.attr('dy','.35em').text(d.data.name);
}
});
const center = document.getElementById('burstCenter');
arcs.on('mouseenter', function(e,d){
center.querySelector('h5').textContent = d.data.name;
center.querySelector('p').textContent = d.data.desc || '';
center.querySelector('h5').style.color = d.depth===1 ? colorFor(d) : C.wheatDeep;
}).on('mouseleave',()=>{
center.querySelector('h5').innerHTML = 'Foodresilience'; center.querySelector('h5').style.color = C.ink; center.querySelector('p').textContent = 'Hover over a pillar to explore its levers.'; }); ScrollTrigger.create({ trigger:'#ssqBurst', start:'top 75%', onEnter:()=>{ arcs.transition().duration(900).delay((d,i)=>d.depth===1 ? i*100 : 600+i*25) .attr('opacity',1); g.selectAll('.burst-lbl-1').transition().duration(600).delay(1200).attr('opacity',1); g.selectAll('.burst-lbl-2').transition().duration(600).delay(1600).attr('opacity',1); } }); })(); })();