using Avalonia; using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Controls.Shapes; using Avalonia.Media; using LehrerApp.Desktop.ViewModels.Groups; using System.Collections.Specialized; using System.ComponentModel; namespace LehrerApp.Desktop.Views.Groups; /// /// Zoomfähige Canvas-Zeitleiste für die drei Mitarbeitmerkmale. Die Shapes werden bewusst /// als Controls erzeugt, damit Ereignisse anklickbar und Tooltips weiterhin verfügbar sind. /// public partial class ParticipationTimelineChart : UserControl { private const double LeftMargin = 76; private const double RightMargin = 34; private const double PlotTop = 78; private const double PlotStep = 42; private const double ChartHeight = 370; private static readonly Color QualityColor = Color.Parse("#2E86DE"); private static readonly Color QuantityColor = Color.Parse("#E67E22"); private static readonly Color WorkphaseColor = Color.Parse("#16A085"); private static readonly Color TrendColor = Color.Parse("#6C5CE7"); public static readonly StyledProperty?> TimelineProperty = AvaloniaProperty.Register?>(nameof(Timeline)); public static readonly StyledProperty ZoomProperty = AvaloniaProperty.Register(nameof(Zoom), 1.0); public static readonly StyledProperty ShowQualityProperty = AvaloniaProperty.Register(nameof(ShowQuality), true); public static readonly StyledProperty ShowQuantityProperty = AvaloniaProperty.Register(nameof(ShowQuantity), true); public static readonly StyledProperty ShowWorkphaseProperty = AvaloniaProperty.Register(nameof(ShowWorkphase), true); public static readonly StyledProperty ShowDataPointsProperty = AvaloniaProperty.Register(nameof(ShowDataPoints), true); public static readonly StyledProperty ShowWeightedTrendProperty = AvaloniaProperty.Register(nameof(ShowWeightedTrend), true); public static readonly StyledProperty ShowQualityTrendProperty = AvaloniaProperty.Register(nameof(ShowQualityTrend)); public static readonly StyledProperty ShowQuantityTrendProperty = AvaloniaProperty.Register(nameof(ShowQuantityTrend)); public static readonly StyledProperty ShowWorkphaseTrendProperty = AvaloniaProperty.Register(nameof(ShowWorkphaseTrend)); private INotifyCollectionChanged? _subscribedTimeline; private readonly List _subscribedPoints = []; public IEnumerable? Timeline { get => GetValue(TimelineProperty); set => SetValue(TimelineProperty, value); } public double Zoom { get => GetValue(ZoomProperty); set => SetValue(ZoomProperty, value); } public bool ShowQuality { get => GetValue(ShowQualityProperty); set => SetValue(ShowQualityProperty, value); } public bool ShowQuantity { get => GetValue(ShowQuantityProperty); set => SetValue(ShowQuantityProperty, value); } public bool ShowWorkphase { get => GetValue(ShowWorkphaseProperty); set => SetValue(ShowWorkphaseProperty, value); } public bool ShowDataPoints { get => GetValue(ShowDataPointsProperty); set => SetValue(ShowDataPointsProperty, value); } public bool ShowWeightedTrend { get => GetValue(ShowWeightedTrendProperty); set => SetValue(ShowWeightedTrendProperty, value); } public bool ShowQualityTrend { get => GetValue(ShowQualityTrendProperty); set => SetValue(ShowQualityTrendProperty, value); } public bool ShowQuantityTrend { get => GetValue(ShowQuantityTrendProperty); set => SetValue(ShowQuantityTrendProperty, value); } public bool ShowWorkphaseTrend { get => GetValue(ShowWorkphaseTrendProperty); set => SetValue(ShowWorkphaseTrendProperty, value); } public ParticipationTimelineChart() => InitializeComponent(); protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); if (change.Property == TimelineProperty) { SubscribeToTimeline(); Rebuild(); } else if (change.Property == ZoomProperty || change.Property == ShowQualityProperty || change.Property == ShowQuantityProperty || change.Property == ShowWorkphaseProperty || change.Property == ShowDataPointsProperty || change.Property == ShowWeightedTrendProperty || change.Property == ShowQualityTrendProperty || change.Property == ShowQuantityTrendProperty || change.Property == ShowWorkphaseTrendProperty) { Rebuild(); } } private void SubscribeToTimeline() { if (_subscribedTimeline is not null) _subscribedTimeline.CollectionChanged -= TimelineChanged; foreach (var point in _subscribedPoints) point.PropertyChanged -= PointChanged; _subscribedPoints.Clear(); _subscribedTimeline = Timeline as INotifyCollectionChanged; if (_subscribedTimeline is not null) _subscribedTimeline.CollectionChanged += TimelineChanged; SubscribeToPoints(); } private void SubscribeToPoints() { foreach (var point in _subscribedPoints) point.PropertyChanged -= PointChanged; _subscribedPoints.Clear(); foreach (var point in Timeline?.SelectMany(g => g.Points) ?? []) { point.PropertyChanged += PointChanged; _subscribedPoints.Add(point); } } private void TimelineChanged(object? sender, NotifyCollectionChangedEventArgs e) { SubscribeToPoints(); Rebuild(); } private void PointChanged(object? sender, PropertyChangedEventArgs e) => Rebuild(); private void Rebuild() { if (ChartCanvas is null) return; ChartCanvas.Children.Clear(); var groups = Timeline?.ToList() ?? []; var slots = new List<(WizardTimelinePoint Point, double X)>(); var spacing = 82 * Math.Clamp(Zoom, 0.65, 2.25); var slotIndex = 0; foreach (var (group, groupIndex) in groups.Select((g, i) => (g, i))) { // Die erste Bandkante gehört zur Zeichenfläche und darf nicht mit dem Zoom // nach links über die Achsenbeschriftung wandern. var groupStart = groupIndex == 0 ? LeftMargin - 10 : LeftMargin + slotIndex * spacing - spacing / 2; foreach (var point in group.Points) { slots.Add((point, LeftMargin + slotIndex * spacing)); slotIndex++; } if (group.Points.Count == 0) slotIndex++; var groupEnd = LeftMargin + (slotIndex - 1) * spacing + spacing / 2; AddSectionBand(group, groupIndex, groupStart, Math.Max(1, groupEnd - groupStart)); } var width = Math.Max(720, LeftMargin + Math.Max(1, slotIndex - 1) * spacing + Math.Max(RightMargin, spacing / 2 + 10)); Width = width; Height = ChartHeight; ChartCanvas.Width = width; ChartCanvas.Height = ChartHeight; DrawGrid(width); if (ShowWeightedTrend) DrawTrend(slots); DrawAspect(slots, "quality", QualityColor, showLine: ShowQuality, showPoints: ShowDataPoints && (ShowQuality || ShowQualityTrend)); DrawAspect(slots, "quantity", QuantityColor, showLine: ShowQuantity, showPoints: ShowDataPoints && (ShowQuantity || ShowQuantityTrend)); DrawAspect(slots, "workphase", WorkphaseColor, showLine: ShowWorkphase, showPoints: ShowDataPoints && (ShowWorkphase || ShowWorkphaseTrend)); if (ShowQualityTrend) DrawAspectTrend(slots, "quality", QualityColor); if (ShowQuantityTrend) DrawAspectTrend(slots, "quantity", QuantityColor); if (ShowWorkphaseTrend) DrawAspectTrend(slots, "workphase", WorkphaseColor); DrawPoints(slots); if (slots.Count == 0) AddText("Noch keine Mitarbeit oder Leistungsnachweise vorhanden.", LeftMargin, 165, 13, 0.55); } private void AddSectionBand(WizardSectionGroup group, int index, double x, double width) { var fill = group.IsOpen ? Color.Parse("#1320A464") : index % 2 == 0 ? Color.Parse("#102E86DE") : Color.Parse("#0816A085"); var band = new Rectangle { Width = width, Height = 352, Fill = new SolidColorBrush(fill), RadiusX = 6, RadiusY = 6, IsHitTestVisible = false, }; Canvas.SetLeft(band, x); Canvas.SetTop(band, 2); ChartCanvas.Children.Add(band); var label = AddText(group.BandLabel.Replace('\n', ' '), x + 8, 9, 10, 0.62); label.FontWeight = FontWeight.SemiBold; } private void DrawGrid(double width) { var labels = new[] { "++", "+", "~", "−", "−−" }; for (var i = 0; i < labels.Length; i++) { var y = PlotTop + i * PlotStep; var line = new Line { StartPoint = new Point(LeftMargin - 10, y), EndPoint = new Point(width - RightMargin, y), Stroke = new SolidColorBrush(Color.Parse(i == 2 ? "#352F3E46" : "#202F3E46")), StrokeThickness = i == 2 ? 1.4 : 1, IsHitTestVisible = false, }; ChartCanvas.Children.Add(line); AddText(labels[i], 22, y - 9, 11, i == 2 ? 0.75 : 0.48); } AddText("Stundennote", 8, 42, 10, 0.55); AddText("Datum / Ereignisse", 8, 289, 10, 0.55); } private void DrawAspect(List<(WizardTimelinePoint Point, double X)> slots, string key, Color color, bool showLine, bool showPoints) { var values = slots .Where(s => !s.Point.IsExam && s.Point.AspectValues.ContainsKey(key)) .Select(s => new Point(s.X, ValueY(s.Point.AspectValues[key]))) .ToList(); if (showLine) AddSmoothPath(values, color, 2.2, dashed: false, opacity: 0.92); if (!showPoints) return; foreach (var point in values) { var marker = new Ellipse { Width = 8, Height = 8, Fill = new SolidColorBrush(color), Stroke = Brushes.White, StrokeThickness = 1.2, IsHitTestVisible = false, }; Canvas.SetLeft(marker, point.X - 4); Canvas.SetTop(marker, point.Y - 4); ChartCanvas.Children.Add(marker); } } private void DrawTrend(List<(WizardTimelinePoint Point, double X)> slots) { var rated = slots.Where(s => !s.Point.IsExam && s.Point.WeightedValue is not null).ToList(); var smoothed = new List(); for (var i = 0; i < rated.Count; i++) { var weightedSum = rated[i].Point.WeightedValue!.Value * 2.0; var weightSum = 2.0; if (i > 0) { weightedSum += rated[i - 1].Point.WeightedValue!.Value; weightSum++; } if (i + 1 < rated.Count) { weightedSum += rated[i + 1].Point.WeightedValue!.Value; weightSum++; } smoothed.Add(new Point(rated[i].X, ValueY(weightedSum / weightSum))); } AddSmoothPath(smoothed, TrendColor, 4, dashed: true, opacity: 0.72); } private void DrawAspectTrend(List<(WizardTimelinePoint Point, double X)> slots, string key, Color color) { var rated = slots .Where(s => !s.Point.IsExam && s.Point.AspectValues.ContainsKey(key)) .ToList(); var smoothed = new List(); for (var i = 0; i < rated.Count; i++) { // Gewichtete 3-Termine-Glättung: der aktuelle Termin zählt doppelt, // direkter Vorgänger und Nachfolger jeweils einfach (1–2–1). var weightedSum = rated[i].Point.AspectValues[key] * 2.0; var weightSum = 2.0; if (i > 0) { weightedSum += rated[i - 1].Point.AspectValues[key]; weightSum++; } if (i + 1 < rated.Count) { weightedSum += rated[i + 1].Point.AspectValues[key]; weightSum++; } smoothed.Add(new Point(rated[i].X, ValueY(weightedSum / weightSum))); } AddSmoothPath(smoothed, color, 4.2, dashed: true, opacity: 0.58); } private void AddSmoothPath(IReadOnlyList points, Color color, double thickness, bool dashed, double opacity) { if (points.Count < 2) return; var geometry = new StreamGeometry(); using (var context = geometry.Open()) { context.BeginFigure(points[0], false); for (var i = 1; i < points.Count; i++) { var previous = points[i - 1]; var current = points[i]; var middleX = (previous.X + current.X) / 2; context.CubicBezierTo( new Point(middleX, previous.Y), new Point(middleX, current.Y), current, true); } context.EndFigure(false); } var path = new Avalonia.Controls.Shapes.Path { Data = geometry, Stroke = new SolidColorBrush(color), StrokeThickness = thickness, Opacity = opacity, IsHitTestVisible = false, }; if (dashed) path.StrokeDashArray = new AvaloniaList { 7, 4 }; ChartCanvas.Children.Add(path); } private void DrawPoints(List<(WizardTimelinePoint Point, double X)> slots) { foreach (var (point, x) in slots) { var guide = new Line { StartPoint = new Point(x, PlotTop), EndPoint = new Point(x, 276), Stroke = new SolidColorBrush(Color.Parse("#122F3E46")), StrokeThickness = 1, IsHitTestVisible = false, }; ChartCanvas.Children.Add(guide); if (!point.IsExam && point.OverallGrade.Length > 0) AddBadge($"Note {point.OverallGrade}", x - 26, 40, "#E8EEF8", "#243B53", 52); var date = AddText(point.DateDisplay, x - 28, 278, 10, 0.62); date.Width = 56; date.TextAlignment = TextAlignment.Center; if (point.IsExam) { AddBadge(point.ExamLabel, x - 40, 307, "#7F77DD", "White", 80, point.TooltipText); continue; } const double attendanceWidth = 30; const double homeworkWidth = 30; const double eventGap = 4; var eventX = x - (attendanceWidth + eventGap + homeworkWidth) / 2; AddCommandBadge(point.AttendanceButtonLabel, eventX, 307, attendanceWidth, point.IsAbsent, point.AttendanceColor, point.CycleAttendanceCommand, point.AttendanceTooltip + " – klicken für nächsten Status"); eventX += attendanceWidth + eventGap; AddCommandBadge(point.HomeworkSymbol, eventX, 307, homeworkWidth, point.HasHomeworkStatus, point.HomeworkColor, point.ToggleHomeworkCommand, point.HomeworkTooltip + " – klicken für nächsten Status"); eventX += homeworkWidth + eventGap; if (point.HasNote) AddBadge("●", eventX, 311, "#607D8B", "White", 22, point.TooltipText); } } private Border AddBadge(string text, double x, double y, string background, string foreground, double width, string? tooltip = null) { var badge = new Border { Width = width, MinHeight = 22, CornerRadius = new CornerRadius(4), Background = new SolidColorBrush(Color.Parse(background)), Padding = new Thickness(4, 2), Child = new TextBlock { Text = text, FontSize = 9, Foreground = new SolidColorBrush(Color.Parse(foreground)), TextAlignment = TextAlignment.Center, TextWrapping = TextWrapping.Wrap, VerticalAlignment = Avalonia.Layout.VerticalAlignment.Center, }, }; if (tooltip is not null) ToolTip.SetTip(badge, tooltip); Canvas.SetLeft(badge, x); Canvas.SetTop(badge, y); ChartCanvas.Children.Add(badge); return badge; } private void AddCommandBadge(string text, double x, double y, double width, bool isActive, string activeBackground, System.Windows.Input.ICommand? command, string tooltip) { var button = new Button { Content = text, Command = command, FontSize = 12, Width = width, Height = 25, Padding = new Thickness(3, 1), HorizontalContentAlignment = Avalonia.Layout.HorizontalAlignment.Center, VerticalContentAlignment = Avalonia.Layout.VerticalAlignment.Center, }; // Im inaktiven Zustand bleiben Farben vollständig beim aktuellen Fluent-Theme. // So ist der Text sowohl im hellen als auch im dunklen Modus kontrastreich. if (isActive && Color.TryParse(activeBackground, out var parsedBackground)) { button.Background = new SolidColorBrush(parsedBackground); button.Foreground = Brushes.White; } else { button.Opacity = 0.34; } ToolTip.SetTip(button, tooltip); Canvas.SetLeft(button, x); Canvas.SetTop(button, y); ChartCanvas.Children.Add(button); } private TextBlock AddText(string text, double x, double y, double fontSize, double opacity) { var block = new TextBlock { Text = text, FontSize = fontSize, Opacity = opacity, IsHitTestVisible = false }; Canvas.SetLeft(block, x); Canvas.SetTop(block, y); ChartCanvas.Children.Add(block); return block; } private static double ValueY(double value) => PlotTop + (2 - Math.Clamp(value, -2, 2)) * PlotStep; }