thibaud frere
update
9b76585
raw
history blame
18.8 kB
<div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:360px; display:flex; align-items:center; justify-content:center;"></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';
// Assurer un fond transparent pour l'ensemble du composant
if (container && container.style) {
container.style.background = 'transparent';
}
const svg = d3.select(container).append('svg')
.attr('width', '100%')
.style('display', 'block')
.style('background', 'transparent');
// Tooltip (reuse style from other embeds)
container.style.position = container.style.position || 'relative';
let tip = container.querySelector('.d3-tooltip');
let tipInner;
const isMobileLayout = () => window.matchMedia('(max-width: 480px)').matches;
let tipVisible = false;
const positionTooltipWithinBounds = (mx, my) => {
if (!tip || isMobileLayout()) return;
const offsetX = 10, offsetY = 12;
const desiredX = Math.round(mx + offsetX);
const desiredY = Math.round(my + offsetY);
const tipWidth = tip.offsetWidth || 0;
const tipHeight = tip.offsetHeight || 0;
const maxX = Math.max(0, (container.clientWidth || 0) - tipWidth - 1);
const maxY = Math.max(0, (container.clientHeight || 0) - tipHeight - 1);
const clampedX = Math.max(0, Math.min(desiredX, maxX));
const clampedY = Math.max(0, Math.min(desiredY, maxY));
tip.style.transform = `translate(${clampedX}px, ${clampedY}px)`;
};
const applyTooltipLayout = () => {
if (!tip) return;
if (isMobileLayout()) {
Object.assign(tip.style, {
position: 'static',
transform: 'none',
margin: '12px auto 0',
maxWidth: 'min(420px, 92%)',
width: 'auto',
left: '0px',
top: '0px',
pointerEvents: 'auto'
});
tip.style.display = tipVisible ? 'block' : 'none';
tip.style.opacity = tipVisible ? '1' : '0';
} else {
Object.assign(tip.style, {
position: 'absolute',
opacity: '0',
transform: 'translate(-9999px, -9999px)',
margin: '0',
maxWidth: '',
width: '',
left: '0px',
top: '0px',
pointerEvents: 'none'
});
tip.style.display = 'block';
}
};
const installMobileDismissHandler = () => {
if (!window.__bannerMobileDismissInstalled) {
window.__bannerMobileDismissInstalled = true;
document.addEventListener('click', (ev) => {
if (!isMobileLayout()) return;
const target = ev.target;
const isCircle = target && target.tagName && target.tagName.toLowerCase && target.tagName.toLowerCase() === 'circle' && container.contains(target);
const inTooltip = tip && tip.contains(target);
if (tipVisible && !isCircle && !inTooltip) {
tipVisible = false;
applyTooltipLayout();
}
}, { passive: true });
}
};
const applyContainerLayout = () => {
if (!container || !container.style) return;
container.style.flexDirection = isMobileLayout() ? 'column' : 'row';
// Optionally add a small gap for breathing space when stacked
container.style.gap = isMobileLayout() ? '10px' : '0px';
container.style.justifyContent = isMobileLayout() ? 'flex-start' : 'center';
};
const truncateText = (text, maxWords = 25) => {
if (!text || typeof text !== 'string') return '—';
const words = text.trim().split(/\s+/);
if (words.length <= maxWords) return text;
return words.slice(0, maxWords).join(' ') + '...';
};
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);
applyTooltipLayout();
applyContainerLayout();
} else {
tipInner = tip.querySelector('.d3-tooltip__inner') || tip;
}
d3.csv(csvUrl, d3.autoType).then(async (raw) => {
const data = raw.filter((d) => d && typeof d.x_position === 'number' && typeof d.y_position === 'number');
// Tri des catégories pour un ordre stable et correspondance avec d3-pie
const categories = Array.from(new Set(data.map((d) => d.category || 'Unknown'))).sort();
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);
// Résolution du chemin des miniatures (comme comparison.html)
const CANDIDATE_BASES = [ '/data/banner-thumbnails/' ];
const resolveBase = (candidates, filename) => new Promise((resolve) => {
let idx = 0; const tryNext = () => {
if (idx >= candidates.length) return resolve(candidates[0]);
const img = new Image();
img.onload = () => resolve(candidates[idx]);
img.onerror = () => { idx += 1; tryNext(); };
img.src = candidates[idx] + filename;
}; tryNext();
});
const firstId = data && data.length ? data[0].original_id : '0';
const thumbBase = await resolveBase(CANDIDATE_BASES, `${firstId}.jpg`);
const updateTooltipContent = (d) => {
if (!d) return;
const imgSrc = `${thumbBase}${d.original_id}.jpg`;
const userText = truncateText(d.user);
const assistantText = truncateText(d.assistant);
const layoutAdjust = isMobileLayout() ? 'flex-direction:column; align-items:center;' : '';
tipInner.innerHTML =
`<div style="display:flex; gap:10px; align-items:flex-start; ${layoutAdjust}">` +
`<div style="width:120px;height:120px;flex:0 0 auto;border-radius:6px;border:1px solid var(--border-color);display:flex;align-items:center;justify-content:center;overflow:hidden;background:var(--surface-bg);">` +
`<img src="${imgSrc}" alt="thumb ${d.original_id}" style="max-width:100%;max-height:100%;object-fit:contain;display:block;" />` +
`</div>` +
`<div style="min-width:140px; max-width:200px;">` +
`<div><strong>${d.category || 'Unknown'}</strong></div>` +
`<div style="word-wrap:break-word; line-height:1.3; margin:4px 0;"><strong>Q:</strong> ${userText}</div>` +
`<div style="word-wrap:break-word; line-height:1.3; margin:4px 0;"><strong>A:</strong> ${assistantText}</div>` +
`<div><strong>Subset</strong> ${d.subset ?? '—'}</div>` +
`</div>` +
`</div>`;
};
// 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 = 18;
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', '10', '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(.8)).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) {
if (isMobileLayout()) { updateTooltipContent(d); return; }
d3.select(this).raise()
.attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)')
.attr('stroke-width', 1.2);
updateTooltipContent(d);
tip.style.opacity = '1';
const [mx, my] = d3.pointer(ev, container);
positionTooltipWithinBounds(mx, my);
})
.on('mousemove', (ev) => {
if (isMobileLayout()) return;
const [mx, my] = d3.pointer(ev, container);
positionTooltipWithinBounds(mx, my);
})
.on('mouseleave', function() {
if (isMobileLayout()) return;
tip.style.opacity = '0';
tip.style.transform = 'translate(-9999px, -9999px)';
d3.select(this)
.attr('stroke', (d) => pointStroke(d))
.attr('stroke-width', 0.4);
})
.on('click', function(ev, d) {
updateTooltipContent(d);
if (isMobileLayout()) {
tipVisible = true;
applyTooltipLayout();
}
}),
(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)
);
// Ensure tooltip layout reflects current viewport
applyTooltipLayout();
applyContainerLayout();
installMobileDismissHandler();
};
if (window.ResizeObserver) {
const ro = new ResizeObserver(() => {
render();
applyTooltipLayout();
applyContainerLayout();
});
ro.observe(container);
} else {
window.addEventListener('resize', () => { render(); applyTooltipLayout(); applyContainerLayout(); });
}
// 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();
applyTooltipLayout();
applyContainerLayout();
installMobileDismissHandler();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true });
} else { ensureD3(bootstrap); }
})();
</script>