const DONUT_ICONS = { beaker: '', grinder: '' }; const esc = (s) => s.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); class DonutChart extends HTMLElement { static get observedAttributes() { return ['data-items']; } connectedCallback() { requestAnimationFrame(() => this.render()); this._themeObserver = new MutationObserver(() => requestAnimationFrame(() => this.render())); this._themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }); } disconnectedCallback() { this._themeObserver?.disconnect(); this._themeObserver = null; } attributeChangedCallback() { if (this.isConnected) requestAnimationFrame(() => this.render()); } render() { const raw = this.dataset.items || ''; if (!raw) { this.innerHTML = ''; return; } const items = raw.split('|').map(s => { const idx = s.lastIndexOf(':'); if (idx === -1) return null; const label = s.slice(0, idx).trim(); const count = parseInt(s.slice(idx + 1), 10); return (label && count > 0) ? { label, count } : null; }).filter(Boolean); if (items.length === 0) { this.innerHTML = ''; return; } const total = items.reduce((sum, i) => sum + i.count, 0); const maxCount = items[0].count; const rgb = getComputedStyle(document.documentElement).getPropertyValue('--highlight-rgb').trim() || '185, 28, 28'; const colorFor = (count) => { const alpha = (0.25 + 0.75 * (count / maxCount)).toFixed(2); return `rgba(${rgb}, ${alpha})`; }; const size = 140; const strokeWidth = 28; const radius = (size - strokeWidth) / 2; const circumference = 2 * Math.PI * radius; const cx = size / 2; const cy = size / 2; const gapDeg = items.length > 1 ? 3 : 0; const gapArc = (gapDeg / 360) * circumference; let angle = 0; const segments = items.map((item, i) => { const fraction = item.count / total; const arcLen = fraction * circumference; const visible = Math.max(0, arcLen - gapArc); const rotation = angle - 90; angle += fraction * 360; return ``; }); const legend = items.map((item, i) => { const pct = Math.round(item.count / total * 100); return `
${esc(item.label)} ${pct}%
`; }); this.innerHTML = `
${segments.join('')} ${(() => { const icon = DONUT_ICONS[this.dataset.icon]; if (!icon) return ''; const s = 24; return `${icon}`; })()}
${legend.join('')}
`; } } customElements.define('donut-chart', DonutChart);