Files
LehrerApp/LehrerApp.Desktop/Views/Groups/ParticipationTimelineChart.axaml.cs
T

456 lines
18 KiB
C#
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.
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;
/// <summary>
/// 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.
/// </summary>
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<IEnumerable<WizardSectionGroup>?> TimelineProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, IEnumerable<WizardSectionGroup>?>(nameof(Timeline));
public static readonly StyledProperty<double> ZoomProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, double>(nameof(Zoom), 1.0);
public static readonly StyledProperty<bool> ShowQualityProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowQuality), true);
public static readonly StyledProperty<bool> ShowQuantityProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowQuantity), true);
public static readonly StyledProperty<bool> ShowWorkphaseProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowWorkphase), true);
public static readonly StyledProperty<bool> ShowDataPointsProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowDataPoints), true);
public static readonly StyledProperty<bool> ShowWeightedTrendProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowWeightedTrend), true);
public static readonly StyledProperty<bool> ShowQualityTrendProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowQualityTrend));
public static readonly StyledProperty<bool> ShowQuantityTrendProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowQuantityTrend));
public static readonly StyledProperty<bool> ShowWorkphaseTrendProperty =
AvaloniaProperty.Register<ParticipationTimelineChart, bool>(nameof(ShowWorkphaseTrend));
private INotifyCollectionChanged? _subscribedTimeline;
private readonly List<WizardTimelinePoint> _subscribedPoints = [];
public IEnumerable<WizardSectionGroup>? 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<Point>();
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<Point>();
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 (121).
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<Point> 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<double> { 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;
}