|
| 1 | +/** |
| 2 | + * Polya Urn – Vanilla D3 Visualizations |
| 3 | + */ |
| 4 | +(function () { |
| 5 | + 'use strict'; |
| 6 | + |
| 7 | + /* ── shared constants ───────────────────────────────────────────────── */ |
| 8 | + var URN_W = 650, |
| 9 | + URN_H = 260, |
| 10 | + DYN_W = 650, |
| 11 | + DYN_H = 250, |
| 12 | + acolor = 'Orange', |
| 13 | + bcolor = 'CornflowerBlue', |
| 14 | + text_color = '#222222', |
| 15 | + num_cols = 15, |
| 16 | + r = Math.floor(URN_W / 2 / num_cols / 2), |
| 17 | + line_offset = 2, |
| 18 | + txt_pad = 4, |
| 19 | + NUM_STEPS = 100, |
| 20 | + NUM_RUNS = 60; |
| 21 | + |
| 22 | + /* ═══════════════════════════════════════════════════════════════════════ |
| 23 | + Figure 1 – PolyaUrn (balls in an urn) |
| 24 | + ═══════════════════════════════════════════════════════════════════════ */ |
| 25 | + |
| 26 | + function ballY(pos) { |
| 27 | + return URN_H - r - line_offset - Math.floor(pos / num_cols) * (line_offset + 2 * r); |
| 28 | + } |
| 29 | + |
| 30 | + function drawBalls(svg, a, b) { |
| 31 | + svg.selectAll('circle.ball').remove(); |
| 32 | + var i; |
| 33 | + for (i = 0; i < a; i++) { |
| 34 | + svg.append('circle').attr('class', 'ball') |
| 35 | + .attr('r', r) |
| 36 | + .attr('cx', URN_W / 2 - r - (i % num_cols) * 2 * r) |
| 37 | + .attr('cy', ballY(i)) |
| 38 | + .style('stroke', 'black').style('stroke-width', 2) |
| 39 | + .style('fill', acolor); |
| 40 | + } |
| 41 | + for (i = 0; i < b; i++) { |
| 42 | + svg.append('circle').attr('class', 'ball') |
| 43 | + .attr('r', r) |
| 44 | + .attr('cx', URN_W / 2 + r + (i % num_cols) * 2 * r) |
| 45 | + .attr('cy', ballY(i)) |
| 46 | + .style('stroke', 'black').style('stroke-width', 2) |
| 47 | + .style('fill', bcolor); |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + function initPolyaUrn(figureEl) { |
| 52 | + var container = figureEl.querySelector('.d3-component'); |
| 53 | + if (!container) return; |
| 54 | + |
| 55 | + container.innerHTML = ''; |
| 56 | + |
| 57 | + /* Read initial values from the idyll-dynamic spans */ |
| 58 | + var dynSpans = figureEl.querySelectorAll('.idyll-dynamic'); |
| 59 | + var a0 = parseInt((dynSpans[0] || {}).textContent || '1', 10); |
| 60 | + var b0 = parseInt((dynSpans[1] || {}).textContent || '2', 10); |
| 61 | + |
| 62 | + var svg = d3.select(container).append('svg') |
| 63 | + .attr('viewBox', '0 0 ' + URN_W + ' ' + URN_H) |
| 64 | + .attr('overflow', 'visible') /* allow animated balls to enter from top */ |
| 65 | + .style('width', '100%') |
| 66 | + .style('height', 'auto') |
| 67 | + .style('display', 'block'); |
| 68 | + |
| 69 | + svg.append('rect') |
| 70 | + .attr('width', URN_W).attr('height', URN_H).attr('fill', 'white') |
| 71 | + .style('stroke', '#ccc').style('stroke-width', 1); |
| 72 | + |
| 73 | + drawBalls(svg, a0, b0); |
| 74 | + |
| 75 | + /* ratio label */ |
| 76 | + var lbl = svg.append('text') |
| 77 | + .attr('x', 2 * URN_W / 3).attr('y', 25) |
| 78 | + .text('Ratio b / (a + b) = ') |
| 79 | + .attr('font-family', 'sans-serif').attr('font-size', '16px') |
| 80 | + .attr('fill', text_color); |
| 81 | + var lb = lbl.node().getBBox(); |
| 82 | + |
| 83 | + var ratioTxt = svg.append('text').attr('class', 'polya-ratio') |
| 84 | + .attr('x', lb.x + lb.width + 5).attr('y', 25) |
| 85 | + .text(d3.format(',.2f')(b0 / (a0 + b0))) |
| 86 | + .attr('font-family', 'sans-serif').attr('font-size', '16px') |
| 87 | + .attr('fill', b0 > a0 ? bcolor : acolor); |
| 88 | + var rb = ratioTxt.node().getBBox(); |
| 89 | + |
| 90 | + svg.append('rect') |
| 91 | + .attr('x', lb.x - txt_pad).attr('y', lb.y - txt_pad) |
| 92 | + .attr('width', lb.width + rb.width + 5 + 2 * txt_pad) |
| 93 | + .attr('height', lb.height + 2 * txt_pad) |
| 94 | + .attr('fill', 'none').style('stroke', text_color).style('stroke-width', 1); |
| 95 | + |
| 96 | + function updateRatio(a, b) { |
| 97 | + svg.select('.polya-ratio') |
| 98 | + .text(d3.format(',.2f')(b / (a + b))) |
| 99 | + .attr('fill', b > a ? bcolor : acolor); |
| 100 | + } |
| 101 | + |
| 102 | + /* Simulate button – add listener directly, no cloneNode needed */ |
| 103 | + var btn = figureEl.querySelector('button.simulate'); |
| 104 | + if (btn) { |
| 105 | + btn.addEventListener('click', function () { |
| 106 | + var a = parseInt((dynSpans[0] || {}).textContent || '1', 10); |
| 107 | + var b = parseInt((dynSpans[1] || {}).textContent || '2', 10); |
| 108 | + |
| 109 | + svg.selectAll('circle.ball').interrupt(); |
| 110 | + drawBalls(svg, a, b); |
| 111 | + |
| 112 | + var max_balls = (Math.floor(URN_H / (2 * r + line_offset)) - 1) * num_cols; |
| 113 | + var step = 0; |
| 114 | + while (a < max_balls && b < max_balls) { |
| 115 | + var color, sign, pos; |
| 116 | + if (Math.random() > b / (a + b)) { |
| 117 | + color = acolor; sign = -1; pos = a++; |
| 118 | + } else { |
| 119 | + color = bcolor; sign = 1; pos = b++; |
| 120 | + } |
| 121 | + step++; |
| 122 | + svg.append('circle').attr('class', 'ball') |
| 123 | + .attr('r', r) |
| 124 | + .attr('cx', URN_W / 2 + sign * (r + (pos % num_cols) * 2 * r)) |
| 125 | + .attr('cy', r) /* start just inside the top edge */ |
| 126 | + .style('stroke', 'black').style('stroke-width', 2) |
| 127 | + .style('fill', color) |
| 128 | + .transition().ease(d3.easeCubicOut).duration(400).delay(step * 8) |
| 129 | + .attr('cy', ballY(pos)); |
| 130 | + } |
| 131 | + updateRatio(a, b); |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 135 | + /* draggable parameter spans */ |
| 136 | + makeDraggable(dynSpans[0], 1, 12, function (val) { |
| 137 | + a0 = val; |
| 138 | + drawBalls(svg, a0, b0); |
| 139 | + updateRatio(a0, b0); |
| 140 | + }); |
| 141 | + makeDraggable(dynSpans[1], 1, 12, function (val) { |
| 142 | + b0 = val; |
| 143 | + drawBalls(svg, a0, b0); |
| 144 | + updateRatio(a0, b0); |
| 145 | + }); |
| 146 | + } |
| 147 | + |
| 148 | + /* ═══════════════════════════════════════════════════════════════════════ |
| 149 | + Figure 2 – PolyaDynamics (time-series) |
| 150 | + ═══════════════════════════════════════════════════════════════════════ */ |
| 151 | + |
| 152 | + function drawBaseline(svg, a, b) { |
| 153 | + svg.selectAll('.dyn').remove(); |
| 154 | + var y0 = DYN_H * (1 - b / (a + b)); |
| 155 | + |
| 156 | + svg.append('line').attr('class', 'dyn') |
| 157 | + .style('stroke', 'black').style('stroke-dasharray', '3,3').style('opacity', 0.8) |
| 158 | + .attr('x1', 0).attr('y1', y0) |
| 159 | + .attr('x2', (NUM_STEPS + 1) * 10).attr('y2', y0); |
| 160 | + |
| 161 | + var hgap = 8, hwidth = 65, vgap = 20, vheight = 26; |
| 162 | + var y0_t = (b > a) ? y0 + vgap + vheight : y0; |
| 163 | + |
| 164 | + svg.append('text').attr('class', 'dyn') |
| 165 | + .attr('x', DYN_W - hgap - hwidth + 5).attr('y', y0_t - vgap) |
| 166 | + .text('Y').attr('font-family', 'sans-serif').attr('font-size', '16px') |
| 167 | + .attr('fill', text_color) |
| 168 | + .append('tspan').text('0').style('font-size', '10px').attr('dx', '-.2em').attr('dy', '.7em'); |
| 169 | + |
| 170 | + svg.append('text').attr('class', 'dyn') |
| 171 | + .attr('x', DYN_W - hgap - hwidth + 15).attr('y', y0_t - vgap) |
| 172 | + .text('= ' + d3.format(',.2f')(b / (a + b))) |
| 173 | + .attr('font-family', 'sans-serif').attr('font-size', '16px').attr('fill', text_color); |
| 174 | + |
| 175 | + svg.append('rect').attr('class', 'dyn') |
| 176 | + .attr('x', DYN_W - hwidth - hgap).attr('y', y0_t - vgap - 17) |
| 177 | + .attr('width', hwidth).attr('height', vheight) |
| 178 | + .attr('fill', 'none').style('stroke', text_color).style('stroke-width', 1); |
| 179 | + } |
| 180 | + |
| 181 | + function initPolyaDynamics(figureEl) { |
| 182 | + var container = figureEl.querySelector('.d3-component'); |
| 183 | + if (!container) return; |
| 184 | + |
| 185 | + container.innerHTML = ''; |
| 186 | + |
| 187 | + var dynSpans = figureEl.querySelectorAll('.idyll-dynamic'); |
| 188 | + var a0 = parseInt((dynSpans[0] || {}).textContent || '1', 10); |
| 189 | + var b0 = parseInt((dynSpans[1] || {}).textContent || '2', 10); |
| 190 | + |
| 191 | + var svg = d3.select(container).append('svg') |
| 192 | + .attr('viewBox', '0 0 ' + DYN_W + ' ' + DYN_H) |
| 193 | + .attr('overflow', 'visible') |
| 194 | + .style('width', '100%') |
| 195 | + .style('height', 'auto') |
| 196 | + .style('display', 'block'); |
| 197 | + |
| 198 | + svg.append('rect') |
| 199 | + .attr('width', DYN_W).attr('height', DYN_H).attr('fill', 'white') |
| 200 | + .style('stroke', '#ccc').style('stroke-width', 1); |
| 201 | + |
| 202 | + drawBaseline(svg, a0, b0); |
| 203 | + |
| 204 | + var btn = figureEl.querySelector('button.simulate'); |
| 205 | + if (btn) { |
| 206 | + btn.addEventListener('click', function () { |
| 207 | + var a = parseInt((dynSpans[0] || {}).textContent || '1', 10); |
| 208 | + var b = parseInt((dynSpans[1] || {}).textContent || '2', 10); |
| 209 | + |
| 210 | + svg.selectAll('.dyn').interrupt(); |
| 211 | + drawBaseline(svg, a, b); |
| 212 | + |
| 213 | + var duration = 2200 / NUM_STEPS; |
| 214 | + var bvals = []; |
| 215 | + for (var k = 0; k < NUM_RUNS; k++) bvals.push(b); |
| 216 | + |
| 217 | + for (var i = 0; i < NUM_STEPS; i++) { |
| 218 | + for (var j = 0; j < NUM_RUNS; j++) { |
| 219 | + var adrawn = Math.random() > bvals[j] / (a + b + i); |
| 220 | + if (!adrawn) bvals[j]++; |
| 221 | + |
| 222 | + svg.append('line').attr('class', 'dyn') |
| 223 | + .style('stroke', 'PowderBlue').style('opacity', 0.35) |
| 224 | + .transition().duration(duration).delay(i * duration) |
| 225 | + .attr('x1', i * 10) |
| 226 | + .attr('y1', DYN_H * (1 - (adrawn ? bvals[j] : bvals[j] - 1) / (a + b + i))) |
| 227 | + .attr('x2', (i + 1) * 10) |
| 228 | + .attr('y2', DYN_H * (1 - bvals[j] / (b + a + i + 1))); |
| 229 | + } |
| 230 | + |
| 231 | + (function (step, snapshot_b, a_val, b_val) { |
| 232 | + var sum = 0; |
| 233 | + for (var s = 0; s < snapshot_b.length; s++) sum += snapshot_b[s]; |
| 234 | + var avg = sum / snapshot_b.length; |
| 235 | + svg.append('circle').attr('class', 'dyn') |
| 236 | + .transition().duration(duration).delay(step * duration) |
| 237 | + .attr('cx', step * 10) |
| 238 | + .attr('cy', DYN_H * (1 - avg / (b_val + a_val + step + 1))) |
| 239 | + .attr('r', 3).style('fill', 'Tomato'); |
| 240 | + }(i, bvals.slice(), a, b)); |
| 241 | + } |
| 242 | + }); |
| 243 | + } |
| 244 | + |
| 245 | + makeDraggable(dynSpans[0], 1, 12, function (val) { |
| 246 | + a0 = val; drawBaseline(svg, a0, b0); |
| 247 | + }); |
| 248 | + makeDraggable(dynSpans[1], 1, 12, function (val) { |
| 249 | + b0 = val; drawBaseline(svg, a0, b0); |
| 250 | + }); |
| 251 | + } |
| 252 | + |
| 253 | + /* ═══════════════════════════════════════════════════════════════════════ |
| 254 | + Draggable .idyll-dynamic spans |
| 255 | + ═══════════════════════════════════════════════════════════════════════ */ |
| 256 | + |
| 257 | + function makeDraggable(el, min, max, onChange) { |
| 258 | + if (!el) return; |
| 259 | + el.style.cursor = 'ew-resize'; |
| 260 | + el.style.userSelect = 'none'; |
| 261 | + el.title = 'Drag left/right to change value'; |
| 262 | + |
| 263 | + el.addEventListener('mousedown', function (e) { |
| 264 | + var startX = e.clientX; |
| 265 | + var startVal = parseInt(el.textContent, 10); |
| 266 | + |
| 267 | + function onMove(e2) { |
| 268 | + var delta = Math.round((e2.clientX - startX) / 20); |
| 269 | + var newVal = Math.max(min, Math.min(max, startVal + delta)); |
| 270 | + if (parseInt(el.textContent, 10) !== newVal) { |
| 271 | + el.textContent = newVal; |
| 272 | + onChange(newVal); |
| 273 | + } |
| 274 | + } |
| 275 | + function onUp() { |
| 276 | + document.removeEventListener('mousemove', onMove); |
| 277 | + document.removeEventListener('mouseup', onUp); |
| 278 | + } |
| 279 | + document.addEventListener('mousemove', onMove); |
| 280 | + document.addEventListener('mouseup', onUp); |
| 281 | + e.preventDefault(); |
| 282 | + }); |
| 283 | + } |
| 284 | + |
| 285 | + /* ═══════════════════════════════════════════════════════════════════════ |
| 286 | + Bootstrap |
| 287 | + ═══════════════════════════════════════════════════════════════════════ */ |
| 288 | + |
| 289 | + function bootstrap() { |
| 290 | + var root = document.getElementById('idyll-mount') || document; |
| 291 | + var figures = root.querySelectorAll('.figure'); |
| 292 | + if (figures[0]) initPolyaUrn(figures[0]); |
| 293 | + if (figures[1]) initPolyaDynamics(figures[1]); |
| 294 | + } |
| 295 | + |
| 296 | + if (document.readyState === 'loading') { |
| 297 | + document.addEventListener('DOMContentLoaded', bootstrap); |
| 298 | + } else { |
| 299 | + bootstrap(); |
| 300 | + } |
| 301 | + |
| 302 | +}()); |
0 commit comments