Skip to content

Instantly share code, notes, and snippets.

@houstonhaynes
Created January 1, 2024 23:12
Show Gist options
  • Save houstonhaynes/f45b73a960d9b2e843494f84c12692a2 to your computer and use it in GitHub Desktop.
Save houstonhaynes/f45b73a960d9b2e843494f84c12692a2 to your computer and use it in GitHub Desktop.
Scrollbar Chart Setup
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ic="using:FluentIcons.Avalonia"
mc:Ignorable="d" d:DesignWidth="1400" d:DesignHeight="800"
xmlns:lvc="clr-namespace:LiveChartsCore.SkiaSharpView.Avalonia;assembly=LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:vm="using:AidenDesktop.ViewModels"
Design.DataContext="{Binding Source={x:Static vm:ChartViewModel.DesignVM}}"
x:DataType="vm:ZoomViewModel"
x:Class="AidenDesktop.Views.ZoomView">
<StackPanel HorizontalAlignment="Center" Margin="10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="200"/>
</Grid.RowDefinitions>
<lvc:CartesianChart
x:Name="MainChart"
Grid.Row="0"
Height="450"
Width="1200"
Series="{Binding Series}"
XAxes="{Binding XAxes}"
YAxes="{Binding YAxes}"
ZoomMode="X"
DrawMargin="{Binding Margin}"
EasingFunction="{Binding Source={x:Null}}"
UpdateStartedCommand="{Binding ChartUpdatedCommand}">
</lvc:CartesianChart>
<lvc:CartesianChart
x:Name="ScrollbarChart"
Grid.Row="1"
Series="{Binding ScrollbarSeries}"
DrawMargin="{Binding Margin}"
Sections="{Binding Thumbs}"
XAxes="{Binding InvisibleX}"
YAxes="{Binding InvisibleY}"
PointerPressedCommand="{Binding PointerDownCommand}"
PointerMoveCommand="{Binding PointerMoveCommand}"
PointerReleasedCommand="{Binding PointerUpCommand}"
TooltipPosition="Hidden">
</lvc:CartesianChart>
</Grid>
</StackPanel>
</UserControl>
namespace AidenDesktop.ViewModels
open System
open System.Linq
open System.IO
open System.Collections.Generic
open System.Collections.ObjectModel
open System.Reactive.Linq
open System.Text.Json
open Avalonia.Controls
open LiveChartsCore.Drawing
open LiveChartsCore.Kernel.Events
open LiveChartsCore.Measure
open LiveChartsCore.SkiaSharpView.Drawing
open LiveChartsCore.VisualElements
open ReactiveUI
open ReactiveElmish
open ReactiveElmish.Avalonia
open Elmish
open SkiaSharp
open LiveChartsCore
open LiveChartsCore.Kernel.Sketches
open LiveChartsCore.SkiaSharpView
open LiveChartsCore.Defaults
open LiveChartsCore.SkiaSharpView.Painting
open Npgsql
module Zoom =
type Msg =
| UpdateSeries
| PointerDown
| PointerUp
| PointerMove of PointerCommandArgs
| ChartUpdated of ChartCommandArgs
| Terminate
type Model =
{
Series: ObservableCollection<ISeries>
ScrollbarSeries: ObservableCollection<ISeries>
ScrollableAxes: ObservableCollection<Axis>
Thumbs: ObservableCollection<RectangularSection>
InvisibleX: ObservableCollection<Axis>
InvisibleY: ObservableCollection<Axis>
IsDown: bool
Margin: Margin
}
let connectionString =
let filePath = Path.Combine(AppContext.BaseDirectory, "appsettings.json")
if File.Exists(filePath) then
let json = JsonDocument.Parse(File.ReadAllText(filePath))
let dbSection = json.RootElement.GetProperty("Database")
let connectionString = dbSection.GetProperty("ConnectionString").GetString()
connectionString
else
printfn $"Error: File not found: %s{filePath}"
""
let fetchEventsPerHourAsync() =
async {
use connection = new NpgsqlConnection(connectionString)
do! connection.OpenAsync() |> Async.AwaitTask
let query =
$@"WITH time_series AS (
SELECT generate_series(
date_trunc('minute', now() AT TIME ZONE 'UTC') - INTERVAL '1 day',
date_trunc('minute', now() AT TIME ZONE 'UTC'),
'1 hour'::interval
) AS event_time
)
SELECT
time_series.event_time,
COALESCE(events.count, 0) AS count
FROM
time_series
LEFT JOIN (
SELECT
date_trunc('minute', event_time) AS event_time,
COUNT(*) AS count
FROM
events_hourly
WHERE
event_time >= now() AT TIME ZONE 'UTC' - INTERVAL '1 day'
GROUP BY
date_trunc('minute', event_time)
) AS events
ON time_series.event_time = events.event_time
ORDER BY
time_series.event_time ASC;"
use cmd = new NpgsqlCommand(query, connection)
do! cmd.PrepareAsync() |> Async.AwaitTask
use! reader = cmd.ExecuteReaderAsync() |> Async.AwaitTask
let results =
[ while reader.Read() do
yield (
reader.GetDateTime(reader.GetOrdinal("event_time")),
reader.GetInt32(reader.GetOrdinal("count"))
) ]
return results
}
let fetchMALEventsPerHourAsync() =
async {
use connection = new NpgsqlConnection(connectionString)
do! connection.OpenAsync() |> Async.AwaitTask
let query =
$@"WITH time_series AS (
SELECT generate_series(
date_trunc('minute', now() AT TIME ZONE 'UTC') - INTERVAL '1 day',
date_trunc('minute', now() AT TIME ZONE 'UTC'),
'1 hour'::interval
) AS event_time
)
SELECT
time_series.event_time,
COALESCE(events.count, 0) AS count
FROM
time_series
LEFT JOIN (
SELECT
date_trunc('minute', event_time) AS event_time,
COUNT(*) AS count
FROM
events_hourly
WHERE
event_time >= now() AT TIME ZONE 'UTC' - INTERVAL '1 day'
AND malware in ('TRUE')
GROUP BY
date_trunc('minute', event_time)
) AS events
ON time_series.event_time = events.event_time
ORDER BY
time_series.event_time ASC;"
use cmd = new NpgsqlCommand(query, connection)
do! cmd.PrepareAsync() |> Async.AwaitTask
use! reader = cmd.ExecuteReaderAsync() |> Async.AwaitTask
let results =
[ while reader.Read() do
yield (
reader.GetDateTime(reader.GetOrdinal("event_time")),
reader.GetInt32(reader.GetOrdinal("count"))
) ]
return results
}
let XAxes : IEnumerable<ICartesianAxis> =
[| Axis (
Labeler = (fun value ->
let eventTime = DateTime(int64 value)
let timeAgo = DateTime.UtcNow - eventTime
if timeAgo.TotalSeconds < 60.0 then
$"{timeAgo.TotalSeconds:F0} seconds ago"
elif timeAgo.TotalMinutes < 60.0 then
$"{timeAgo.TotalMinutes:F0} minutes ago"
else
$"{timeAgo.TotalHours:F0} hours ago"),
LabelsRotation = 10,
UnitWidth = float(TimeSpan.FromSeconds(1).Ticks),
MinStep = float(TimeSpan.FromSeconds(1).Ticks),
NamePaint = new SolidColorPaint(SKColors.Tan),
LabelsPaint = new SolidColorPaint(SKColors.Tan),
TextSize = 12.0
)
|]
let YAxes : IEnumerable<ICartesianAxis> =
[| Axis (
Name = "Events per Minute",
Labeler = (fun value -> $"{value:F0}"),
MinStep = 1.0,
MinLimit = 0.0,
NamePaint = new SolidColorPaint(SKColors.Tan),
LabelsPaint = new SolidColorPaint(SKColors.Tan),
SeparatorsPaint = new SolidColorPaint(SKColor.Parse("#808080"))
)
|]
let init() =
let eventsPerHour =
fetchEventsPerHourAsync()
|> Async.RunSynchronously
|> List.map (fun (time, count) -> DateTimePoint(time, float count))
|> ObservableCollection<_>
let malEventsPerHour =
fetchMALEventsPerHourAsync()
|> Async.RunSynchronously
|> List.map (fun (time, count) -> DateTimePoint(time, float count))
|> ObservableCollection<_>
let minTime = eventsPerHour |> Seq.minBy (fun point -> point.DateTime)
let maxTime = eventsPerHour |> Seq.maxBy (fun point -> point.DateTime)
{
Series = ObservableCollection<ISeries>
[
LineSeries<DateTimePoint>(Values = eventsPerHour,
Name = "Total Events",
GeometryFill = null,
GeometryStroke = null,
Stroke = new SolidColorPaint(SKColors.LightSlateGray, StrokeThickness = 4.0f)
) :> ISeries
LineSeries<DateTimePoint>(Values = malEventsPerHour,
Name = "Possible Threat",
GeometryFill = null,
GeometryStroke = null,
Stroke = new SolidColorPaint(SKColor.FromHsv(30.0f, 100.0f, 100.0f, byte 190), StrokeThickness = 4.0f)
) :> ISeries
]
ScrollbarSeries = ObservableCollection<ISeries>
[
LineSeries<DateTimePoint>(Values = eventsPerHour,
Name = "Total Events",
GeometryFill = null,
GeometryStroke = null,
Stroke = new SolidColorPaint(SKColors.LightSlateGray, StrokeThickness = 4.0f)
) :> ISeries
LineSeries<DateTimePoint>(Values = malEventsPerHour,
Name = "Possible Threat",
GeometryFill = null,
GeometryStroke = null,
Stroke = new SolidColorPaint(SKColor.FromHsv(30.0f, 100.0f, 100.0f, byte 190), StrokeThickness = 4.0f)
) :> ISeries
]
Thumbs = ObservableCollection<RectangularSection>
[
RectangularSection(Xi = float minTime.DateTime.Ticks,
Xj = float maxTime.DateTime.Ticks,
Yi = 20.0,
Yj = 120.0,
Fill = new SolidColorPaint(SKColors.Aqua),
Stroke = new SolidColorPaint(SKColors.Fuchsia))
]
ScrollableAxes = ObservableCollection<Axis> [ Axis() ]
InvisibleX = ObservableCollection<Axis> [ Axis(IsVisible = false) ]
InvisibleY = ObservableCollection<Axis> [ Axis(IsVisible = false) ]
IsDown = false
Margin = Margin(100f, 0f, 50f, 50f)
}
let update (msg: Msg) (model: Model) =
match msg with
| PointerUp ->
printfn $"{DateTime.Now} pointer is up"
{ model with IsDown = false }
| PointerDown ->
printfn $"{DateTime.Now} pointer is down"
{ model with IsDown = true }
| PointerMove args ->
if not model.IsDown then
model
else
printfn $"{DateTime.Now} pointer is moved"
let chart = args.Chart :?> ICartesianChartView<SkiaSharpDrawingContext>
let positionInData = chart.ScalePixelsToData(args.PointerPosition)
let thumb = model.Thumbs.[0]
let currentRange = thumb.Xj.Value - thumb.Xi.Value
// update the scroll bar thumb when the user is dragging the chart
thumb.Xi <- positionInData.X - currentRange / 2.0
thumb.Xj <- positionInData.X + currentRange / 2.0
// update the chart visible range
model.ScrollableAxes.[0].MinLimit <- thumb.Xi.Value
model.ScrollableAxes.[0].MaxLimit <- thumb.Xj.Value
// update the thumb rectangle's Xi and Xj properties
thumb.Xi <- model.ScrollableAxes.[0].MinLimit
thumb.Xj <- model.ScrollableAxes.[0].MaxLimit
model
| ChartUpdated args ->
printfn $"{DateTime.Now} chart updated"
let chart = args.Chart :?> ICartesianChartView<SkiaSharpDrawingContext>
let xAxis = (chart.XAxes.OfType<Axis>()).FirstOrDefault()
let zoomLevel = xAxis.MaxLimit.Value - xAxis.MinLimit.Value
let thumb = model.Thumbs.[0]
// Calculate the new size of the Thumb rectangle based on the zoom level
let newSize = thumb.Xj.Value / zoomLevel
// Update the Xi and Xj properties of the Thumb rectangle
thumb.Xi <- thumb.Xi.Value * newSize
thumb.Xj <- thumb.Xj.Value * newSize
model
| UpdateSeries ->
let latestEvents =
fetchEventsPerHourAsync()
|> Async.RunSynchronously
|> List.map (fun (time, count) -> DateTimePoint(time, float count))
let malEventsPerSecond =
fetchMALEventsPerHourAsync()
|> Async.RunSynchronously
|> List.map (fun (time, count) -> DateTimePoint(time, float count))
// Update existing data points and add new ones
let cutoff = DateTime.UtcNow.AddHours(-24.0)
let values1 = model.Series[0].Values :?> ObservableCollection<DateTimePoint>
let values2 = model.Series[1].Values :?> ObservableCollection<DateTimePoint>
latestEvents
|> List.iter (fun point ->
match Seq.tryFind (fun (p: DateTimePoint) -> p.DateTime = point.DateTime) values1 with
| Some existingPoint -> existingPoint.Value <- point.Value
| None -> values1.Insert(values1.Count, point))
malEventsPerSecond
|> List.iter (fun point ->
match Seq.tryFind (fun (p: DateTimePoint) -> p.DateTime = point.DateTime) values2 with
| Some existingPoint -> existingPoint.Value <- point.Value
| None -> values2.Insert(values2.Count, point))
// Find the elements to remove
let oldValues1 = values1 |> Seq.filter (fun point -> point.DateTime < cutoff) |> Seq.toList
let oldValues2 = values2 |> Seq.filter (fun point -> point.DateTime < cutoff) |> Seq.toList
// Remove the old elements
oldValues1 |> List.iter (fun point -> ignore (values1.Remove point))
oldValues2 |> List.iter (fun point -> ignore (values2.Remove point))
model
| Terminate ->
model
open Zoom
type ZoomViewModel() as this =
inherit ReactiveElmishViewModel()
let local =
Program.mkAvaloniaSimple init update
|> Program.withErrorHandler (fun (_, ex) -> printfn $"Error: %s{ex.Message}")
//|> Program.withConsoleTrace
//|> Program.withSubscription subscriptions
//|> Program.mkStore
//Terminate all Elmish subscriptions on dispose (view is registered as Transient).
|> Program.mkStoreWithTerminate this Terminate
member this.Series = local.Model.Series
member this.ScrollbarSeries = local.Model.ScrollbarSeries
member this.InvisibleX = local.Model.InvisibleX
member this.InvisibleY = local.Model.InvisibleY
member val PointerDownCommand = ReactiveCommand.Create<obj, unit> (fun _ -> local.Dispatch PointerDown) with get, set
member val PointerMoveCommand = ReactiveCommand.Create<PointerCommandArgs, unit> (fun args -> local.Dispatch (PointerMove args)) with get, set
member val PointerUpCommand = ReactiveCommand.Create<obj, unit> (fun _ -> local.Dispatch PointerUp) with get, set
member val ChartUpdatedCommand = ReactiveCommand.Create<ChartCommandArgs, unit> (fun args -> local.Dispatch (ChartUpdated args)) with get, set
member this.Thumbs = local.Model.Thumbs
member this.Margin = local.Model.Margin
member this.XAxes = this.Bind (local, fun _ -> XAxes)
member this.YAxes = this.Bind (local, fun _ -> YAxes)
static member DesignVM = new ZoomViewModel()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment