Skip to content

Instantly share code, notes, and snippets.

@asahicantu
Created March 27, 2025 15:24
Show Gist options
  • Save asahicantu/5be6b5e92847898ae12f5775d1f7fba4 to your computer and use it in GitHub Desktop.
Save asahicantu/5be6b5e92847898ae12f5775d1f7fba4 to your computer and use it in GitHub Desktop.
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));
}
}
}
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);
}
}
namespace NeoNatalie.Live.MAUI.Views.Controls;
public enum RulerDataType
{
DateTime,
TimeSpan,
Numeric,
String
}
namespace NeoNatalie.Live.MAUI.Views.Controls;
public enum RulerOrientation
{
Top,
Bottom,
Left,
Right
}
@asahicantu
Copy link
Author

A dynamic ruler written for .NET MAUI

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment