Spaces:
Running
Running
thibaud frere
Move assets into content/assets; update imports; clean .gitattributes; fix LFS tracking
b8e1b6c
| <div class="d3-galaxy" style="width:100%;margin:10px 0;aspect-ratio:3/1;min-height:260px;"></div> | |
| <script> | |
| (() => { | |
| 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 mount = document.currentScript ? document.currentScript.previousElementSibling : null; | |
| const container = (mount && mount.querySelector && mount.querySelector('.d3-galaxy')) || document.querySelector('.d3-galaxy'); | |
| if (!container) return; | |
| if (container.dataset) { | |
| if (container.dataset.mounted === 'true') return; | |
| container.dataset.mounted = 'true'; | |
| } | |
| // Scene params (match previous Plotly ranges) | |
| const cx = 1.5, cy = 0.5; | |
| const a = 1.3, b = 0.45; | |
| const numPoints = 3000; | |
| const numArms = 3; | |
| const numTurns = 2.1; | |
| const angleJitter = 0.12; | |
| const posNoise = 0.015; | |
| // Generate spiral + bulge | |
| const twoPi = Math.PI * 2; | |
| const t = Float64Array.from({ length: numPoints }, () => Math.random() * (twoPi * numTurns)); | |
| const armIndices = Int16Array.from({ length: numPoints }, () => Math.floor(Math.random() * numArms)); | |
| const armOffsets = Float64Array.from(armIndices, (k) => k * (twoPi / numArms)); | |
| const theta = Float64Array.from(t, (tv, i) => tv + armOffsets[i] + d3.randomNormal.source(Math.random)(0, angleJitter)()); | |
| const rNorm = Float64Array.from(t, (tv) => Math.pow(tv / (twoPi * numTurns), 0.9)); | |
| const noiseScale = (rn) => posNoise * (0.8 + 0.6 * rn); | |
| const noiseX = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))()); | |
| const noiseY = Float64Array.from(rNorm, (rn) => d3.randomNormal.source(Math.random)(0, noiseScale(rn))()); | |
| const xSpiral = Float64Array.from(theta, (th, i) => cx + a * rNorm[i] * Math.cos(th) + noiseX[i]); | |
| const ySpiral = Float64Array.from(theta, (th, i) => cy + b * rNorm[i] * Math.sin(th) + noiseY[i]); | |
| const bulgePoints = Math.floor(0.18 * numPoints); | |
| const phiB = Float64Array.from({ length: bulgePoints }, () => twoPi * Math.random()); | |
| const rB = Float64Array.from({ length: bulgePoints }, () => Math.pow(Math.random(), 2.2) * 0.22); | |
| const noiseXB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)()); | |
| const noiseYB = Float64Array.from({ length: bulgePoints }, () => d3.randomNormal.source(Math.random)(0, posNoise * 0.6)()); | |
| const xBulge = Float64Array.from(phiB, (ph, i) => cx + a * rB[i] * Math.cos(ph) + noiseXB[i]); | |
| const yBulge = Float64Array.from(phiB, (ph, i) => cy + b * rB[i] * Math.sin(ph) + noiseYB[i]); | |
| // Concatenate | |
| const X = Array.from(xSpiral).concat(Array.from(xBulge)); | |
| const Y = Array.from(ySpiral).concat(Array.from(yBulge)); | |
| const lenSpiral = xSpiral.length; | |
| const zSpiral = Array.from(rNorm, (rn) => 1 - rn); | |
| const maxRB = rB && rB.length ? (window.d3 && d3.max ? d3.max(rB) : Math.max.apply(null, Array.from(rB))) : 1; | |
| const zBulge = Array.from(rB, (rb) => 1 - (maxRB ? rb / maxRB : 0)); | |
| const Zraw = zSpiral.concat(zBulge); | |
| const sizesPx = Zraw.map((z) => (z + 1) * 5); // 5..10 px (diameter) | |
| // Labels (same categories as Python version) | |
| const labelOf = (i) => { | |
| const z = Zraw[i]; | |
| if (z < 0.25) return 'smol dot'; | |
| if (z < 0.5) return 'ok-ish dot'; | |
| if (z < 0.75) return 'a dot'; | |
| return 'biiig dot'; | |
| }; | |
| // Sort by size ascending for z-index: small first, big last | |
| const idx = d3.range(X.length).sort((i, j) => sizesPx[i] - sizesPx[j]); | |
| // Colors: piecewise gradient [0 -> 0.5 -> 1] | |
| const c0 = d3.rgb(78, 165, 183); // rgb(78, 165, 183) | |
| const c1 = d3.rgb(206, 192, 250); // rgb(206, 192, 250) | |
| const c2 = d3.rgb(232, 137, 171); // rgb(232, 137, 171) | |
| const interp01 = d3.interpolateRgb(c0, c1); | |
| const interp12 = d3.interpolateRgb(c1, c2); | |
| const colorFor = (v) => { | |
| const t = Math.max(0, Math.min(1, v)); | |
| return t <= 0.5 ? interp01(t / 0.5) : interp12((t - 0.5) / 0.5); | |
| }; | |
| // Create SVG | |
| const svg = d3.select(container).append('svg') | |
| .attr('width', '100%') | |
| .style('display', 'block'); | |
| const render = () => { | |
| const width = container.clientWidth || 800; | |
| const height = Math.max(260, Math.round(width / 3)); // keep ~3:1, min height | |
| svg.attr('width', width).attr('height', height); | |
| const xScale = d3.scaleLinear().domain([0, 3]).range([0, width]); | |
| const yScale = d3.scaleLinear().domain([0, 1]).range([height, 0]); | |
| // Subtle stroke color depending on theme | |
| const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; | |
| const strokeColor = isDark ? 'rgba(255,255,255,0.18)' : 'rgba(0,0,0,0.12)'; | |
| // Group for points (no blend mode for better print/PDF visibility) | |
| const g = svg.selectAll('g.points').data([0]).join('g').attr('class', 'points'); | |
| // Ensure container can host an absolute tooltip | |
| 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; | |
| } | |
| // Final filter: remove small dots very close to the galaxy center (after placement) | |
| const centerHoleRadius = 0.48; // elliptical radius threshold | |
| const smallSizeThreshold = 7.5; // same notion as Python size cut | |
| const rTotal = idx.map((i) => Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2)); | |
| const idxFiltered = idx.filter((i, k) => !(rTotal[k] <= centerHoleRadius && sizesPx[i] < smallSizeThreshold)); | |
| const sel = g.selectAll('circle').data(idxFiltered, (i) => i); | |
| sel.join( | |
| (enter) => enter.append('circle') | |
| .attr('cx', (i) => xScale(X[i])) | |
| .attr('cy', (i) => yScale(Y[i])) | |
| .attr('r', (i) => sizesPx[i] / 2) | |
| .attr('fill', (i) => colorFor(Zraw[i])) | |
| .attr('fill-opacity', 0.9) | |
| .attr('stroke', strokeColor) | |
| .attr('stroke-width', 0.4) | |
| .on('mouseenter', function(ev, i) { | |
| d3.select(this).raise() | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)') | |
| .attr('stroke-width', 1.2); | |
| const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2); | |
| const type = i < lenSpiral ? 'spiral' : 'bulge'; | |
| const arm = i < lenSpiral ? (armIndices[i] + 1) : null; | |
| tipInner.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` + | |
| `<div><strong>Type</strong> ${type}${arm ? ` (arm ${arm})` : ''}</div>` + | |
| `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>` + | |
| `<div><strong>X</strong> ${X[i].toFixed(2)} 路 <strong>Y</strong> ${Y[i].toFixed(2)}</div>` + | |
| `<div><strong>r</strong> ${r.toFixed(3)} 路 <strong>z</strong> ${Zraw[i].toFixed(3)}</div>`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', (ev, i) => { | |
| 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', strokeColor).attr('stroke-width', 0.4); | |
| }), | |
| (update) => update | |
| .attr('cx', (i) => xScale(X[i])) | |
| .attr('cy', (i) => yScale(Y[i])) | |
| .attr('r', (i) => sizesPx[i] / 2) | |
| .attr('fill', (i) => colorFor(Zraw[i])) | |
| .attr('fill-opacity', 0.9) | |
| .attr('stroke', strokeColor) | |
| .attr('stroke-width', 0.4) | |
| .on('mouseenter', function(ev, i) { | |
| d3.select(this).raise() | |
| .attr('stroke', isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.85)') | |
| .attr('stroke-width', 1.2); | |
| const r = Math.sqrt(((X[i] - cx) / a) ** 2 + ((Y[i] - cy) / b) ** 2); | |
| const type = i < lenSpiral ? 'spiral' : 'bulge'; | |
| const arm = i < lenSpiral ? (armIndices[i] + 1) : null; | |
| tipInner.innerHTML = `<div><strong>${labelOf(i)}</strong></div>` + | |
| `<div><strong>Type</strong> ${type}${arm ? ` (arm ${arm})` : ''}</div>` + | |
| `<div><strong>Size</strong> ${sizesPx[i].toFixed(1)} px</div>` + | |
| `<div><strong>X</strong> ${X[i].toFixed(2)} 路 <strong>Y</strong> ${Y[i].toFixed(2)}</div>` + | |
| `<div><strong>r</strong> ${r.toFixed(3)} 路 <strong>z</strong> ${Zraw[i].toFixed(3)}</div>`; | |
| tip.style.opacity = '1'; | |
| }) | |
| .on('mousemove', (ev, i) => { | |
| 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', strokeColor).attr('stroke-width', 0.4); | |
| }) | |
| ); | |
| }; | |
| // First render + resize | |
| if (window.ResizeObserver) { | |
| const ro = new ResizeObserver(() => render()); | |
| ro.observe(container); | |
| } else { | |
| window.addEventListener('resize', render); | |
| } | |
| render(); | |
| }; | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); | |
| } else { ensureD3(bootstrap); } | |
| })(); | |
| </script> | |