chahinebrini c3478f4743 fix(native): Gesamt-Verteilung als Half-Donut, 2 Circles + 1 Donut (gleiche Größe)
DeviceSlotDonut bekommt half-Modus. Reihenfolge: Mobil-Circle, Computer-Circle,
Gesamt-Half-Donut (Mobil/Computer-Anteil als zwei Bögen). Alle SIZE×SIZE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 00:30:08 +02:00

149 lines
4.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useRef, useState } from 'react';
import { Animated, Easing, Text, View } from 'react-native';
import Svg, { Circle, Path } from 'react-native-svg';
import { useColors } from '../../lib/theme';
const SIZE = 88;
const STROKE = 11;
const R = (SIZE - STROKE) / 2;
/** Geräte-Kategorie-Farben — auch im Gesamt-Half-Donut (Verteilung). */
export const MOBILE_COLOR = '#22c55e';
export const DESKTOP_COLOR = '#2563eb';
export type SlotSegment = { value: number; color: string };
function polar(cx: number, cy: number, r: number, deg: number) {
const rad = (deg * Math.PI) / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function arcPath(cx: number, cy: number, r: number, startDeg: number, endDeg: number) {
const start = polar(cx, cy, r, startDeg);
const end = polar(cx, cy, r, endDeg);
const large = endDeg - startDeg > 180 ? 1 : 0;
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 1 ${end.x} ${end.y}`;
}
/**
* Slot-Anzeige (react-native-svg, keine Lib), gleiche Größe in beiden Modi:
* - `half=false` (Default): voller Progress-Ring — eine Kategorie (Mobil/Computer).
* - `half=true`: Half-Donut für die Gesamt-Verteilung (Mobil-/Computer-Anteil
* als zwei farbige Bögen).
*/
export function DeviceSlotDonut({
segments,
total,
label,
atLimit,
half = false,
}: {
segments: SlotSegment[];
total: number;
label: string;
atLimit: boolean;
half?: boolean;
}) {
const colors = useColors();
const cx = SIZE / 2;
// Half-Donut tiefer setzen, damit der Bogen mittig im SIZE×SIZE-Feld sitzt.
const cy = half ? SIZE / 2 + R / 2 : SIZE / 2;
const r = R;
const safeTotal = Math.max(1, total);
const used = segments.reduce((s, x) => s + x.value, 0);
const startAngle = half ? 180 : -90;
const sweep = half ? 180 : 360;
const anim = useRef(new Animated.Value(0)).current;
const [progress, setProgress] = useState(0);
useEffect(() => {
anim.setValue(0);
const l = anim.addListener(({ value }) => setProgress(value));
Animated.timing(anim, {
toValue: 1,
duration: 1400,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
return () => anim.removeListener(l);
}, [used, total, anim]);
let cum = startAngle;
const arcs = segments.map((seg) => {
const span = sweep * (seg.value / safeTotal);
const start = cum;
const end = cum + span;
cum = end;
return { start, end, color: seg.color };
});
return (
<View style={{ alignItems: 'center', flex: 1 }}>
<View style={{ width: SIZE, height: SIZE }}>
<Svg width={SIZE} height={SIZE}>
{half ? (
<Path
d={arcPath(cx, cy, r, 180, 360)}
stroke={colors.surfaceElevated}
strokeWidth={STROKE}
fill="none"
strokeLinecap="round"
/>
) : (
<Circle cx={cx} cy={cy} r={r} stroke={colors.surfaceElevated} strokeWidth={STROKE} fill="none" />
)}
{arcs.map((a, i) => {
const animEnd = a.start + (a.end - a.start) * progress;
if (animEnd <= a.start + 0.5) return null;
const drawEnd = Math.min(animEnd, a.start + (half ? 179.99 : 359.99));
return (
<Path
key={i}
d={arcPath(cx, cy, r, a.start, drawEnd)}
stroke={a.color}
strokeWidth={STROKE}
fill="none"
strokeLinecap="round"
/>
);
})}
</Svg>
<View
pointerEvents="none"
style={{
position: 'absolute',
top: half ? SIZE / 2 - 4 : 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: half ? 'flex-start' : 'center',
}}
>
<Text style={{ fontSize: 22, fontFamily: 'Nunito_900Black', color: colors.text, letterSpacing: -0.5 }}>
{used}
<Text style={{ fontSize: 12, fontFamily: 'Nunito_700Bold', color: colors.textMuted }}>
/{total}
</Text>
</Text>
</View>
</View>
<Text
numberOfLines={1}
style={{
fontSize: 11,
color: atLimit ? colors.brandOrange : colors.textMuted,
fontFamily: 'Nunito_600SemiBold',
marginTop: 6,
textAlign: 'center',
}}
>
{label}
</Text>
</View>
);
}