Created
March 27, 2025 15:24
-
-
Save asahicantu/5be6b5e92847898ae12f5775d1f7fba4 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using LiveChartsCore.Defaults; | |
using System.Runtime.CompilerServices; | |
namespace NeoNatalie.Live.MAUI.Views.Controls; | |
/// <summary> | |
/// A dynamic ruler control that adapts to different data types and maintains proper spacing | |
/// between ticks and labels when resized or when data changes. | |
/// </summary> | |
/// | |
public struct TickElement | |
{ | |
public float Position { get; set; } | |
public object Value { get; set; } | |
public bool IsMajorTick { get; set; } | |
public TickElement() { } | |
public TickElement(float position, object value, bool isMajorTick = false) | |
{ | |
Position = position; | |
Value = value; | |
IsMajorTick = isMajorTick; | |
} | |
} | |
public class DynamicRuler : GraphicsView | |
{ | |
#region Fields | |
public readonly List<TickElement> Ticks = new(); | |
#endregion | |
#region Bindable Properties | |
// Appearance | |
public static readonly BindableProperty MajorTickColorProperty = BindableProperty.Create( | |
nameof(MajorTickColor), typeof(Color), typeof(DynamicRuler), | |
Colors.Black, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MinortickColorProperty = BindableProperty.Create( | |
nameof(MinorTickColor), typeof(Color), typeof(DynamicRuler), | |
Colors.Black, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MinLabelSpacingProperty = BindableProperty.Create( | |
nameof(MinLabelSpacing), typeof(float), typeof(DynamicRuler), | |
50f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MinTickSpacingProperty = BindableProperty.Create( | |
nameof(MinTickSpacing), typeof(float), typeof(DynamicRuler), | |
30f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty OrientationProperty = BindableProperty.Create( | |
nameof(Orientation), typeof(RulerOrientation), typeof(DynamicRuler), | |
RulerOrientation.Bottom, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty FontColorProperty = BindableProperty.Create( | |
nameof(FontColor), typeof(Color), typeof(DynamicRuler), | |
Colors.Black, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MinValueLabelOffsetProperty = BindableProperty.Create( | |
nameof(MinValueLabelOffset), typeof(float), typeof(DynamicRuler), | |
0.0f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MaxValueLabelOffsetProperty = BindableProperty.Create( | |
nameof(MaxValueLabelOffset), typeof(float), typeof(DynamicRuler), | |
0.0f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty TickLengthProperty = BindableProperty.Create( | |
nameof(TickLength), typeof(float), typeof(DynamicRuler), | |
10f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MajorTickLengthProperty = BindableProperty.Create( | |
nameof(MajorTickLength), typeof(float), typeof(DynamicRuler), | |
15f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty TickThicknessProperty = BindableProperty.Create( | |
nameof(TickThickness), typeof(float), typeof(DynamicRuler), | |
1f, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty ShowLabelsProperty = BindableProperty.Create( | |
nameof(ShowLabels), typeof(bool), typeof(DynamicRuler), | |
true, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty ShowMajorTicksProperty = BindableProperty.Create( | |
nameof(ShowMajorTicks), typeof(bool), typeof(DynamicRuler), | |
true, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty ShowMinorTicksProperty = BindableProperty.Create( | |
nameof(ShowMinorTicks), typeof(bool), typeof(DynamicRuler), | |
true, propertyChanged: OnRulerPropertyChanged); | |
// Data Range Properties | |
public static readonly BindableProperty MinValueProperty = BindableProperty.Create( | |
nameof(MinValue), typeof(string), typeof(DynamicRuler), | |
string.Empty, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MaxValueProperty = BindableProperty.Create( | |
nameof(MaxValue), typeof(string), typeof(DynamicRuler), | |
string.Empty, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty DataTypeProperty = BindableProperty.Create( | |
nameof(DataType), typeof(RulerDataType), typeof(DynamicRuler), | |
RulerDataType.Numeric, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty InvertRuleValuesProperty = BindableProperty.Create( | |
nameof(InvertRuleValues), typeof(bool), typeof(DynamicRuler), | |
false, propertyChanged: OnRulerPropertyChanged); | |
// Tick Density and Spacing | |
public static readonly BindableProperty DesiredMajorTickCountProperty = BindableProperty.Create( | |
nameof(DesiredMajorTickCount), typeof(int), typeof(DynamicRuler), | |
int.MaxValue, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty MinorTicksPerMajorProperty = BindableProperty.Create( | |
nameof(MinorTicksPerMajor), typeof(int), typeof(DynamicRuler), | |
int.MaxValue, propertyChanged: OnRulerPropertyChanged); | |
public static readonly BindableProperty LabelFormatProperty = BindableProperty.Create( | |
nameof(LabelFormat), typeof(string), typeof(DynamicRuler), | |
"{0}", propertyChanged: OnRulerPropertyChanged); | |
// Custom Ticks Collection | |
public static readonly BindableProperty CustomTicksProperty = BindableProperty.Create( | |
nameof(CustomTicks), typeof(IEnumerable<object>), typeof(DynamicRuler), | |
null, propertyChanged: OnRulerPropertyChanged); | |
#endregion | |
#region Properties | |
public bool IsHorizontalOrientation => Orientation == RulerOrientation.Top || Orientation == RulerOrientation.Bottom; | |
public bool IsVerticalOrientation => Orientation == RulerOrientation.Left || Orientation == RulerOrientation.Right; | |
public RulerOrientation Orientation | |
{ | |
get => (RulerOrientation)GetValue(OrientationProperty); | |
set => SetValue(OrientationProperty, value); | |
} | |
// Minimum pixels between ticks | |
public float MinTickSpacing | |
{ | |
get => (float)GetValue(MinTickSpacingProperty); | |
set => SetValue(MinTickSpacingProperty, value); | |
} | |
// Minimum pixels between labels | |
public float MinLabelSpacing | |
{ | |
get => (float)GetValue(MinLabelSpacingProperty); | |
set => SetValue(MinLabelSpacingProperty, value); | |
} | |
public Color MajorTickColor | |
{ | |
get => (Color)GetValue(MajorTickColorProperty); | |
set => SetValue(MajorTickColorProperty, value); | |
} | |
public Color MinorTickColor | |
{ | |
get => (Color)GetValue(MinortickColorProperty); | |
set => SetValue(MinortickColorProperty, value); | |
} | |
public Color FontColor | |
{ | |
get => (Color)GetValue(FontColorProperty); | |
set => SetValue(FontColorProperty, value); | |
} | |
public float MinValueLabelOffset | |
{ | |
get => (float)GetValue(MinValueLabelOffsetProperty); | |
set => SetValue(MinValueLabelOffsetProperty, value); | |
} | |
public float MaxValueLabelOffset | |
{ | |
get => (float)GetValue(MaxValueLabelOffsetProperty); | |
set => SetValue(MaxValueLabelOffsetProperty, value); | |
} | |
public float TickLength | |
{ | |
get => (float)GetValue(TickLengthProperty); | |
set => SetValue(TickLengthProperty, value); | |
} | |
public float MajorTickLength | |
{ | |
get => (float)GetValue(MajorTickLengthProperty); | |
set => SetValue(MajorTickLengthProperty, value); | |
} | |
public float TickThickness | |
{ | |
get => (float)GetValue(TickThicknessProperty); | |
set => SetValue(TickThicknessProperty, value); | |
} | |
public bool ShowLabels | |
{ | |
get => (bool)GetValue(ShowLabelsProperty); | |
set => SetValue(ShowLabelsProperty, value); | |
} | |
public bool ShowMajorTicks | |
{ | |
get => (bool)GetValue(ShowMajorTicksProperty); | |
set => SetValue(ShowMajorTicksProperty, value); | |
} | |
public bool ShowMinorTicks | |
{ | |
get => (bool)GetValue(ShowMinorTicksProperty); | |
set => SetValue(ShowMinorTicksProperty, value); | |
} | |
public float LabelMargin | |
{ | |
get => (float)GetValue(LabelMarginProperty); | |
set => SetValue(LabelMarginProperty, value); | |
} | |
public static readonly BindableProperty LabelMarginProperty = BindableProperty.Create( | |
nameof(LabelMargin), typeof(float), typeof(DynamicRuler),0f, propertyChanged: OnRulerPropertyChanged); | |
public int FontSize | |
{ | |
get => (int)GetValue(FontSizeProperty); | |
set => SetValue(FontSizeProperty, value); | |
} | |
public static readonly BindableProperty FontSizeProperty = BindableProperty.Create( | |
nameof(FontSize), typeof(int), typeof(DynamicRuler),12, propertyChanged: OnRulerPropertyChanged); | |
public int FontWeight | |
{ | |
get => (int)GetValue(FontWeightProperty); | |
set => SetValue(FontWeightProperty, value); | |
} | |
public static readonly BindableProperty FontWeightProperty = BindableProperty.Create( | |
nameof(FontWeight), typeof(int), typeof(DynamicRuler),400, propertyChanged: OnRulerPropertyChanged); | |
public string FontFamily | |
{ | |
get => (string)GetValue(FontFamilyProperty); | |
set => SetValue(FontFamilyProperty, value); | |
} | |
public static readonly BindableProperty FontFamilyProperty = BindableProperty.Create( | |
nameof(FontFamily), typeof(string), typeof(DynamicRuler),null, propertyChanged: OnRulerPropertyChanged); | |
public FontStyleType FontStyle | |
{ | |
get => (FontStyleType)GetValue(FontStyleProperty); | |
set => SetValue(FontStyleProperty, value); | |
} | |
public static readonly BindableProperty FontStyleProperty = BindableProperty.Create( | |
nameof(FontStyle), typeof(FontStyleType), typeof(DynamicRuler),FontStyleType.Normal, propertyChanged: OnRulerPropertyChanged); | |
public object MinValue | |
{ | |
get => (string)GetValue(MinValueProperty); | |
set | |
{ | |
_minDate = null; | |
_minTime = null; | |
_minNumericValue = null; | |
SetValue(MinValueProperty, value); | |
} | |
} | |
public object MaxValue | |
{ | |
get => (string)GetValue(MaxValueProperty); | |
set | |
{ | |
_maxDate = null; | |
_maxTime = null; | |
_maxNumericValue = null; | |
SetValue(MaxValueProperty, value); | |
} | |
} | |
private DateTime? _minDate; | |
private DateTime? _maxDate; | |
private TimeSpan? _minTime; | |
private TimeSpan? _maxTime; | |
private double? _minNumericValue; | |
private double? _maxNumericValue; | |
public double MinNumericValue => TryParseNumericValue(_minNumericValue, MinValue); | |
public double MaxNumericValue => TryParseNumericValue(_maxNumericValue, MaxValue); | |
public DateTime MinDate => TryParseDateValue(_minDate, MinValue); | |
public DateTime MaxDate => TryParseDateValue(_maxDate, MaxValue); | |
public TimeSpan MinTime => TryParseTimeValue(_minTime, MinValue); | |
public TimeSpan MaxTime => TryParseTimeValue(_maxTime, MaxValue); | |
private DateTime TryParseDateValue(DateTime? date, object objValue) | |
{ | |
if (date == null) | |
{ | |
var dateVal = DateTime.MinValue; | |
if (objValue is DateTime) | |
{ | |
dateVal = (DateTime)MaxValue; | |
} | |
else | |
{ | |
DateTime.TryParse(objValue.ToString(), out dateVal); | |
} | |
date = dateVal; | |
} | |
return date.Value; | |
} | |
private TimeSpan TryParseTimeValue(TimeSpan? time, object objValue) | |
{ | |
if (time == null) | |
{ | |
var timeVal = TimeSpan.FromSeconds(0); | |
if (objValue is TimeSpan) | |
{ | |
timeVal = (TimeSpan)MaxValue; | |
} | |
else | |
{ | |
TimeSpan.TryParse(objValue.ToString(), out timeVal); | |
} | |
time = timeVal; | |
} | |
return time.Value; | |
} | |
private double TryParseNumericValue(double? numericValue, object objValue) | |
{ | |
if (numericValue == null) | |
{ | |
double val = double.MinValue; | |
if (objValue is IConvertible) | |
{ | |
val = Convert.ToDouble(objValue); | |
} | |
else | |
{ | |
double.TryParse(objValue.ToString(), out val); | |
} | |
numericValue = val; | |
} | |
return numericValue.Value; | |
} | |
public RulerDataType DataType | |
{ | |
get => (RulerDataType)GetValue(DataTypeProperty); | |
set => SetValue(DataTypeProperty, value); | |
} | |
public bool InvertRuleValues | |
{ | |
get => (bool)GetValue(InvertRuleValuesProperty); | |
set => SetValue(InvertRuleValuesProperty, value); | |
} | |
public int DesiredMajorTickCount | |
{ | |
get => (int)GetValue(DesiredMajorTickCountProperty); | |
set => SetValue(DesiredMajorTickCountProperty, value); | |
} | |
public int MinorTicksPerMajor | |
{ | |
get => (int)GetValue(MinorTicksPerMajorProperty); | |
set => SetValue(MinorTicksPerMajorProperty, value); | |
} | |
public string LabelFormat | |
{ | |
get => (string)GetValue(LabelFormatProperty); | |
set => SetValue(LabelFormatProperty, value); | |
} | |
public IEnumerable<object> CustomTicks | |
{ | |
get => (IEnumerable<object>)GetValue(CustomTicksProperty); | |
set => SetValue(CustomTicksProperty, value); | |
} | |
public double RealWidth => Width - MinValueLabelOffset - MaxValueLabelOffset; | |
public double RealHeight => Height - MinValueLabelOffset - MaxValueLabelOffset; | |
#endregion | |
public DynamicRuler() | |
{ | |
Drawable = new DynamicRulerDrawable(this); | |
SizeChanged += OnSizeChanged; | |
} | |
private void OnSizeChanged(object sender, EventArgs e) | |
{ | |
CalculateTickPositions(); | |
Invalidate(); | |
} | |
protected override void OnPropertyChanged([CallerMemberName] string propertyName = null) | |
{ | |
switch (propertyName) | |
{ | |
case nameof(BackgroundColor): | |
Invalidate(); | |
break; | |
} | |
} | |
private static void OnRulerPropertyChanged(BindableObject bindable, object oldValue, object newValue) | |
{ | |
if (bindable is DynamicRuler ruler) | |
{ | |
ruler.CalculateTickPositions(); | |
ruler.Invalidate(); | |
} | |
} | |
private void CalculateTickPositions() | |
{ | |
Ticks.Clear(); | |
if (RealWidth <= 0 || RealHeight <= 0) | |
return; | |
double availableLength = GetTickBasePosition(); | |
if (availableLength <= 0) | |
return; | |
// If using custom ticks | |
if (CustomTicks != null && CustomTicks.Any()) | |
{ | |
CalculateCustomTickPositions(availableLength); | |
return; | |
} | |
// For standard numeric, datetime, or string ticks | |
switch (DataType) | |
{ | |
case RulerDataType.Numeric: | |
CalculateNumericTickPositions(availableLength); | |
break; | |
case RulerDataType.DateTime: | |
CalculateDateTimeTickPositions(availableLength); | |
break; | |
case RulerDataType.TimeSpan: | |
CalculateTimeSpanTickPositions(availableLength); | |
break; | |
case RulerDataType.String: | |
CalculateStringTickPositions(availableLength); | |
break; | |
} | |
} | |
private double GetTickBasePosition() | |
{ | |
return Orientation switch | |
{ | |
RulerOrientation.Top => RealWidth, | |
RulerOrientation.Bottom => RealWidth, | |
RulerOrientation.Left => RealHeight, | |
RulerOrientation.Right => RealHeight, | |
_ => 0 | |
}; | |
} | |
private void CalculateNumericTickPositions(double availableLength) | |
{ | |
if (MinNumericValue >= MaxNumericValue) | |
return; | |
double range = MaxNumericValue - MinNumericValue; | |
// Calculate a sensible tick interval | |
int maxPossibleTicks = (int)(availableLength / MinTickSpacing); | |
if (maxPossibleTicks <= 0) maxPossibleTicks = 1; | |
int targetMajorTicks = Math.Min(DesiredMajorTickCount, maxPossibleTicks); | |
// Find a nice round interval | |
double interval = range / targetMajorTicks; | |
// Calculate major ticks | |
var minorTicksPerMajor = Math.Min(MinorTicksPerMajor, 10); | |
var minorInterval = interval / minorTicksPerMajor; | |
for (double value = Math.Ceiling(MinNumericValue); value <= MaxNumericValue; value += interval) | |
{ | |
var position = (float)((value - MinNumericValue) / range * availableLength); | |
Ticks.Add(new TickElement(position, value, true)); | |
// Add minor ticks | |
if (minorTicksPerMajor > 0) | |
{ | |
for (int i = 1; i <= minorTicksPerMajor; i++) | |
{ | |
var minorValue = value + (i * minorInterval); | |
if (minorValue <= MaxNumericValue) | |
{ | |
var minorPosition = (float)((minorValue - MinNumericValue) / range * availableLength); | |
Ticks.Add(new TickElement(position, value, false)); | |
} | |
} | |
} | |
} | |
} | |
private void CalculateTimeSpanTickPositions(double availableLength) | |
{ | |
if (MinTime >= MaxTime) | |
return; | |
var range = MaxTime - MinTime; | |
double totalMiliseconds = range.TotalMilliseconds; | |
// Determine appropriate interval based on range | |
// Adjust interval based on available space | |
// Calculate a sensible tick interval | |
int maxPossibleTicks = (int)(Math.Ceiling(availableLength / MinTickSpacing)); | |
maxPossibleTicks = Math.Min(DesiredMajorTickCount, maxPossibleTicks); | |
var majorInterval = TimeSpan.FromMilliseconds(totalMiliseconds / maxPossibleTicks); | |
var minorTicksPerMajor = Math.Min(MinorTicksPerMajor, 10); | |
var minorInterval = majorInterval / minorTicksPerMajor; | |
// Calculate major tick positions | |
var currentTime = MinTime; | |
// Align to a nice starting point based on interval | |
while (currentTime <= MaxTime) | |
{ | |
var position = (float)((currentTime - MinTime).TotalMilliseconds / totalMiliseconds * availableLength); | |
Ticks.Add(new TickElement( position, currentTime,true)); | |
var nextTime = currentTime.Add(majorInterval); | |
if (minorTicksPerMajor > 0) | |
{ | |
var minorTime = currentTime; | |
while(minorTime <= nextTime) | |
{ | |
minorTime = minorTime.Add(minorInterval); | |
if (minorTime <= MaxTime) | |
{ | |
var minorPosition = (float)((minorTime - MinTime).TotalMilliseconds / totalMiliseconds * availableLength); | |
Ticks.Add(new TickElement(minorPosition, minorTime, false)); | |
} | |
} | |
} | |
currentTime = nextTime; | |
} | |
} | |
private void CalculateDateTimeTickPositions(double availableLength) | |
{ | |
if (MinDate >= MaxDate) | |
return; | |
TimeSpan range = MaxDate - MinDate; | |
double totalSeconds = range.TotalSeconds; | |
// Determine appropriate interval based on range | |
TimeSpan interval; | |
if (range.TotalDays > 365 * 2) // > 2 years | |
interval = TimeSpan.FromDays(365); // yearly | |
else if (range.TotalDays > 60) // > 2 months | |
interval = TimeSpan.FromDays(30); // monthly | |
else if (range.TotalDays > 14) // > 2 weeks | |
interval = TimeSpan.FromDays(7); // weekly | |
else if (range.TotalDays > 2) // > 2 days | |
interval = TimeSpan.FromDays(1); // daily | |
else if (range.TotalHours > 2) // > 2 hours | |
interval = TimeSpan.FromHours(1); // hourly | |
else if (range.TotalMinutes > 2) // > 2 minutes | |
interval = TimeSpan.FromMinutes(1); // by minute | |
else | |
interval = TimeSpan.FromSeconds(15); // by 15 seconds | |
// Adjust interval based on available space | |
int maxPossibleTicks = (int)(availableLength / MinLabelSpacing); | |
if (maxPossibleTicks <= 0) maxPossibleTicks = 1; | |
double ticksWithCurrentInterval = totalSeconds / interval.TotalSeconds; | |
if (ticksWithCurrentInterval > maxPossibleTicks) | |
{ | |
// Scale up the interval to fit the available space | |
double scaleFactor = Math.Ceiling(ticksWithCurrentInterval / maxPossibleTicks); | |
interval = TimeSpan.FromSeconds(interval.TotalSeconds * scaleFactor); | |
} | |
// Calculate major tick positions | |
DateTime currentDate = MinDate; | |
// Align to a nice starting point based on interval | |
if (interval.TotalDays >= 1) | |
{ | |
currentDate = new DateTime(MinDate.Year, MinDate.Month, MinDate.Day); | |
if (interval.TotalDays >= 30) | |
{ | |
currentDate = new DateTime(MinDate.Year, MinDate.Month, 1); | |
} | |
if (interval.TotalDays >= 365) | |
{ | |
currentDate = new DateTime(MinDate.Year, 1, 1); | |
} | |
} | |
else if (interval.TotalHours >= 1) | |
{ | |
currentDate = new DateTime(MinDate.Year, MinDate.Month, MinDate.Day, MinDate.Hour, 0, 0); | |
} | |
else if (interval.TotalMinutes >= 1) | |
{ | |
currentDate = new DateTime(MinDate.Year, MinDate.Month, MinDate.Day, MinDate.Hour, MinDate.Minute, 0); | |
} | |
// Ensure first tick is not before min date | |
if (currentDate < MinDate) | |
{ | |
if (interval.TotalDays >= 365) | |
currentDate = currentDate.AddYears(1); | |
else if (interval.TotalDays >= 30) | |
currentDate = currentDate.AddMonths(1); | |
else if (interval.TotalDays >= 1) | |
currentDate = currentDate.AddDays(1); | |
else if (interval.TotalHours >= 1) | |
currentDate = currentDate.AddHours(1); | |
else if (interval.TotalMinutes >= 1) | |
currentDate = currentDate.AddMinutes(1); | |
else | |
currentDate = currentDate.AddSeconds(interval.TotalSeconds); | |
} | |
while (currentDate <= MaxDate) | |
{ | |
float position = (float)((currentDate - MinDate).TotalSeconds / totalSeconds * availableLength); | |
Ticks.Add(new TickElement(position, currentDate,true)); | |
currentDate = currentDate.Add(interval); | |
} | |
} | |
private void CalculateStringTickPositions(double availableLength) | |
{ | |
if (!(CustomTicks?.Any() ?? false)) | |
return; | |
int totalItems = CustomTicks.Count(); | |
if (totalItems <= 1) | |
return; | |
// Calculate how many labels can fit | |
int maxLabels = Math.Max(1, (int)(availableLength / MinLabelSpacing)); | |
int step = (int)Math.Ceiling((double)totalItems / maxLabels); | |
int itemIndex = 0; | |
foreach (var item in CustomTicks) | |
{ | |
if (itemIndex % step == 0) | |
{ | |
var position = (float)(itemIndex / (totalItems - 1) * availableLength); | |
Ticks.Add( new TickElement(position, item, true)); | |
} | |
itemIndex++; | |
} | |
} | |
private void CalculateCustomTickPositions(double availableLength) | |
{ | |
if (!(CustomTicks?.Any() ?? false)) | |
return; | |
// Determine the data type to handle conversions | |
bool isNumeric = CustomTicks.All(t => t is IConvertible); | |
bool isDateTime = CustomTicks.All(t => t is DateTime || | |
(t is string s && DateTime.TryParse(s, out _))); | |
if (!isNumeric && !isDateTime) | |
{ | |
// Treat as strings/categorical | |
CalculateStringTickPositions(availableLength); | |
return; | |
} | |
// Find min and max values | |
object actualMin = null; | |
object actualMax = null; | |
if (isNumeric) | |
{ | |
double min = double.MaxValue; | |
double max = double.MinValue; | |
foreach (var tick in CustomTicks) | |
{ | |
double value = Convert.ToDouble(tick); | |
min = Math.Min(min, value); | |
max = Math.Max(max, value); | |
} | |
actualMin = min; | |
actualMax = max; | |
} | |
else if (isDateTime) | |
{ | |
DateTime min = DateTime.MaxValue; | |
DateTime max = DateTime.MinValue; | |
foreach (var tick in CustomTicks) | |
{ | |
DateTime dt; | |
if (tick is DateTime dateTime) | |
dt = dateTime; | |
else if (tick is string str && DateTime.TryParse(str, out var parsedDt)) | |
dt = parsedDt; | |
else | |
continue; | |
min = dt < min ? dt : min; | |
max = dt > max ? dt : max; | |
} | |
actualMin = min; | |
actualMax = max; | |
} | |
if (actualMin == null || actualMax == null) | |
return; | |
// Calculate positions for each custom tick | |
foreach (var tick in CustomTicks) | |
{ | |
float position; | |
if (isNumeric) | |
{ | |
double value = Convert.ToDouble(tick); | |
double min = Convert.ToDouble(actualMin); | |
double max = Convert.ToDouble(actualMax); | |
if (max <= min) continue; | |
position = (float)((value - min) / (max - min) * availableLength); | |
} | |
else // isDateTime | |
{ | |
DateTime value; | |
if (tick is DateTime dt) | |
value = dt; | |
else if (tick is string str && DateTime.TryParse(str, out var parsedDt)) | |
value = parsedDt; | |
else | |
continue; | |
DateTime min = (DateTime)actualMin; | |
DateTime max = (DateTime)actualMax; | |
if (max <= min) continue; | |
position = (float)((value - min).TotalSeconds / (max - min).TotalSeconds * availableLength); | |
} | |
Ticks.Add(new TickElement(position, tick, true)); | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System.Globalization; | |
namespace NeoNatalie.Live.MAUI.Views.Controls; | |
public class DynamicRulerDrawable : IDrawable | |
{ | |
private readonly DynamicRuler _ruler; | |
public DynamicRulerDrawable(DynamicRuler ruler) | |
{ | |
_ruler = ruler; | |
} | |
public void Draw(ICanvas canvas, RectF dirtyRect) | |
{ | |
if (_ruler.Ticks.Count == 0) | |
return; | |
// Set up appearance | |
canvas.StrokeColor = _ruler.MajorTickColor; | |
canvas.StrokeSize = _ruler.TickThickness; | |
canvas.FontSize = (float)_ruler.FontSize; | |
canvas.Font = new Microsoft.Maui.Graphics.Font(_ruler.FontFamily, _ruler.FontWeight,_ruler.FontStyle); | |
canvas.FontColor = _ruler.FontColor; | |
canvas.FillColor = _ruler.BackgroundColor; | |
canvas.FillRectangle(dirtyRect); | |
// Draw the ruler line | |
if (_ruler.IsHorizontalOrientation) | |
{ | |
float x = 0 + _ruler.MinValueLabelOffset; | |
float y = _ruler.Orientation == RulerOrientation.Top ? (float)dirtyRect.Height : 0; | |
float x1 = dirtyRect.Width - _ruler.MaxValueLabelOffset; | |
canvas.DrawLine(x, y, x1, y); | |
DrawHorizontalTicksAndLabels(canvas, dirtyRect, y); | |
} | |
else // Vertical | |
{ | |
float x = _ruler.Orientation == RulerOrientation.Left ? (float)dirtyRect.Width: 0; | |
float y = 0 + _ruler.MaxValueLabelOffset; | |
canvas.DrawLine(x, y, x, dirtyRect.Height); | |
DrawVerticalTicksAndLabels(canvas, dirtyRect, x); | |
} | |
} | |
private void DrawHorizontalTicksAndLabels(ICanvas canvas, RectF dirtyRect, float y) | |
{ | |
// Calculate which labels to show based on available space | |
int labelStep = CalculateLabelStep(); | |
int labelsDrawn = 0; | |
float lastLabelEnd = _ruler.InvertRuleValues ? float.MaxValue : float.MinValue; | |
var positionFactor = _ruler.Orientation == RulerOrientation.Top ? -1 : 1; | |
foreach (var tick in _ruler.Ticks) | |
{ | |
float x = (_ruler.InvertRuleValues ? dirtyRect.Width - tick.Position : tick.Position) + _ruler.MinValueLabelOffset; | |
// Skip if tick is outside the visible area | |
if (x < 0 || x > dirtyRect.Width) | |
continue; | |
var tickLength = tick.IsMajorTick ? _ruler.MajorTickLength : _ruler.TickLength; | |
var yDelta = y + (tickLength * positionFactor); | |
// Draw the tick- | |
if (tick.IsMajorTick && _ruler.ShowMajorTicks) | |
{ | |
canvas.StrokeColor = _ruler.MajorTickColor; | |
canvas.DrawLine(x, y, x, yDelta); | |
} | |
if (!tick.IsMajorTick && _ruler.ShowMinorTicks) | |
{ | |
canvas.StrokeColor = _ruler.MinorTickColor; | |
canvas.DrawLine(x, y, x, yDelta); | |
} | |
// Draw label if it's a major tick and we should show labels | |
if (_ruler.ShowLabels && tick.IsMajorTick) | |
{ | |
var labelText = FormatTickLabel(tick.Value); | |
var labelWidth = MeasureTextWidth(canvas, labelText); | |
var delta = (tickLength + _ruler.FontSize + _ruler.LabelMargin); | |
var labelY = y + (delta * positionFactor); | |
var labelX = x - (labelWidth / 2); // Center text on tick | |
if (labelX < 0) | |
{ | |
labelX = 0; | |
} | |
var aligmnmet = HorizontalAlignment.Left; | |
//Readjust text if we reach the end of the ruler so text does not overlap | |
if (x + labelWidth > _ruler.Width) | |
{ | |
labelX = (float)_ruler.Width; | |
aligmnmet = HorizontalAlignment.Right; | |
} | |
// Only draw label if it won't overlap with previous label | |
var drawLabel = false; | |
if (_ruler.InvertRuleValues) | |
{ | |
drawLabel = labelX < lastLabelEnd - _ruler.LabelMargin; | |
} | |
else | |
{ | |
drawLabel = labelX > lastLabelEnd + _ruler.LabelMargin; | |
} | |
if (drawLabel) | |
{ | |
canvas.DrawString(labelText, labelX, (float)labelY, aligmnmet); | |
lastLabelEnd = labelX + labelWidth; | |
labelsDrawn++; | |
} | |
} | |
} | |
} | |
private void DrawVerticalTicksAndLabels(ICanvas canvas, RectF dirtyRect, float x) | |
{ | |
// Calculate which labels to show based on available space | |
int labelStep = CalculateLabelStep(); | |
int labelsDrawn = 0; | |
var lastLabelEnd = _ruler.InvertRuleValues ? double.MaxValue : double.MinValue; | |
foreach (var tick in _ruler.Ticks) | |
{ | |
float y = _ruler.InvertRuleValues ? dirtyRect.Height - tick.Position : tick.Position; | |
// Skip if tick is outside the visible area | |
if (y < 0 || y > dirtyRect.Height) | |
continue; | |
float tickLength = tick.IsMajorTick ? _ruler.MajorTickLength : _ruler.TickLength; | |
// Draw the tick | |
var x2 = x + ( _ruler.Orientation == RulerOrientation.Left ? -tickLength : tickLength); | |
var xLabel = x + (_ruler.Orientation == RulerOrientation.Left ? -(tickLength + _ruler.LabelMargin) : tickLength + _ruler.LabelMargin); | |
if (tick.IsMajorTick && _ruler.ShowMajorTicks) | |
{ | |
canvas.StrokeColor = _ruler.MajorTickColor; | |
canvas.DrawLine(x2, y, x, y); | |
} | |
if (!tick.IsMajorTick && _ruler.ShowMinorTicks) | |
{ | |
canvas.StrokeColor = _ruler.MinorTickColor; | |
canvas.DrawLine(x2, y, x, y); | |
} | |
// Draw label if it's a major tick and we should show labels | |
if (_ruler.ShowLabels && tick.IsMajorTick) | |
{ | |
var labelText = FormatTickLabel(tick.Value); | |
//var labelSize = _ruler.FontSize * 0.7f; //CalculateFontSize(_ruler.FontSize, _ruler.FontFamily, labelText); // _ruler.FontSize * 0.7f; // Approximate text height | |
var labelHeight = _ruler.FontSize * 0.2f; | |
var yLabel = y - (labelHeight / 2); // Center text on tick | |
if(yLabel < 0) | |
{ | |
yLabel = labelHeight; | |
} | |
if(yLabel + labelHeight >= dirtyRect.Height) | |
{ | |
yLabel = dirtyRect.Height - labelHeight; | |
} | |
// Only draw label if it won't overlap with previous label | |
var drawLabel = false; | |
if (_ruler.InvertRuleValues) | |
{ | |
drawLabel = yLabel < lastLabelEnd - _ruler.LabelMargin; | |
} | |
else | |
{ | |
drawLabel = yLabel > lastLabelEnd + _ruler.LabelMargin; | |
} | |
if (drawLabel) | |
{ | |
canvas.DrawString(labelText, xLabel, (float)yLabel,HorizontalAlignment.Right); | |
lastLabelEnd = yLabel + labelHeight; | |
labelsDrawn++; | |
} | |
} | |
} | |
} | |
private int CalculateLabelStep() | |
{ | |
// Calculate how many labels we can display without overlap | |
var availableLength = _ruler.IsHorizontalOrientation ? _ruler.RealWidth : _ruler.RealHeight; | |
// Get count of major ticks | |
int majorTickCount = 0; | |
foreach (var tick in _ruler.Ticks) | |
{ | |
if (tick.IsMajorTick) | |
majorTickCount++; | |
} | |
if (majorTickCount == 0) | |
return 1; | |
// Estimate average label width | |
double averageLabelWidth; | |
if (_ruler.DataType == RulerDataType.DateTime) | |
{ | |
// Use Standard DateTime String Size | |
averageLabelWidth = _ruler.FontSize * DateTime.Today.ToShortDateString().Length; | |
} | |
else if (_ruler.DataType == RulerDataType.Numeric) | |
{ | |
// Numeric labels vary based on range | |
double min = Convert.ToDouble(_ruler.MinValue); | |
double max = Convert.ToDouble(_ruler.MaxValue); | |
int maxDigits = Math.Max( | |
min.ToString("F2").Length, | |
max.ToString("F2").Length | |
); | |
averageLabelWidth = _ruler.FontSize * maxDigits; //(maxDigits * 0.6f); | |
} | |
else | |
{ | |
// String labels - use a reasonable default | |
averageLabelWidth = _ruler.FontSize * 5; | |
} | |
// If vertical, consider height instead | |
if (_ruler.IsVerticalOrientation) | |
{ | |
averageLabelWidth = _ruler.FontSize * 1.5f; | |
} | |
// Calculate how many labels can fit with minimum spacing | |
var minLabelSpacing = _ruler.IsHorizontalOrientation ? _ruler.MinLabelSpacing : _ruler.FontSize * 1.5f; | |
int maxLabels = (int)(availableLength / minLabelSpacing); | |
if (maxLabels >= majorTickCount) | |
return 1; // Show all labels | |
// Calculate step to skip some labels | |
return (int)Math.Ceiling((double)majorTickCount / maxLabels); | |
} | |
private Size CalculateFontSize(int fontSize,string fontFamily, string text) | |
{ | |
if (string.IsNullOrEmpty(text)) | |
return new SizeF(0, 0); | |
var label = new Label { FontSize = fontSize, FontFamily = fontFamily, Text = text}; | |
var size = label.Measure(double.PositiveInfinity, double.PositiveInfinity); | |
return size; | |
} | |
private string FormatTickLabel(object value) | |
{ | |
var formattedValue = string.Empty; | |
if (value == null) return formattedValue; | |
// Apply custom format if specified | |
if (!string.IsNullOrEmpty(_ruler.LabelFormat) && _ruler.LabelFormat != "{0}") | |
{ | |
try | |
{ | |
return string.Format(CultureInfo.CurrentCulture, _ruler.LabelFormat, value); | |
} | |
catch (FormatException) | |
{ | |
// Fallback to basic formatting if format string is invalid | |
return formattedValue; | |
} | |
} | |
switch (_ruler.DataType) | |
{ | |
case RulerDataType.DateTime: | |
if (value is DateTime dateTime) | |
{ | |
// Format based on range - determine appropriate format | |
TimeSpan range = _ruler.MaxDate - _ruler.MinDate; | |
if (range.TotalDays > 365) | |
formattedValue = dateTime.ToString("yyyy"); | |
else if (range.TotalDays > 30) | |
formattedValue = dateTime.ToString("MMM yy"); | |
else if (range.TotalDays > 1) | |
formattedValue = dateTime.ToString("MM/dd"); | |
else if (range.TotalHours > 1) | |
formattedValue = dateTime.ToString("HH:mm"); | |
else | |
formattedValue = dateTime.ToString("HH:mm:ss"); | |
} | |
break; | |
case RulerDataType.TimeSpan: | |
if (value is TimeSpan timeSpan) | |
{ | |
// Format based on range - determine appropriate format | |
TimeSpan range = _ruler.MaxTime - _ruler.MinTime; | |
if (range.TotalDays > 1) | |
formattedValue = timeSpan.ToString(@"dd\:hh"); | |
else if (range.TotalHours > 1) | |
formattedValue = timeSpan.ToString(@"hh\:mm"); | |
else if (range.TotalMinutes > 1) | |
formattedValue = timeSpan.ToString(@"mm\:ss"); | |
else if (range.TotalSeconds > 1) | |
formattedValue = timeSpan.ToString(@"ss\:ffff"); | |
else if (range.TotalMilliseconds > 1) | |
formattedValue = timeSpan.ToString("ffff"); | |
} | |
break; | |
case RulerDataType.Numeric: | |
if (value is IConvertible) | |
{ | |
// Format numeric value | |
double numericValue = Convert.ToDouble(value); | |
// Determine appropriate number format | |
double range = _ruler.MaxNumericValue - _ruler.MinNumericValue; | |
if (range < 0.1) | |
formattedValue = numericValue.ToString("F3"); | |
else if (range < 1) | |
formattedValue = numericValue.ToString("F2"); | |
else if (range < 10) | |
formattedValue = numericValue.ToString("F1"); | |
else | |
formattedValue = numericValue.ToString("F0"); | |
} | |
break; | |
default: | |
// String or other type | |
formattedValue = value.ToString(); | |
break; | |
} | |
return formattedValue; | |
} | |
private float MeasureTextWidth(ICanvas canvas, string text) | |
{ | |
// Approximate width based on font size and text length | |
// In actual implementation, you might want to use a more accurate measurement | |
// if available in the graphics API | |
return text.Length * ((float)_ruler.FontSize * 0.6f); | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace NeoNatalie.Live.MAUI.Views.Controls; | |
public enum RulerDataType | |
{ | |
DateTime, | |
TimeSpan, | |
Numeric, | |
String | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
namespace NeoNatalie.Live.MAUI.Views.Controls; | |
public enum RulerOrientation | |
{ | |
Top, | |
Bottom, | |
Left, | |
Right | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
A dynamic ruler written for .NET MAUI