TimeHistogramPicker
Description
A histogram control for selecting a time range
The TimeHistogramPicker turns a DateTime array into adaptive buckets and lets users narrow the selected range from either side.
It sorts a private copy of the input values, so callers can pass unsorted data without changing their source array.
Samples
Three Values
time-histogram-picker-sample.js
using System;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var now = DateTime.Now;
var values = new[]
{
now.AddMinutes(30),
now.AddMinutes(-15),
now.AddHours(2)
};
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 12)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Dense Minute and Hour Data
time-histogram-picker-2-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddHours(8);
var values = Enumerable.Range(0, 720)
.SelectMany(i => Enumerable.Range(0, 1 + (i % 9)).Select(j => start.AddMinutes(i).AddSeconds(j * 7)))
.ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 64)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Fine-grained Seconds
time-histogram-picker-3-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddHours(14);
var values = Enumerable.Range(0, 360)
.SelectMany(second =>
{
var count = second % 45 < 9 ? 8 : second % 30 < 4 ? 5 : second % 11 == 0 ? 3 : 1;
return Enumerable.Range(0, count).Select(index => start.AddSeconds(second).AddMilliseconds(index * 90));
})
.ToArray();
Func<DateTime, string> renderTime = date => date.ToString("HH:mm:ss");
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 360)
.WithCustomTimeRender(renderTime)
.ShowBucketTooltipOnHover(true)
.OnRangeChanged((from, to, count) => selection.Value = $"{renderTime(from)} - {renderTime(to)} ({count:n0} values)");
selection.Value = $"{renderTime(picker.SelectedFrom)} - {renderTime(picker.SelectedTo)} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Custom Time Rendering
time-histogram-picker-4-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddHours(8);
var values = Enumerable.Range(0, 720)
.SelectMany(i => Enumerable.Range(0, 1 + (i % 9)).Select(j => start.AddMinutes(i).AddSeconds(j * 7)))
.ToArray();
Func<DateTime, string> renderTime = date => date.ToString("MMM d, HH:mm");
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 48)
.WithCustomTimeRender(renderTime)
.OnRangeChanged((from, to, count) => selection.Value = $"{renderTime(from)} - {renderTime(to)} ({count:n0} values)");
selection.Value = $"{renderTime(picker.SelectedFrom)} - {renderTime(picker.SelectedTo)} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Sparse Multi-year Data
time-histogram-picker-5-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddYears(-6);
var values = Enumerable.Range(0, 180)
.Select(i => start.AddDays((i * 17) % 2190).AddHours((i * 11) % 24))
.ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 48)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Gapped Clusters with Uneven Groups
time-histogram-picker-6-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddYears(-4);
var tinyBurst = Enumerable.Range(0, 18)
.Select(i => start.AddSeconds(i * 11));
var releaseCluster = Enumerable.Range(0, 240)
.Select(i => start.AddMonths(9).AddMinutes(i * 5).AddSeconds(i % 13));
var sparseReview = Enumerable.Range(0, 9)
.Select(i => start.AddMonths(18).AddDays(i * 3).AddHours(i % 5));
var denseMigration = Enumerable.Range(0, 1200)
.Select(i => start.AddYears(3).AddMinutes(i % 180).AddSeconds(i / 180));
var longTail = Enumerable.Range(0, 45)
.Select(i => start.AddYears(4).AddDays(i * 2).AddHours(i % 7));
var values = denseMigration
.Concat(tinyBurst)
.Concat(longTail)
.Concat(releaseCluster)
.Concat(sparseReview)
.ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 80)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Large Dataset (100,000 Values)
time-histogram-picker-7-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddYears(-1);
var minutesInYear = 365 * 24 * 60;
var values = Enumerable.Range(0, 100000)
.Select(i => start.AddMinutes((i * 37) % minutesInYear).AddSeconds(i % 60))
.ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(values, 80)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Daily Buckets from Backend
time-histogram-picker-8-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddDays(-18);
var counts = new[] { 12, 0, 3, 45, 0, 0, 18, 95, 30, 0, 4, 8, 0, 150, 70, 0, 22, 6, -5 };
var buckets = counts
.Select((count, i) => new TimeHistogramBucket(start.AddDays(i), start.AddDays(i + 1), count))
.Reverse()
.ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(buckets)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}
Long-range Aggregated Buckets from Backend
time-histogram-picker-9-sample.js
using System;
using System.Linq;
using H5.Core;
using Tesserae;
using static H5.Core.dom;
using static Tesserae.UI;
namespace Tesserae.Tests
{
internal static class App
{
private static void Main()
{
var start = DateTime.Today.AddYears(-8);
var counts = new[] { 20, 0, 45, 140, 12, 0, 0, 260, 410, 90, 0, 35, 75, 0, 510, 180 };
var buckets = counts.Select((count, i) =>
{
var bucketStart = start.AddMonths(i * 6);
return new TimeHistogramBucket(bucketStart, bucketStart.AddMonths(6), count);
}).ToArray();
var selection = new SettableObservable<string>();
var picker = TimeHistogramPicker(buckets)
.OnRangeChanged((from, to, count) => selection.Value = $"{from:g} - {to:g} ({count:n0} values)");
selection.Value = $"{picker.SelectedFrom:g} - {picker.SelectedTo:g} ({picker.SelectedCount:n0} values)";
var component = VStack().WS().Children(
picker,
HStack().AlignItemsCenter().Children(
TextBlock("Selected: ").SemiBold(),
DeferSync(selection, value => TextBlock(value))
)
);
document.body.style.overflow = "hidden";
MountCenteredToBody(component);
}
}
}