brewlog/static/js/components/donut-chart.js
Jon Seager 5820c162f6
fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile
- Fix design token violations (text-red-500, fallback colors)
- Add disconnectedCallback to chip-scroll, image-upload, searchable-select
- Replace innerHTML with safe DOM APIs in world-map screen reader table
- Add credentials: same-origin to WebAuthn fetch calls
- Move page-specific scripts (donut-chart, location) out of base.html
- Add client-side image resizing (1920px max dimension)
- Resize app-icon-512.png from 2048x2048 to 512x512
- Document Datastar unsafe-eval CSP requirement
- Add static asset serving tests (16 routes)
- Add e2e tests for world-map, donut-chart, chip-scroll presence
- Add cache-busting query params to all static asset URLs
2026-02-13 17:13:02 +00:00

122 lines
4.6 KiB
JavaScript

const DONUT_ICONS = {
beaker:
'<path fill-rule="evenodd" d="M8.5 3.528v4.644c0 .729-.29 1.428-.805 1.944l-1.217 1.216a8.75 8.75 0 0 1 3.55.621l.502.201a7.25 7.25 0 0 0 4.178.365l-2.403-2.403a2.75 2.75 0 0 1-.805-1.944V3.528a40.205 40.205 0 0 0-3 0Zm4.5.084.19.015a.75.75 0 1 0 .12-1.495 41.364 41.364 0 0 0-6.62 0 .75.75 0 0 0 .12 1.495L7 3.612v4.56c0 .331-.132.649-.366.883L2.6 13.09c-1.496 1.496-.817 4.15 1.403 4.475C5.961 17.852 7.963 18 10 18s4.039-.148 5.997-.436c2.22-.325 2.9-2.979 1.403-4.475l-4.034-4.034A1.25 1.25 0 0 1 13 8.172v-4.56Z" clip-rule="evenodd" />',
grinder:
'<path d="M3.5 3.5Q3.5 1.5 6 1.5h8Q16.5 1.5 16.5 3.5L13 6v9H7V6Z" /><rect x="5" y="16.5" width="10" height="1.5" rx=".5" />',
};
const esc = (s) =>
s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
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() || "194, 65, 12";
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 `<circle cx="${cx}" cy="${cy}" r="${radius}" fill="none"
stroke="${colorFor(item.count)}" stroke-width="${strokeWidth}"
stroke-dasharray="${visible} ${circumference - visible}"
transform="rotate(${rotation} ${cx} ${cy})" />`;
});
const legend = items.map((item, i) => {
const pct = Math.round((item.count / total) * 100);
return `<div style="display:flex;align-items:center;gap:0.5rem">
<span style="flex-shrink:0;width:0.5rem;height:0.5rem;border-radius:9999px;background:${colorFor(item.count)}"></span>
<span class="text-xs text-text-secondary" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(item.label)}</span>
<span class="text-xs text-text-muted" style="margin-left:auto;flex-shrink:0">${pct}%</span>
</div>`;
});
this.innerHTML = `<div style="display:flex;flex-direction:column;align-items:center;gap:1rem">
<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" style="display:block" role="img" aria-label="Distribution: ${items.map((i) => `${esc(i.label)} ${Math.round((i.count / total) * 100)}%`).join(", ")}">
${segments.join("")}
${(() => {
const icon = DONUT_ICONS[this.dataset.icon];
if (!icon) return "";
const s = 24;
return `<svg x="${cx - s / 2}" y="${cy - s / 2}" width="${s}" height="${s}" viewBox="0 0 20 20" class="text-text-muted" fill="currentColor">${icon}</svg>`;
})()}
</svg>
<div style="display:flex;flex-direction:column;gap:0.375rem;width:100%">
${legend.join("")}
</div>
</div>`;
}
}
customElements.define("donut-chart", DonutChart);