thibaud frere
update images and banner
9792701
raw
history blame
12.6 kB
<div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:360px;"></div>
<script>
(() => {
const THIS_SCRIPT = document.currentScript;
const ensureD3 = (cb) => {
if (window.d3 && typeof window.d3.select === 'function') return cb();
let s = document.getElementById('d3-cdn-script');
if (!s) {
s = document.createElement('script');
s.id = 'd3-cdn-script';
s.src = 'https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js';
document.head.appendChild(s);
}
const onReady = () => { if (window.d3 && typeof window.d3.select === 'function') cb(); };
s.addEventListener('load', onReady, { once: true });
if (window.d3) onReady();
};
const bootstrap = () => {
const scriptEl = THIS_SCRIPT;
const host = scriptEl && scriptEl.parentElement;
let container = null;
if (host && host.querySelector) {
container = host.querySelector('.d3-galaxy');
}
if (!container) {
let sib = scriptEl && scriptEl.previousElementSibling;
while (sib && sib.tagName && sib.tagName.toLowerCase() === 'style') {
sib = sib.previousElementSibling;
}
if (sib && sib.classList && sib.classList.contains('d3-galaxy')) {
container = sib;
}
}
if (!container) {
container = document.querySelector('.d3-galaxy');
}
if (!container) return;
if (container.dataset) {
if (container.dataset.mounted === 'true') return;
container.dataset.mounted = 'true';
}
const csvUrl = (container.dataset && container.dataset.src) || '/data/banner_visualisation_data.csv';
const svg = d3.select(container).append('svg')
.attr('width', '100%')
.style('display', 'block');
// Tooltip (reuse style from other embeds)
container.style.position = container.style.position || 'relative';
let tip = container.querySelector('.d3-tooltip');
let tipInner;
if (!tip) {
tip = document.createElement('div');
tip.className = 'd3-tooltip';
Object.assign(tip.style, {
position: 'absolute',
top: '0px',
left: '0px',
transform: 'translate(-9999px, -9999px)',
pointerEvents: 'none',
padding: '8px 10px',
borderRadius: '8px',
fontSize: '12px',
lineHeight: '1.35',
border: '1px solid var(--border-color)',
background: 'var(--surface-bg)',
color: 'var(--text-color)',
boxShadow: '0 4px 24px rgba(0,0,0,.18)',
opacity: '0',
transition: 'opacity .12s ease'
});
tipInner = document.createElement('div');
tipInner.className = 'd3-tooltip__inner';
tipInner.style.textAlign = 'left';
tip.appendChild(tipInner);
container.appendChild(tip);
} else {
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
}
d3.csv(csvUrl, d3.autoType).then((raw) => {
const data = raw.filter((d) => d && typeof d.x_position === 'number' && typeof d.y_position === 'number');
const categories = Array.from(new Set(data.map((d) => d.category || 'Unknown')));
const color = d3.scaleOrdinal().domain(categories).range(d3.schemeTableau10);
const xDomain = d3.extent(data, (d) => d.x_position);
const yDomain = d3.extent(data, (d) => d.y_position);
// Centroides par catégorie (moyenne x/y)
const centroids = Array.from(
d3.rollup(
data,
(v) => ({
category: (v[0] && (v[0].category || 'Unknown')) || 'Unknown',
x: d3.mean(v, (d) => d.x_position),
y: d3.mean(v, (d) => d.y_position),
count: v.length
}),
(d) => d.category || 'Unknown'
).values()
);
const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points');
const gCentroids = svg.selectAll('g.centroids').data([0]).join('g').attr('class', 'centroids');
const render = () => {
const width = container.clientWidth || 800;
const height = Math.max(310, Math.round(width / 3) + 50);
const padTop = 24, padBottom = 24;
const innerHeight = Math.max(0, height - padTop - padBottom);
svg.attr('width', width).attr('height', height);
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)';
const xScale = d3.scaleLinear().domain(xDomain).range([0, width]);
const yScale = d3.scaleLinear().domain(yDomain).range([innerHeight, 0]);
// Appliquer le padding vertical aux groupes de rendu
g.attr('transform', `translate(0, ${padTop})`);
gCentroids.attr('transform', `translate(0, ${padTop})`);
// Centroides en labels: doublage (fond blanc + texte couleur) + anti-chevauchement
const fontPx = 16;
const estimateTextWidth = (s) => Math.max(fontPx, (s ? String(s).length : 0) * fontPx * 0.62 + 8);
// Prépare des noeuds à l'échelle pixel pour la simulation
const nodes = centroids.map((c) => ({
category: c.category,
count: c.count,
x: xScale(c.x),
y: yScale(c.y),
targetX: xScale(c.x),
targetY: yScale(c.y),
width: estimateTextWidth(c.category),
height: fontPx
}));
// Simulation de forces pour éviter les collisions (nudge léger)
if (nodes.length > 1) {
const sim = d3.forceSimulation(nodes)
.force('x', d3.forceX((d) => d.targetX).strength(0.6))
.force('y', d3.forceY((d) => d.targetY).strength(0.6))
.force('collide', d3.forceCollide((d) => Math.hypot(d.width / 2, d.height / 2) + 2))
.stop();
for (let i = 0; i < 1000; i++) sim.tick();
// Limiter le déplacement max autour de la cible (nudge <= 6px)
const maxOffset = 55;
nodes.forEach((n) => {
const dx = n.x - n.targetX;
const dy = n.y - n.targetY;
const dist = Math.hypot(dx, dy);
if (dist > maxOffset && dist > 0) {
const s = maxOffset / dist;
n.x = n.targetX + dx * s;
n.y = n.targetY + dy * s;
}
});
}
const selC = gCentroids.selectAll('g.centroid').data(nodes, (d) => d.category);
const mergedC = selC.join((enter) => {
const g = enter.append('g').attr('class', 'centroid');
// Fond blanc épaissi
g.append('text')
.attr('class', 'label-bg')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('paint-order', 'stroke fill')
.text((d) => d.category);
// Couche couleur au-dessus
g.append('text')
.attr('class', 'label-fg')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.text((d) => d.category);
return g;
});
// Si une des couches manque (ancien DOM), la recréer
mergedC.each(function(d) {
const sel = d3.select(this);
if (!this.querySelector('text.label-bg')) {
sel.append('text')
.attr('class', 'label-bg')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.style('paint-order', 'stroke fill')
.text(d.category);
}
if (!this.querySelector('text.label-fg')) {
sel.append('text')
.attr('class', 'label-fg')
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.text(d.category);
}
});
mergedC
.attr('transform', (d) => `translate(${Math.round(d.x)}, ${Math.round(d.y)})`)
.attr('pointer-events', 'none');
// Styles forcés (inline !important) pour surpasser le CSS global
mergedC.select('text.label-bg').each(function() {
this.style.setProperty('fill', 'var(--surface-bg)', 'important');
this.style.setProperty('stroke', 'var(--surface-bg)', 'important');
this.style.setProperty('stroke-width', '6', 'important');
this.style.setProperty('font-weight', '900', 'important');
this.style.setProperty('font-size', `${fontPx}px`, 'important');
});
mergedC.select('text.label-fg').each(function(d) {
const base = d3.color(color(d.category || 'Unknown'));
const adjusted = base
? (isDark ? base.brighter(0.8) : base.darker(0.7)).toString()
: color(d.category || 'Unknown');
this.style.setProperty('fill', adjusted, 'important');
this.style.setProperty('font-weight', '900', 'important');
this.style.setProperty('font-size', `${fontPx}px`, 'important');
});
const pointStroke = (d) => {
const base = d3.color(color(d.category || 'Unknown'));
if (!base) return strokeColor;
const adjusted = isDark ? base.brighter(0.6) : base.darker(0.6);
return adjusted.toString();
};
const sel = g.selectAll('circle').data(data, (d) => d.original_id);
sel.join(
(enter) => enter.append('circle')
.attr('cx', (d) => xScale(d.x_position))
.attr('cy', (d) => yScale(d.y_position))
.attr('r', 3.5)
.attr('fill', (d) => color(d.category || 'Unknown'))
.attr('fill-opacity', 0.9)
.attr('stroke', (d) => pointStroke(d))
.attr('stroke-width', 0.4)
.on('mouseenter', function(ev, d) {
d3.select(this).raise()
.attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
.attr('stroke-width', 1.2);
tipInner.innerHTML =
`<div><strong>${d.category || 'Unknown'}</strong></div>` +
`<div><strong>ID</strong> ${d.original_id}</div>` +
`<div><strong>Subset</strong> ${d.subset ?? '—'}</div>` +
`<div><strong>X</strong> ${d.x_position} · <strong>Y</strong> ${d.y_position}</div>`;
tip.style.opacity = '1';
})
.on('mousemove', (ev) => {
const [mx, my] = d3.pointer(ev, container);
const offsetX = 10, offsetY = 12;
tip.style.transform = `translate(${Math.round(mx + offsetX)}px, ${Math.round(my + offsetY)}px)`;
})
.on('mouseleave', function() {
tip.style.opacity = '0';
tip.style.transform = 'translate(-9999px, -9999px)';
d3.select(this)
.attr('stroke', (d) => pointStroke(d))
.attr('stroke-width', 0.4);
}),
(update) => update
.attr('cx', (d) => xScale(d.x_position))
.attr('cy', (d) => yScale(d.y_position))
.attr('r', 3.5)
.attr('fill', (d) => color(d.category || 'Unknown'))
.attr('fill-opacity', 0.9)
.attr('stroke', (d) => pointStroke(d))
.attr('stroke-width', 0.4)
);
};
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => render());
ro.observe(container);
} else {
window.addEventListener('resize', render);
}
// Re-render on theme changes (data-theme attribute updates live)
const themeObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes' && m.attributeName === 'data-theme') {
render();
break;
}
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
render();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
} else { ensureD3(bootstrap); }
})();
</script>