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);