mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 05:46:42 +02:00
- Add reusable chart components in shared-ui (StatsGrid, ActivityHeatmap, TrendLineChart, DonutChart, ProgressBars, StatisticsSkeleton) - Use CSS variables (--primary) for consistent theme-based styling - Add statistics pages to Calendar and Contacts apps - Add statistics stores with app-specific metrics - Fix PriorityDonutChart layout in Todo app (vertical layout with 2x2 legend grid) - Add date-fns dependency to shared-ui 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
260 lines
5.3 KiB
Svelte
260 lines
5.3 KiB
Svelte
<script lang="ts">
|
|
import type { DonutSegment } from './types';
|
|
|
|
interface Props {
|
|
data: DonutSegment[];
|
|
title?: string;
|
|
centerLabel?: string;
|
|
centerValue?: number | string;
|
|
showLegend?: boolean;
|
|
}
|
|
|
|
let {
|
|
data,
|
|
title = 'Verteilung',
|
|
centerLabel = 'Gesamt',
|
|
centerValue,
|
|
showLegend = true,
|
|
}: Props = $props();
|
|
|
|
// Chart settings
|
|
const SIZE = 200;
|
|
const CENTER = SIZE / 2;
|
|
const RADIUS = 80;
|
|
const INNER_RADIUS = 50;
|
|
|
|
// Total count
|
|
let total = $derived(centerValue ?? data.reduce((sum, d) => sum + d.count, 0));
|
|
|
|
// Generate arc paths
|
|
let arcs = $derived.by(() => {
|
|
const totalCount = data.reduce((sum, d) => sum + d.count, 0);
|
|
if (totalCount === 0) return [];
|
|
|
|
const result: Array<{
|
|
path: string;
|
|
color: string;
|
|
id: string;
|
|
label: string;
|
|
count: number;
|
|
percentage: number;
|
|
}> = [];
|
|
|
|
let currentAngle = -90; // Start at top
|
|
|
|
data.forEach((segment) => {
|
|
if (segment.count === 0) return;
|
|
|
|
const angle = (segment.count / totalCount) * 360;
|
|
const startAngle = currentAngle;
|
|
const endAngle = currentAngle + angle;
|
|
|
|
// Convert angles to radians
|
|
const startRad = (startAngle * Math.PI) / 180;
|
|
const endRad = (endAngle * Math.PI) / 180;
|
|
|
|
// Calculate points
|
|
const x1 = CENTER + RADIUS * Math.cos(startRad);
|
|
const y1 = CENTER + RADIUS * Math.sin(startRad);
|
|
const x2 = CENTER + RADIUS * Math.cos(endRad);
|
|
const y2 = CENTER + RADIUS * Math.sin(endRad);
|
|
const x3 = CENTER + INNER_RADIUS * Math.cos(endRad);
|
|
const y3 = CENTER + INNER_RADIUS * Math.sin(endRad);
|
|
const x4 = CENTER + INNER_RADIUS * Math.cos(startRad);
|
|
const y4 = CENTER + INNER_RADIUS * Math.sin(startRad);
|
|
|
|
const largeArc = angle > 180 ? 1 : 0;
|
|
|
|
// Create arc path
|
|
const path = [
|
|
`M ${x1} ${y1}`,
|
|
`A ${RADIUS} ${RADIUS} 0 ${largeArc} 1 ${x2} ${y2}`,
|
|
`L ${x3} ${y3}`,
|
|
`A ${INNER_RADIUS} ${INNER_RADIUS} 0 ${largeArc} 0 ${x4} ${y4}`,
|
|
'Z',
|
|
].join(' ');
|
|
|
|
result.push({
|
|
path,
|
|
color: segment.color,
|
|
id: segment.id,
|
|
label: segment.label,
|
|
count: segment.count,
|
|
percentage: segment.percentage,
|
|
});
|
|
|
|
currentAngle = endAngle;
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
// Hover state
|
|
let hoveredSegment = $state<string | null>(null);
|
|
</script>
|
|
|
|
<div class="donut-container">
|
|
<h3 class="donut-title">{title}</h3>
|
|
|
|
<div class="donut-content">
|
|
<div class="donut-chart">
|
|
<svg viewBox="0 0 {SIZE} {SIZE}" class="donut-svg">
|
|
{#each arcs as arc}
|
|
<path
|
|
d={arc.path}
|
|
fill={arc.color}
|
|
class="arc-segment"
|
|
class:hovered={hoveredSegment === arc.id}
|
|
onmouseenter={() => (hoveredSegment = arc.id)}
|
|
onmouseleave={() => (hoveredSegment = null)}
|
|
role="graphics-symbol"
|
|
aria-label="{arc.label}: {arc.count}"
|
|
>
|
|
<title>{arc.label}: {arc.count} ({arc.percentage}%)</title>
|
|
</path>
|
|
{/each}
|
|
|
|
<!-- Center text -->
|
|
<text x={CENTER} y={CENTER - 8} class="center-count">
|
|
{total}
|
|
</text>
|
|
<text x={CENTER} y={CENTER + 12} class="center-label">
|
|
{centerLabel}
|
|
</text>
|
|
</svg>
|
|
</div>
|
|
|
|
<!-- Legend -->
|
|
{#if showLegend}
|
|
<div class="donut-legend">
|
|
{#each data as item}
|
|
<div
|
|
class="legend-item"
|
|
class:active={hoveredSegment === item.id}
|
|
onmouseenter={() => (hoveredSegment = item.id)}
|
|
onmouseleave={() => (hoveredSegment = null)}
|
|
role="button"
|
|
tabindex="0"
|
|
>
|
|
<span class="legend-color" style="background-color: {item.color}"></span>
|
|
<span class="legend-label">{item.label}</span>
|
|
<span class="legend-count">{item.count}</span>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.donut-container {
|
|
background: rgba(255, 255, 255, 0.95);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
|
border-radius: 1.5rem;
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
:global(.dark) .donut-container {
|
|
background: rgba(30, 30, 30, 0.95);
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
}
|
|
|
|
.donut-title {
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
color: hsl(var(--foreground));
|
|
margin: 0 0 1rem 0;
|
|
}
|
|
|
|
.donut-content {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1.5rem;
|
|
}
|
|
|
|
@media (max-width: 400px) {
|
|
.donut-content {
|
|
flex-direction: column;
|
|
}
|
|
}
|
|
|
|
.donut-chart {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.donut-svg {
|
|
width: 140px;
|
|
height: 140px;
|
|
}
|
|
|
|
.arc-segment {
|
|
transition:
|
|
opacity 0.15s ease,
|
|
transform 0.15s ease;
|
|
transform-origin: center;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.arc-segment:hover,
|
|
.arc-segment.hovered {
|
|
opacity: 0.85;
|
|
transform: scale(1.02);
|
|
}
|
|
|
|
.center-count {
|
|
font-size: 28px;
|
|
font-weight: 700;
|
|
fill: hsl(var(--foreground));
|
|
text-anchor: middle;
|
|
}
|
|
|
|
.center-label {
|
|
font-size: 12px;
|
|
fill: hsl(var(--muted-foreground));
|
|
text-anchor: middle;
|
|
}
|
|
|
|
.donut-legend {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.legend-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.375rem 0.5rem;
|
|
border-radius: 0.5rem;
|
|
cursor: pointer;
|
|
transition: background-color 0.15s ease;
|
|
}
|
|
|
|
.legend-item:hover,
|
|
.legend-item.active {
|
|
background: hsl(var(--muted) / 0.3);
|
|
}
|
|
|
|
.legend-color {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 3px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.legend-label {
|
|
font-size: 0.875rem;
|
|
color: hsl(var(--foreground));
|
|
flex: 1;
|
|
}
|
|
|
|
.legend-count {
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
color: hsl(var(--muted-foreground));
|
|
}
|
|
</style>
|