Skip to content

Instantly share code, notes, and snippets.

@houstonhaynes
Last active December 13, 2023 18:45
Show Gist options
  • Save houstonhaynes/ba26f40ec3273a357fb81d7b4e50ff09 to your computer and use it in GitHub Desktop.
Save houstonhaynes/ba26f40ec3273a357fb81d7b4e50ff09 to your computer and use it in GitHub Desktop.
ViewModel that uses a recursive update function to "reach" multiple model elements from a single data set change
<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"
mc:Ignorable="d" d:DesignWidth="1200" d:DesignHeight="850"
xmlns:lvc="using:LiveChartsCore.SkiaSharpView.Avalonia"
xmlns:vm="using:AvaloniaExample.ViewModels"
xmlns:avalonia="clr-namespace:LiveChartsCore.SkiaSharpView.Avalonia;assembly=LiveChartsCore.SkiaSharpView.Avalonia"
Design.DataContext="{Binding Source={x:Static vm:DoughnutViewModel.DesignVM}}"
x:DataType="vm:DoughnutViewModel"
x:Class="AvaloniaExample.Views.DoughnutView">
<StackPanel HorizontalAlignment="Center" Margin="10">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="50" />
</Grid.RowDefinitions>
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="VPN"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
FontWeight="Bold"
Foreground="Black"
Grid.Column="0" />
<lvc:PieChart
Height="300"
Width="300"
DrawMargin="{Binding Margin}"
Series="{Binding VPN_Series}"
Grid.Column="0" />
<TextBlock Text="PXY"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
FontWeight="Bold"
Foreground="Black"
Grid.Column="1" />
<lvc:PieChart
Height="300"
Width="300"
DrawMargin="{Binding Margin}"
Series="{Binding PXY_Series}"
Grid.Column="1" />
<TextBlock Text="TOR"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
FontWeight="Bold"
Foreground="Black"
Grid.Column="2" />
<lvc:PieChart
Height="300"
Width="300"
DrawMargin="{Binding Margin}"
Series="{Binding TOR_Series}"
Grid.Column="2" />
<TextBlock Text="MAL"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
FontWeight="Bold"
Foreground="Black"
Grid.Column="3"/>
<lvc:PieChart
Height="300"
Width="300"
DrawMargin="{Binding Margin}"
Series="{Binding MAL_Series}"
Grid.Column="3"/>
<TextBlock Text="COO"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="20"
FontWeight="Bold"
Foreground="Black"
Grid.Column="4"/>
<lvc:PieChart
Height="300"
Width="300"
DrawMargin="{Binding Margin}"
Series="{Binding COO_PieSeries}"
Grid.Column="4"/>
</Grid>
<Grid Grid.Row="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="20" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<DataGrid Grid.Column="1" ItemsSource="{Binding COO_GridData}" HorizontalAlignment="Right">
<DataGrid.Columns>
<DataGridTextColumn Header="Country" Binding="{Binding Name}" />
<DataGridTextColumn Header="Count" Binding="{Binding Count}" />
</DataGrid.Columns>
</DataGrid>
<Border Grid.Column="2" CornerRadius="10"
Background="LightBlue"
Padding="20">
<lvc:GeoMap
HorizontalAlignment="Center"
Height="400"
Width="900"
Series="{Binding COO_MapSeries}"
MapProjection="Default"
Grid.Column="2" />
</Border>
</Grid>
<Button Grid.Row="2" Content="Ok" Command="{Binding Ok}" HorizontalAlignment="Center" />
</Grid>
</StackPanel>
</UserControl>
namespace AvaloniaExample.ViewModels
open System
open System.IO
open System.Collections.ObjectModel
open System.Timers
open System.Text.Json
open ReactiveElmish
open ReactiveElmish.Avalonia
open Elmish
open LiveChartsCore
open LiveChartsCore.SkiaSharpView
open LiveChartsCore.SkiaSharpView.Drawing.Geometries
open CommunityToolkit.Mvvm.ComponentModel
open CommunityToolkit.Mvvm.Input
open LiveChartsCore.Geo
open SkiaSharp
open Npgsql
module Doughnut =
let rnd = Random()
// gets connection string from settings.json
let connectionString =
let json = JsonDocument.Parse(File.ReadAllText("appsettings.json"))
let dbSection = json.RootElement.GetProperty("Database")
let host = dbSection.GetProperty("Host").GetString()
let port = dbSection.GetProperty("Port").GetInt32()
let user = dbSection.GetProperty("User").GetString()
let password = dbSection.GetProperty("Password").GetString()
let database = dbSection.GetProperty("Database").GetString()
let connectionString = $"Host={host};Port={port};Username={user};Password={password};Database={database}"
printfn $"Connection String: %s{connectionString}"
connectionString
type CountryData = {
Name: string
Count: int
}
type Model =
{
VPN_Series: ObservableCollection<ISeries>
TOR_Series: ObservableCollection<ISeries>
PXY_Series: ObservableCollection<ISeries>
MAL_Series: ObservableCollection<ISeries>
COO_PieSeries: ObservableCollection<ISeries>
COO_MapSeries: HeatLandSeries array
COO_GridData: ObservableCollection<CountryData>
IsFrozen: bool
Margin: LiveChartsCore.Measure.Margin
currentColorSeries: Drawing.LvcColor array
}
type Msg =
| UpdateVPNChartData of (string * int) list
| UpdateTORChartData of (string * int) list
| UpdatePXYChartData of (string * int) list
| UpdateMALChartData of (string * int) list
| UpdateCOOChartData of (string * int) list
| UpdateCOOGridData of (string * int) list
| Terminate
let blueSeries = [|
SKColor.Parse("#5e56f5").AsLvcColor(); // LightBlue
SKColor.Parse("#2d2899").AsLvcColor(); // Blue
SKColor.Parse("#100c52").AsLvcColor() // DeepBlue
|]
let orangeSeries = [|
SKColor.Parse("#ed6339").AsLvcColor();
SKColor.Parse("#bf431d").AsLvcColor();
SKColor.Parse("#ad2a02").AsLvcColor()
|]
let greenSeries = [|
SKColor.Parse("#47cc47").AsLvcColor();
SKColor.Parse("#0cab0c").AsLvcColor();
SKColor.Parse("#036603").AsLvcColor()
|]
let goldSeries = [|
SKColor.Parse("#f0d341").AsLvcColor();
SKColor.Parse("#c4a81a").AsLvcColor();
SKColor.Parse("#998005").AsLvcColor()
|]
let purpleSeries = [|
SKColor.Parse("#9e4cf5").AsLvcColor();
SKColor.Parse("#671fb5").AsLvcColor();
SKColor.Parse("#3c0478").AsLvcColor()
|]
let allColorSeries = [blueSeries; orangeSeries; greenSeries; goldSeries; purpleSeries]
let selectRandomColorSeries (currentColorSeries: Drawing.LvcColor array) =
// Filter out the current color series
let availableColorSeries = allColorSeries |> List.filter (fun series -> series <> currentColorSeries)
// Select a random color series from the available ones
let rnd = Random()
let index = rnd.Next(availableColorSeries.Length)
availableColorSeries.[index]
let fetchDataAsync(column: string) =
async {
// Connect to the database and execute the query
use connection = new NpgsqlConnection(connectionString)
do! connection.OpenAsync() |> Async.AwaitTask
// Construct the query string with the column name
let query =
$@"SELECT UNNEST(string_to_array({column}, ':')) AS label, COUNT(*) AS count
FROM events_hourly
WHERE event_time >= now() AT TIME ZONE 'UTC' - INTERVAL '1 minute'
GROUP BY label;"
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.GetString(reader.GetOrdinal("label")),
reader.GetInt32(reader.GetOrdinal("count"))
) ]
return results
}
let fetchDataForPXYChart (dispatch: Msg -> unit) =
let timer = new Timer(rnd.Next(2990, 3010)) // Fetch data every 5 seconds
let disposable =
timer.Elapsed.Subscribe(fun _ ->
async {
let! fetchedData = fetchDataAsync("proxy")
dispatch (UpdatePXYChartData fetchedData)
} |> Async.Start
)
printfn $"{DateTime.Now} PXY Subscription started"
timer.Start()
disposable
let fetchDataForMALChart (dispatch: Msg -> unit) =
let timer = new Timer(rnd.Next(2990, 3010)) // Fetch data every 5 seconds
let disposable =
timer.Elapsed.Subscribe(fun _ ->
async {
let! fetchedData = fetchDataAsync("malware")
dispatch (UpdateMALChartData fetchedData)
} |> Async.Start
)
printfn $"{DateTime.Now} MAL Subscription started"
timer.Start()
disposable
let fetchDataForCOOChart (dispatch: Msg -> unit) =
let timer = new Timer(rnd.Next(2990, 3010)) // Fetch data every 5 seconds
let disposable =
timer.Elapsed.Subscribe(fun _ ->
async {
let! fetchedData = fetchDataAsync("cc")
dispatch (UpdateCOOChartData fetchedData)
} |> Async.Start
)
printfn $"{DateTime.Now} COO Subscription started"
timer.Start()
disposable
let fetchDataForVPNChart (dispatch: Msg -> unit) =
let timer = new Timer(rnd.Next(2990, 3010)) // Fetch data every 5 seconds
let disposable =
timer.Elapsed.Subscribe(fun _ ->
async {
let! fetchedData = fetchDataAsync("vpn")
dispatch (UpdateVPNChartData fetchedData)
} |> Async.Start
)
printfn $"{DateTime.Now} VPN Subscription started"
timer.Start()
disposable
let fetchDataForTORChart (dispatch: Msg -> unit) =
let timer = new Timer(rnd.Next(2990, 3010)) // Fetch data every 5 seconds
let disposable =
timer.Elapsed.Subscribe(fun _ ->
async {
let! fetchedData = fetchDataAsync("tor")
dispatch (UpdateTORChartData fetchedData)
} |> Async.Start
)
printfn $"{DateTime.Now} TOR Subscription started"
timer.Start()
disposable
let fetchPieDataAsync (dataType: string) =
async {
let! data = fetchDataAsync(dataType)
let series = data |> List.map (fun (name, value) -> PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries)
return ObservableCollection<ISeries>(series)
}
let fetchCOOGridDataAsync (dataType: string) =
async {
let! data = fetchDataAsync(dataType)
let updatedGridData = data |> List.map (fun (name, count) -> { Name = name.ToUpper(); Count = count })
return ObservableCollection<_>(updatedGridData)
}
let init() =
async {
let! vpnSeries = fetchPieDataAsync("vpn")
let! torSeries = fetchPieDataAsync("tor")
let! pxySeries = fetchPieDataAsync("proxy")
let! malSeries = fetchPieDataAsync("malware")
let! cooSeries = fetchPieDataAsync("cc")
let! cooGridData = fetchCOOGridDataAsync("cc")
return {
VPN_Series = vpnSeries
TOR_Series = torSeries
PXY_Series = pxySeries
COO_MapSeries = [| HeatLandSeries(HeatMap = blueSeries, Lands = [|
HeatLand(Name = "usa", Value = 47.0) :> IWeigthedMapLand
HeatLand(Name = "gbr", Value = 6.0) :> IWeigthedMapLand
HeatLand(Name = "egy", Value = 7.0) :> IWeigthedMapLand
HeatLand(Name = "ind", Value = 18.0) :> IWeigthedMapLand
HeatLand(Name = "kor", Value = 10.0) :> IWeigthedMapLand
HeatLand(Name = "rus", Value = 7.0) :> IWeigthedMapLand
HeatLand(Name = "can", Value = 22.0) :> IWeigthedMapLand
HeatLand(Name = "ukr", Value = 6.0) :> IWeigthedMapLand
HeatLand(Name = "idn", Value = 5.0) :> IWeigthedMapLand
HeatLand(Name = "deu", Value = 2.0) :> IWeigthedMapLand
|]) |]
COO_PieSeries = cooSeries
COO_GridData = cooGridData
MAL_Series = malSeries
IsFrozen = false
Margin = LiveChartsCore.Measure.Margin(50f, 50f, 50f, 50f)
currentColorSeries = blueSeries
}
} |> Async.RunSynchronously
let rec update (msg: Msg) (model: Model) =
match msg with
| UpdateMALChartData chartData ->
let seriesMap =
model.MAL_Series |> Seq.map (fun s -> (s :?> PieSeries<int>).Name, s) |> Map.ofSeq
chartData |> List.iter (fun (name, value) ->
match seriesMap.TryFind(name) with
| Some series ->
let pieSeries = series :?> PieSeries<int>
pieSeries.Values <- ObservableCollection<_>([| value |]) // Assign a new collection
| None ->
// Add new series
let newSeries = PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries
model.MAL_Series.Add(newSeries)
)
// Remove series not present in chartData
let currentNames = chartData |> List.map fst |> Set.ofList
model.MAL_Series
|> Seq.toArray
|> Array.iter (fun series ->
let pieSeries = series :?> PieSeries<int>
if not (Set.contains pieSeries.Name currentNames) then
model.MAL_Series.Remove(series) |> ignore
)
printfn $"{DateTime.Now} MAL Series updated"
model
| UpdatePXYChartData chartData ->
let seriesMap =
model.PXY_Series |> Seq.map (fun s -> (s :?> PieSeries<int>).Name, s) |> Map.ofSeq
chartData |> List.iter (fun (name, value) ->
match seriesMap.TryFind(name) with
| Some series ->
let pieSeries = series :?> PieSeries<int>
pieSeries.Values <- ObservableCollection<_>([| value |]) // Assign a new collection
| None ->
// Add new series
let newSeries = PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries
model.PXY_Series.Add(newSeries)
)
// Remove series not present in chartData
let currentNames = chartData |> List.map fst |> Set.ofList
model.PXY_Series
|> Seq.toArray
|> Array.iter (fun series ->
let pieSeries = series :?> PieSeries<int>
if not (Set.contains pieSeries.Name currentNames) then
model.PXY_Series.Remove(series) |> ignore
)
printfn $"{DateTime.Now} PXY Series updated"
model
| UpdateVPNChartData chartData ->
let seriesMap =
model.VPN_Series |> Seq.map (fun s -> (s :?> PieSeries<int>).Name, s) |> Map.ofSeq
chartData |> List.iter (fun (name, value) ->
match seriesMap.TryFind(name) with
| Some series ->
let pieSeries = series :?> PieSeries<int>
pieSeries.Values <- ObservableCollection<_>([| value |]) // Assign a new collection
| None ->
// Add new series
let newSeries = PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries
model.VPN_Series.Add(newSeries)
)
// Remove series not present in chartData
let currentNames = chartData |> List.map fst |> Set.ofList
model.VPN_Series
|> Seq.toArray
|> Array.iter (fun series ->
let pieSeries = series :?> PieSeries<int>
if not (Set.contains pieSeries.Name currentNames) then
model.VPN_Series.Remove(series) |> ignore
)
printfn $"{DateTime.Now} VPN Series updated"
model
| UpdateTORChartData chartData ->
let seriesMap =
model.TOR_Series |> Seq.map (fun s -> (s :?> PieSeries<int>).Name, s) |> Map.ofSeq
chartData |> List.iter (fun (name, value) ->
match seriesMap.TryFind(name) with
| Some series ->
let pieSeries = series :?> PieSeries<int>
pieSeries.Values <- ObservableCollection<_>([| value |]) // Assign a new collection
| None ->
// Add new series
let newSeries = PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries
model.TOR_Series.Add(newSeries)
)
// Remove series not present in chartData
let currentNames = chartData |> List.map fst |> Set.ofList
model.TOR_Series
|> Seq.toArray
|> Array.iter (fun series ->
let pieSeries = series :?> PieSeries<int>
if not (Set.contains pieSeries.Name currentNames) then
model.TOR_Series.Remove(series) |> ignore
)
printfn $"{DateTime.Now} TOR Series updated"
model
| UpdateCOOChartData chartData ->
let seriesMap =
model.COO_PieSeries |> Seq.map (fun s -> (s :?> PieSeries<int>).Name, s) |> Map.ofSeq
chartData |> List.iter (fun (name, value) ->
match seriesMap.TryFind(name) with
| Some series ->
let pieSeries = series :?> PieSeries<int>
pieSeries.Values <- ObservableCollection<_>([| value |]) // Assign a new collection
| None ->
// Add new series
let newSeries = PieSeries<int>(Values = ObservableCollection<_>([| value |]), InnerRadius = 40.0, Name = name) :> ISeries
model.COO_PieSeries.Add(newSeries)
)
let createHeatMap () =
let selectedColorSeries = selectRandomColorSeries model.currentColorSeries
selectedColorSeries
let updateOrAddLand (series: HeatLandSeries) (name: string, value: int) =
let nameLower = name.ToLower()
let landsMap = series.Lands |> Seq.map (fun l -> (l.Name.ToLower(), l)) |> Map.ofSeq
let updatedLands =
if Map.containsKey nameLower landsMap then
landsMap
|> Map.map (fun key l ->
if key = nameLower then
HeatLand(Name = l.Name, Value = float value) :> IWeigthedMapLand
else l)
|> Map.values
else
landsMap.Add(nameLower, new HeatLand(Name = name, Value = float value) :> IWeigthedMapLand)
|> Map.values
let newHeatMap = createHeatMap()
series.HeatMap <- newHeatMap
series.Lands <- updatedLands |> Seq.toArray
updatedLands, newHeatMap
printfn $"{DateTime.Now} COO Series updated"
// Replace the COO_MapSeries in the model with the new series
match model.COO_MapSeries, model.currentColorSeries with
| [| heatLandSeries |] as existingSeries, currentColorSeries ->
let updatedLandsAndHeatMaps = chartData |> List.map (fun (name, value) -> updateOrAddLand heatLandSeries (name, value))
let _, newHeatMaps = List.unzip updatedLandsAndHeatMaps
let updatedModel = { model with COO_MapSeries = existingSeries; currentColorSeries = newHeatMaps |> List.last }
// this is the second loop that calls the branch below
update (UpdateCOOGridData chartData) updatedModel
| _ ->
// Handle case where COO_MapSeries is not initialized or in an unexpected state
model
| UpdateCOOGridData chartData ->
let updatedGridData = chartData |> List.map (fun (name, count) -> { Name = name.ToUpper(); Count = count })
{ model with COO_GridData = ObservableCollection<_>(updatedGridData) }
| Terminate -> model
let subscriptions (model: Model) : Sub<Msg> =
[
[ nameof fetchDataForCOOChart], fetchDataForCOOChart
[ nameof fetchDataForMALChart], fetchDataForMALChart
[ nameof fetchDataForVPNChart], fetchDataForVPNChart
[ nameof fetchDataForPXYChart], fetchDataForPXYChart
[ nameof fetchDataForTORChart], fetchDataForTORChart
]
open Doughnut
type DoughnutViewModel() as this =
inherit ReactiveElmishViewModel()
let app = App.app
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.Margin = this.Bind(local, _.Margin)
member this.VPN_Series = this.Bind(local, _.VPN_Series)
member this.TOR_Series = this.Bind(local, _.TOR_Series)
member this.PXY_Series = this.Bind(local, _.PXY_Series)
member this.COO_MapSeries = this.Bind(local, _.COO_MapSeries)
member this.COO_PieSeries = this.Bind(local, _.COO_PieSeries)
member this.COO_GridData = this.Bind(local, _.COO_GridData)
member this.MAL_Series = this.Bind(local, _.MAL_Series)
member this.Ok() = app.Dispatch App.GoHome
static member DesignVM = new DoughnutViewModel()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment