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>
149 lines
4.4 KiB
TypeScript
149 lines
4.4 KiB
TypeScript
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>
|
||
);
|
||
}
|