using System; using System.Collections.Generic; using System.Linq; using CommunityToolkit.WinUI; using Itenso.TimePeriod; using Windows.Foundation; using Windows.UI.Xaml; using Windows.UI.Xaml.Controls; using Wino.Calendar.Models; using Wino.Calendar.ViewModels.Data; using Wino.Core.Domain.Interfaces; namespace Wino.Calendar.Controls; public partial class WinoCalendarPanel : Panel { private const double LastItemRightExtraMargin = 12d; // Store each ICalendarItem measurements by their Id. private readonly Dictionary _measurements = new Dictionary(); public static readonly DependencyProperty EventItemMarginProperty = DependencyProperty.Register(nameof(EventItemMargin), typeof(Thickness), typeof(WinoCalendarPanel), new PropertyMetadata(new Thickness(0, 0, 0, 0))); public static readonly DependencyProperty HourHeightProperty = DependencyProperty.Register(nameof(HourHeight), typeof(double), typeof(WinoCalendarPanel), new PropertyMetadata(0d)); public static readonly DependencyProperty PeriodProperty = DependencyProperty.Register(nameof(Period), typeof(ITimePeriod), typeof(WinoCalendarPanel), new PropertyMetadata(null)); public ITimePeriod Period { get { return (ITimePeriod)GetValue(PeriodProperty); } set { SetValue(PeriodProperty, value); } } public double HourHeight { get { return (double)GetValue(HourHeightProperty); } set { SetValue(HourHeightProperty, value); } } public Thickness EventItemMargin { get { return (Thickness)GetValue(EventItemMarginProperty); } set { SetValue(EventItemMarginProperty, value); } } private void ResetMeasurements() => _measurements.Clear(); private double GetChildTopMargin(ICalendarItem calendarItemViewModel, double availableHeight) { var childStart = calendarItemViewModel.StartDate; if (childStart <= Period.Start) { // Event started before or exactly at the periods tart. This might be a multi-day event. // We can simply consider event must not have a top margin. return 0d; } double minutesFromStart = (childStart - Period.Start).TotalMinutes; return (minutesFromStart / 1440) * availableHeight; } private double GetChildWidth(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) { return (calendarItemMeasurement.Right - calendarItemMeasurement.Left) * availableWidth; } private double GetChildLeftMargin(CalendarItemMeasurement calendarItemMeasurement, double availableWidth) => availableWidth * calendarItemMeasurement.Left; private double GetChildHeight(ICalendarItem child) { // All day events are not measured. if (child.IsAllDayEvent) return 0; double childDurationInMinutes = 0d; double availableHeight = HourHeight * 24; var periodRelation = child.Period.GetRelation(Period); // Debug.WriteLine($"Render relation of {child.Title} ({child.Period.Start} - {child.Period.End}) is {periodRelation} with {Period.Start.Day}"); if (!child.IsMultiDayEvent) { childDurationInMinutes = child.Period.Duration.TotalMinutes; } else { // Multi-day event. // Check how many of the event falls into the current period. childDurationInMinutes = (child.Period.End - Period.Start).TotalMinutes; } return (childDurationInMinutes / 1440) * availableHeight; } protected override Size MeasureOverride(Size availableSize) { ResetMeasurements(); return base.MeasureOverride(availableSize); } protected override Size ArrangeOverride(Size finalSize) { if (Period == null || HourHeight == 0d) return finalSize; // Measure/arrange each child height and width. // This is a vertical calendar. Therefore the height of each child is the duration of the event. // Children weights for left and right will be saved if they don't exist. // This is important because we don't want to measure the weights again. // They don't change until new event is added or removed. // Width of the each child may depend on the rectangle packing algorithm. // Children are first categorized into columns. Then each column is shifted to the left until // no overlap occurs. The width of each child is calculated based on the number of columns it spans. double availableHeight = finalSize.Height; double availableWidth = finalSize.Width; var calendarControls = Children.Cast(); if (!calendarControls.Any()) return base.ArrangeOverride(finalSize); var events = calendarControls.Select(a => a.Content as CalendarItemViewModel); LayoutEvents(events); foreach (var control in calendarControls) { // We can't arrange this child. if (!(control.Content is ICalendarItem child)) continue; bool isHorizontallyLastItem = false; double childWidth = 0, childHeight = Math.Max(0, GetChildHeight(child)), childTop = Math.Max(0, GetChildTopMargin(child, availableHeight)), childLeft = 0; // No need to measure anything here. if (childHeight == 0) continue; if (!_measurements.ContainsKey(child)) { // Multi-day event. childLeft = 0; childWidth = availableWidth; } else { var childMeasurement = _measurements[child]; childWidth = Math.Max(0, GetChildWidth(childMeasurement, finalSize.Width)); childLeft = Math.Max(0, GetChildLeftMargin(childMeasurement, availableWidth)); isHorizontallyLastItem = childMeasurement.Right == 1; } // Add additional right margin to items that falls on the right edge of the panel. double extraRightMargin = 0; // Multi-day events don't have any margin and their hit test is disabled. if (!child.IsMultiDayEvent) { // Max of 5% of the width or 20px max. extraRightMargin = isHorizontallyLastItem ? Math.Max(LastItemRightExtraMargin, finalSize.Width * 5 / 100) : 0; } if (childWidth < 0) childWidth = 1; // Regular events must have 2px margin if (!child.IsMultiDayEvent && !child.IsAllDayEvent) { childLeft += 2; childTop += 2; childHeight -= 2; childWidth -= 2; } var arrangementRect = new Rect(childLeft + EventItemMargin.Left, childTop + EventItemMargin.Top, Math.Max(childWidth - extraRightMargin, 1), childHeight); // Make sure measured size will fit in the arranged box. var measureSize = arrangementRect.ToSize(); control.Measure(measureSize); control.Arrange(arrangementRect); //Debug.WriteLine($"{child.Title}, Measured: {measureSize}, Arranged: {arrangementRect}"); } return finalSize; } #region ColumSpanning and Packing Algorithm private void AddOrUpdateMeasurement(ICalendarItem calendarItem, CalendarItemMeasurement measurement) { if (_measurements.ContainsKey(calendarItem)) { _measurements[calendarItem] = measurement; } else { _measurements.Add(calendarItem, measurement); } } // Pick the left and right positions of each event, such that there are no overlap. private void LayoutEvents(IEnumerable events) { var columns = new List>(); DateTime? lastEventEnding = null; foreach (var ev in events.OrderBy(ev => ev.StartDate).ThenBy(ev => ev.EndDate)) { // Multi-day events are not measured. if (ev.IsMultiDayEvent) continue; if (ev.Period.Start >= lastEventEnding) { PackEvents(columns); columns.Clear(); lastEventEnding = null; } bool placed = false; foreach (var col in columns) { if (!col.Last().Period.OverlapsWith(ev.Period)) { col.Add(ev); placed = true; break; } } if (!placed) { columns.Add(new List { ev }); } if (lastEventEnding == null || ev.Period.End > lastEventEnding.Value) { lastEventEnding = ev.Period.End; } } if (columns.Count > 0) { PackEvents(columns); } } // Set the left and right positions for each event in the connected group. private void PackEvents(List> columns) { float numColumns = columns.Count; int iColumn = 0; foreach (var col in columns) { foreach (var ev in col) { int colSpan = ExpandEvent(ev, iColumn, columns); var leftWeight = iColumn / numColumns; var rightWeight = (iColumn + colSpan) / numColumns; AddOrUpdateMeasurement(ev, new CalendarItemMeasurement(leftWeight, rightWeight)); } iColumn++; } } // Checks how many columns the event can expand into, without colliding with other events. private int ExpandEvent(ICalendarItem ev, int iColumn, List> columns) { int colSpan = 1; foreach (var col in columns.Skip(iColumn + 1)) { foreach (var ev1 in col) { if (ev1.Period.OverlapsWith(ev.Period)) return colSpan; } colSpan++; } return colSpan; } #endregion }