| <div class="d3-pr-timeline"></div> |
| <style> |
| .d3-pr-timeline { |
| position: relative; |
| } |
| .d3-pr-timeline .metric-cards { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 12px; |
| margin-bottom: 16px; |
| } |
| .d3-pr-timeline .metric-card { |
| background: var(--surface-bg); |
| padding: 12px; |
| border-left: 2px solid #2D5A27; |
| } |
| .d3-pr-timeline .metric-card:last-child { |
| border-left-color: #8B4513; |
| } |
| .d3-pr-timeline .mc-label { |
| font-size: 12px; |
| color: var(--text-color); |
| font-weight: 700; |
| } |
| .d3-pr-timeline .mc-sub { |
| font-size: 11px; |
| color: var(--muted-color, var(--text-color)); |
| opacity: 0.6; |
| } |
| .d3-pr-timeline .mc-value { |
| font-size: 20px; |
| font-weight: 700; |
| color: var(--text-color); |
| margin-top: 2px; |
| } |
| .d3-pr-timeline .mc-value span { |
| font-size: 12px; |
| font-weight: 400; |
| opacity: 0.6; |
| } |
| .d3-pr-timeline .mc-rate { |
| font-size: 11px; |
| color: var(--muted-color, var(--text-color)); |
| opacity: 0.6; |
| } |
| .d3-pr-timeline .legend { |
| display: flex; |
| flex-wrap: wrap; |
| gap: 16px; |
| margin-top: 8px; |
| font-size: 12px; |
| color: var(--text-color); |
| } |
| .d3-pr-timeline .legend .item { |
| display: flex; |
| align-items: center; |
| gap: 5px; |
| } |
| .d3-pr-timeline .legend .swatch { |
| width: 12px; |
| height: 12px; |
| display: inline-block; |
| flex-shrink: 0; |
| } |
| .d3-pr-timeline .d3-tooltip { |
| position: absolute; |
| pointer-events: none; |
| padding: 8px 10px; |
| font-size: 12px; |
| line-height: 1.35; |
| border: 1px solid var(--border-color); |
| background: var(--surface-bg); |
| color: var(--text-color); |
| box-shadow: 0 4px 24px rgba(0,0,0,.18); |
| opacity: 0; |
| transition: opacity .12s ease; |
| top: 0; left: 0; |
| transform: translate(-9999px, -9999px); |
| } |
| @media (max-width: 480px) { |
| .d3-pr-timeline .metric-cards { |
| grid-template-columns: repeat(2, 1fr); |
| } |
| } |
| </style> |
| <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 scriptEl = document.currentScript; |
| let container = scriptEl ? scriptEl.previousElementSibling : null; |
| if (!(container && container.classList && container.classList.contains('d3-pr-timeline'))) { |
| const candidates = Array.from(document.querySelectorAll('.d3-pr-timeline')) |
| .filter((el) => !(el.dataset && el.dataset.mounted === 'true')); |
| container = candidates[candidates.length - 1] || null; |
| } |
| if (!container) return; |
| if (container.dataset.mounted === 'true') return; |
| container.dataset.mounted = 'true'; |
| container.style.position = 'relative'; |
| |
| const tip = document.createElement('div'); |
| tip.className = 'd3-tooltip'; |
| const tipInner = document.createElement('div'); |
| tip.appendChild(tipInner); |
| container.appendChild(tip); |
| |
| const COLORS = ['#2D5A27', '#8B4513', '#6B6B6B', '#B8860B']; |
| const LABELS = ['Feature', 'Defect fix', 'Documentation', 'Other']; |
| const periods = [ |
| { label: 'Q3 2025', sub: 'Jun鈥揝ep 路 4 mo', total: 176, rate: '~44/mo', data: [55, 70, 43, 8] }, |
| { label: 'Q4 2025', sub: 'Oct鈥揇ec 路 3 mo', total: 207, rate: '~69/mo', data: [83, 76, 31, 17] }, |
| { label: 'Q1 2026', sub: 'Jan鈥揗ar 路 3 mo', total: 222, rate: '~74/mo', data: [116, 80, 15, 11] }, |
| { label: 'Apr 2026', sub: '1 month', total: 167, rate: '~167/mo', data: [72, 73, 8, 14] } |
| ]; |
| |
| const cards = document.createElement('div'); |
| cards.className = 'metric-cards'; |
| periods.forEach(p => { |
| const c = document.createElement('div'); |
| c.className = 'metric-card'; |
| c.innerHTML = `<div class="mc-label">${p.label}</div><div class="mc-sub">${p.sub}</div><div class="mc-value">${p.total} <span>PRs</span></div><div class="mc-rate">${p.rate}</div>`; |
| cards.appendChild(c); |
| }); |
| container.appendChild(cards); |
| |
| const svg = d3.select(container).append('svg').style('display', 'block'); |
| |
| function render() { |
| const w = container.clientWidth || 600; |
| const h = 280; |
| const margin = { top: 12, right: 16, bottom: 32, left: 48 }; |
| const iw = w - margin.left - margin.right; |
| const ih = h - margin.top - margin.bottom; |
| |
| svg.attr('width', w).attr('height', h); |
| svg.selectAll('g').remove(); |
| const g = svg.append('g').attr('transform', `translate(${margin.left},${margin.top})`); |
| |
| const x = d3.scaleBand().domain(periods.map(p => p.label)).range([0, iw]).padding(0.3); |
| const yMax = Math.max(...periods.map(p => p.total)); |
| const y = d3.scaleLinear().domain([0, yMax * 1.05]).range([ih, 0]); |
| |
| const stackData = periods.map((p, pi) => { |
| let y0 = 0; |
| return p.data.map((v, ci) => { |
| const obj = { y0, y1: y0 + v, value: v, period: p.label, cat: LABELS[ci] }; |
| y0 += v; |
| return obj; |
| }); |
| }); |
| |
| const axisColor = 'var(--axis-color, var(--text-color))'; |
| const tickColor = 'var(--tick-color, var(--muted-color, var(--text-color)))'; |
| const gridColor = 'var(--grid-color, rgba(128,128,128,0.15))'; |
| |
| g.append('g').attr('transform', `translate(0,${ih})`).call(d3.axisBottom(x).tickSize(0).tickPadding(8)) |
| .call(g => g.select('.domain').attr('stroke', axisColor)) |
| .call(g => g.selectAll('text').style('fill', tickColor).style('font-size', '12px')); |
| |
| g.append('g').call(d3.axisLeft(y).ticks(5).tickSize(-iw).tickPadding(8)) |
| .call(g => g.select('.domain').remove()) |
| .call(g => g.selectAll('.tick line').attr('stroke', gridColor)) |
| .call(g => g.selectAll('.tick text').style('fill', tickColor).style('font-size', '11px')); |
| |
| g.append('text').attr('transform', 'rotate(-90)').attr('x', -ih/2).attr('y', -38) |
| .attr('text-anchor', 'middle').style('fill', tickColor).style('font-size', '12px').text('PR count'); |
| |
| stackData.forEach((bars, pi) => { |
| const period = periods[pi]; |
| bars.forEach((b, ci) => { |
| g.append('rect') |
| .attr('x', x(period.label)) |
| .attr('y', y(b.y1)) |
| .attr('width', x.bandwidth()) |
| .attr('height', Math.max(0, y(b.y0) - y(b.y1))) |
| .attr('fill', COLORS[ci]) |
| .on('mouseenter', function(ev) { |
| tipInner.innerHTML = `<strong>${period.label}</strong><br>${b.cat}: ${b.value}`; |
| tip.style.opacity = '1'; |
| }) |
| .on('mousemove', function(ev) { |
| const rect = container.getBoundingClientRect(); |
| tip.style.transform = `translate(${ev.clientX - rect.left + 12}px, ${ev.clientY - rect.top - 20}px)`; |
| }) |
| .on('mouseleave', function() { |
| tip.style.opacity = '0'; |
| tip.style.transform = 'translate(-9999px, -9999px)'; |
| }); |
| }); |
| }); |
| } |
| |
| const legendEl = document.createElement('div'); |
| legendEl.className = 'legend'; |
| LABELS.forEach((l, i) => { |
| const item = document.createElement('span'); |
| item.className = 'item'; |
| item.innerHTML = `<span class="swatch" style="background:${COLORS[i]}"></span><span>${l}</span>`; |
| legendEl.appendChild(item); |
| }); |
| container.appendChild(legendEl); |
| |
| render(); |
| if (window.ResizeObserver) { new ResizeObserver(() => render()).observe(container); } |
| }; |
| |
| if (document.readyState === 'loading') { |
| document.addEventListener('DOMContentLoaded', () => ensureD3(bootstrap), { once: true }); |
| } else { ensureD3(bootstrap); } |
| })(); |
| </script> |