Mitarbeits Wizzard und neue Statuslabels für Anwesenheit und Hausaufgaben
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
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 (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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user