Spaces:
Running
Running
| <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> | |