Meter chain topology + per-energy-type flow (Sankey) pages
ci / build-test (push) Successful in 1m24s
ci / build-test (push) Successful in 1m24s
Adds a meter hierarchy and a flow view: a downstream meter is a *subsection*
of an upstream one (not an addition), so you can see where a main meter's flow
divides — e.g. official water → garden, pool, other; grid/battery → all → car.
- MeterLink (schema + migration AddMeterLinks): a directed from→to flow edge.
Multi-parent allowed (a merge, e.g. grid + solar → house); multi-child is a
split. Cascade-deletes with either endpoint; unique + distinct-endpoint checks.
- FlowService: per energy type + period, builds a Sankey graph — nodes = meters
sized by consumption; link value = downstream meter's consumption, split
proportionally across multiple upstreams; unaccounted remainder under a meter
becomes a synthetic "Other" node; depth via topological longest-path.
- SankeyChart.razor: hand-rolled inline-SVG Sankey (ApexCharts has no Sankey
type) — columns by depth, nodes stacked by value, bezier ribbons sized by flow,
left→right, theme-aware, HTML-encoded labels, tooltips. Built as a MarkupString
to sidestep Razor's <text> element clash.
- /energy/{id} page (one per energy type): KPIs (consumption + cost), the flow
Sankey, and the meter list. NavMenu now lists a link per energy type
(Electricity, Water, Gas, …) loaded from the DB.
- Meters admin: cycle-safe "Sub-meter of (upstream meters)" multi-select
(descendants excluded to prevent cycles); reconciles meter_link rows on save.
- Reference data seeds a demo chain (Haus → Auto) so electricity flow shows
Haus dividing into Auto + Other.
Tests: FlowServiceTests (single-parent remainder; two-parent proportional
split); render test now asserts the flow chain + covers /energy/{id}. 69 Core +
47 Integration = 116 green. Live-verified: Haus 95,450 kWh → Auto 51,909 +
Other 43,541 (flow conserved), all 5 energy-type pages render.
Claude-Session: https://claude.ai/code/session_01Lz2RqAsnQhetqWNoCDfexK
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MeterVault.Core.Domain
|
||||
|
||||
<MudNavMenu>
|
||||
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">Overview</MudNavLink>
|
||||
<MudNavLink Href="/trends" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.ShowChart">Trends</MudNavLink>
|
||||
|
||||
@foreach (var type in _energyTypes)
|
||||
{
|
||||
<MudNavLink Href="@($"/energy/{type.Id}")" Match="NavLinkMatch.Prefix" Icon="@TypeIcon(type.Icon)">@type.DisplayName</MudNavLink>
|
||||
}
|
||||
|
||||
<MudNavLink Href="/solar" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.WbSunny">Solar / PV</MudNavLink>
|
||||
<MudNavLink Href="/consumables" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.OilBarrel">Oil / consumables</MudNavLink>
|
||||
<MudNavLink Href="/meters" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Speed">Meters</MudNavLink>
|
||||
@@ -14,3 +24,32 @@
|
||||
<MudNavLink Href="/admin/settings" Icon="@Icons.Material.Filled.Tune">Settings</MudNavLink>
|
||||
</MudNavGroup>
|
||||
</MudNavMenu>
|
||||
|
||||
@code {
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.Id).ToListAsync();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Nav must never break the layout — a DB hiccup just hides the per-type links.
|
||||
_energyTypes = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Map the energy type's stored icon name to a Material icon; fall back to a generic gauge.
|
||||
private static string TypeIcon(string? icon) => icon switch
|
||||
{
|
||||
"bolt" => Icons.Material.Filled.Bolt,
|
||||
"water_drop" => Icons.Material.Filled.WaterDrop,
|
||||
"local_gas_station" => Icons.Material.Filled.LocalGasStation,
|
||||
"gas_meter" => Icons.Material.Filled.GasMeter,
|
||||
"thermostat" => Icons.Material.Filled.Thermostat,
|
||||
_ => Icons.Material.Filled.Bolt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
@page "/energy/{Id:int}"
|
||||
@rendermode InteractiveServer
|
||||
@inject FlowService Flow
|
||||
@inject MeterVault.Infrastructure.Costing.CostService Costs
|
||||
@inject Microsoft.EntityFrameworkCore.IDbContextFactory<MeterVault.Infrastructure.Persistence.MeterVaultDbContext> DbFactory
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using MudBlazor
|
||||
|
||||
<PageTitle>MeterVault — @(_graph?.EnergyType ?? "Energy")</PageTitle>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-4 flex-wrap" style="gap:1rem">
|
||||
<MudText Typo="Typo.h4">@(_graph?.EnergyType ?? "Energy") flow</MudText>
|
||||
<MudSelect T="int" Value="_months" ValueChanged="OnRangeChanged" Label="Range" Dense="true" Style="max-width:200px">
|
||||
<MudSelectItem T="int" Value="12">Last 12 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="24">Last 24 months</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="60">Last 5 years</MudSelectItem>
|
||||
<MudSelectItem T="int" Value="1200">All time</MudSelectItem>
|
||||
</MudSelect>
|
||||
</div>
|
||||
|
||||
@if (_graph is null)
|
||||
{
|
||||
<MudProgressLinear Indeterminate="true" Color="Color.Primary" />
|
||||
}
|
||||
else if (!_graph.HasData)
|
||||
{
|
||||
<MudAlert Severity="Severity.Info">
|
||||
No meters for this energy type yet. Add meters in <MudLink Href="/meters">Meters</MudLink>, or load the
|
||||
reference data from <MudLink Href="/import">Import</MudLink>.
|
||||
</MudAlert>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudGrid Class="mb-2">
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Top-level consumption</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Number(_graph.Total, 0) @_graph.Unit</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Cost (range)</MudText>
|
||||
<MudText Typo="Typo.h5">@Format.Euro(_cost)</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
<MudItem xs="12" sm="4">
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.overline" Color="Color.Secondary">Meters</MudText>
|
||||
<MudText Typo="Typo.h5">@_meters.Count</MudText>
|
||||
</MudPaper>
|
||||
</MudItem>
|
||||
</MudGrid>
|
||||
|
||||
<MudPaper Class="pa-4 mb-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-1">Flow</MudText>
|
||||
@if (_graph.HasChain)
|
||||
{
|
||||
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">
|
||||
Where the top-level flow goes. Arrow thickness ∝ amount; "Other" is the unmetered remainder.
|
||||
</MudText>
|
||||
<SankeyChart Nodes="_graph.Nodes" Links="_graph.Links" Unit="_graph.Unit" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudAlert Severity="Severity.Info" Dense="true" Class="mb-3">
|
||||
No meter chain configured yet. In <MudLink Href="/meters">Meters</MudLink> → edit a sub-meter and set its
|
||||
<b>upstream meter(s)</b> to show where the main meter's flow divides (e.g. main → car, pool, other).
|
||||
</MudAlert>
|
||||
@if (_graph.Nodes.Count > 0)
|
||||
{
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Meter</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var node in _graph.Nodes.OrderByDescending(n => n.Value))
|
||||
{
|
||||
<tr>
|
||||
<td>@node.Label</td>
|
||||
<td style="text-align:right">@Format.Number(node.Value, 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
}
|
||||
}
|
||||
</MudPaper>
|
||||
|
||||
<MudPaper Class="pa-4" Elevation="2">
|
||||
<MudText Typo="Typo.h6" Class="mb-2">Meters</MudText>
|
||||
<MudSimpleTable Dense="true" Hover="true">
|
||||
<thead><tr><th>Name</th><th>Mode</th><th>Upstream of</th><th style="text-align:right">Consumption</th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (var meter in _meters)
|
||||
{
|
||||
<tr>
|
||||
<td><MudLink Href="@($"/meters/{meter.Id}")">@meter.Name</MudLink></td>
|
||||
<td>@meter.Mode</td>
|
||||
<td>@UpstreamLabel(meter.Id)</td>
|
||||
<td style="text-align:right">@Format.Number(NodeValue(meter.Id), 0) @_graph.Unit</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</MudSimpleTable>
|
||||
</MudPaper>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int Id { get; set; }
|
||||
|
||||
private int _months = 60;
|
||||
private bool _loading;
|
||||
private FlowGraph? _graph;
|
||||
private double _cost;
|
||||
private List<Meter> _meters = [];
|
||||
private Dictionary<int, List<string>> _downstream = [];
|
||||
|
||||
protected override Task OnParametersSetAsync() => LoadAsync();
|
||||
|
||||
private async Task OnRangeChanged(int months)
|
||||
{
|
||||
_months = months;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
if (_loading)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_loading = true;
|
||||
_graph = null;
|
||||
try
|
||||
{
|
||||
var asOf = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var from = new DateOnly(asOf.AddMonths(-_months).Year, asOf.AddMonths(-_months).Month, 1);
|
||||
var to = asOf.AddMonths(1);
|
||||
var typeId = (short)Id;
|
||||
|
||||
_graph = await Flow.GetFlowAsync(typeId, from, to);
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_meters = await db.Meters.AsNoTracking().Where(m => m.EnergyTypeId == typeId).OrderBy(m => m.Name).ToListAsync();
|
||||
var links = await db.MeterLinks.AsNoTracking()
|
||||
.Where(l => _meters.Select(m => m.Id).Contains(l.FromMeterId))
|
||||
.ToListAsync();
|
||||
var names = _meters.ToDictionary(m => m.Id, m => m.Name);
|
||||
_downstream = links
|
||||
.GroupBy(l => l.FromMeterId)
|
||||
.ToDictionary(g => g.Key, g => g.Select(l => names.GetValueOrDefault(l.ToMeterId, $"#{l.ToMeterId}")).ToList());
|
||||
|
||||
var fromUtc = new DateTimeOffset(from.Year, from.Month, from.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
var toUtc = new DateTimeOffset(to.Year, to.Month, to.Day, 0, 0, 0, TimeSpan.Zero);
|
||||
double cost = 0;
|
||||
foreach (var meter in _meters)
|
||||
{
|
||||
cost += (await Costs.GetMeterCostsAsync(meter.Id, fromUtc, toUtc, CostBucket.Month)).Sum(c => c.Cost);
|
||||
}
|
||||
_cost = cost;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private double NodeValue(int meterId) => _graph?.Nodes.FirstOrDefault(n => n.MeterId == meterId)?.Value ?? 0;
|
||||
|
||||
private string UpstreamLabel(int meterId) =>
|
||||
_downstream.TryGetValue(meterId, out var children) && children.Count > 0 ? string.Join(", ", children) : "—";
|
||||
}
|
||||
@@ -87,6 +87,15 @@ else
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridImport">grid_import</MudSelectItem>
|
||||
<MudSelectItem T="string" Value="@MeterRoles.GridExport">grid_export</MudSelectItem>
|
||||
</MudSelect>
|
||||
<MudSelect T="int" MultiSelection="true" @bind-SelectedValues="_working.Upstream"
|
||||
Label="Sub-meter of (upstream meters)" Class="mb-2"
|
||||
MultiSelectionTextFunc="@(ids => UpstreamText(ids))"
|
||||
HelperText="This meter measures a subsection of the selected meter(s)' flow.">
|
||||
@foreach (var m in AvailableUpstream())
|
||||
{
|
||||
<MudSelectItem T="int" Value="m.Id">@m.Name</MudSelectItem>
|
||||
}
|
||||
</MudSelect>
|
||||
<MudTextField @bind-Value="_working.Location" Label="Location (optional)" Class="mb-2" />
|
||||
<MudTextField @bind-Value="_working.SerialNumber" Label="Serial number (optional)" Class="mb-2" />
|
||||
<div class="d-flex" style="gap:1rem">
|
||||
@@ -108,6 +117,7 @@ else
|
||||
@code {
|
||||
private List<Meter>? _meters;
|
||||
private List<EnergyType> _energyTypes = [];
|
||||
private List<MeterLink> _allLinks = [];
|
||||
private bool _editOpen;
|
||||
private EditModel _working = new();
|
||||
private readonly DialogOptions _dialogOptions = new() { MaxWidth = MaxWidth.Small, FullWidth = true };
|
||||
@@ -118,6 +128,7 @@ else
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
_energyTypes = await db.EnergyTypes.AsNoTracking().OrderBy(t => t.DisplayName).ToListAsync();
|
||||
_allLinks = await db.MeterLinks.AsNoTracking().ToListAsync();
|
||||
_meters = await db.Meters
|
||||
.AsNoTracking()
|
||||
.Include(m => m.EnergyType)
|
||||
@@ -126,6 +137,49 @@ else
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
// Upstream candidates: same energy type, not self, and not a descendant (would create a cycle).
|
||||
private IEnumerable<Meter> AvailableUpstream()
|
||||
{
|
||||
if (_meters is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var descendants = Descendants(_working.Id);
|
||||
return _meters.Where(m => m.EnergyTypeId == _working.EnergyTypeId && m.Id != _working.Id && !descendants.Contains(m.Id));
|
||||
}
|
||||
|
||||
private HashSet<int> Descendants(int meterId)
|
||||
{
|
||||
var result = new HashSet<int>();
|
||||
if (meterId == 0)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var queue = new Queue<int>();
|
||||
queue.Enqueue(meterId);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var current = queue.Dequeue();
|
||||
foreach (var link in _allLinks.Where(l => l.FromMeterId == current))
|
||||
{
|
||||
if (result.Add(link.ToMeterId))
|
||||
{
|
||||
queue.Enqueue(link.ToMeterId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string UpstreamText(IReadOnlyList<string> ids)
|
||||
{
|
||||
var names = ids.Select(idText => int.TryParse(idText, out var id) ? _meters?.FirstOrDefault(m => m.Id == id)?.Name ?? idText : idText);
|
||||
return string.Join(", ", names);
|
||||
}
|
||||
|
||||
private void OpenEdit(Meter? meter)
|
||||
{
|
||||
if (meter is null)
|
||||
@@ -150,6 +204,7 @@ else
|
||||
Manufacturer = meter.Manufacturer,
|
||||
Model = meter.Model,
|
||||
IsActive = meter.IsActive,
|
||||
Upstream = _allLinks.Where(l => l.ToMeterId == meter.Id).Select(l => l.FromMeterId).ToHashSet(),
|
||||
};
|
||||
}
|
||||
_editOpen = true;
|
||||
@@ -164,9 +219,10 @@ else
|
||||
}
|
||||
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
int meterId;
|
||||
if (_working.Id == 0)
|
||||
{
|
||||
db.Meters.Add(new Meter
|
||||
var meter = new Meter
|
||||
{
|
||||
Name = _working.Name.Trim(),
|
||||
EnergyTypeId = _working.EnergyTypeId,
|
||||
@@ -179,8 +235,10 @@ else
|
||||
Manufacturer = Trim(_working.Manufacturer),
|
||||
Model = Trim(_working.Model),
|
||||
IsActive = _working.IsActive,
|
||||
});
|
||||
};
|
||||
db.Meters.Add(meter);
|
||||
await db.SaveChangesAsync();
|
||||
meterId = meter.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -208,13 +266,35 @@ else
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
meterId = existing.Id;
|
||||
}
|
||||
|
||||
await SyncUpstreamAsync(db, meterId, _working.Upstream);
|
||||
|
||||
_editOpen = false;
|
||||
Snackbar.Add("Saved.", Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
/// <summary>Reconciles the meter's incoming flow links to the selected upstream meters.</summary>
|
||||
private static async Task SyncUpstreamAsync(MeterVault.Infrastructure.Persistence.MeterVaultDbContext db, int meterId, IEnumerable<int> desiredUpstream)
|
||||
{
|
||||
var desired = desiredUpstream.Where(id => id != meterId).ToHashSet();
|
||||
var existing = await db.MeterLinks.Where(l => l.ToMeterId == meterId).ToListAsync();
|
||||
|
||||
foreach (var link in existing.Where(l => !desired.Contains(l.FromMeterId)))
|
||||
{
|
||||
db.MeterLinks.Remove(link);
|
||||
}
|
||||
|
||||
foreach (var fromId in desired.Where(id => existing.All(l => l.FromMeterId != id)))
|
||||
{
|
||||
db.MeterLinks.Add(new MeterLink { FromMeterId = fromId, ToMeterId = meterId });
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteAsync(Meter meter)
|
||||
{
|
||||
await using var db = await DbFactory.CreateDbContextAsync();
|
||||
@@ -258,6 +338,7 @@ else
|
||||
public string? Manufacturer { get; set; }
|
||||
public string? Model { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public IReadOnlyCollection<int> Upstream { get; set; } = new HashSet<int>();
|
||||
|
||||
public bool RecomputeNeeded => Mode != OriginalMode || Math.Abs(InitialBaseline - OriginalBaseline) > 1e-9;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
@using System.Globalization
|
||||
@using System.Text
|
||||
@using System.Net
|
||||
@using MeterVault.Infrastructure.Dashboard
|
||||
|
||||
@if (string.IsNullOrEmpty(_svg))
|
||||
{
|
||||
<MudBlazor.MudText Typo="MudBlazor.Typo.body2" Color="MudBlazor.Color.Secondary">No flow to show for this period.</MudBlazor.MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="width:100%; overflow-x:auto">
|
||||
@((MarkupString)_svg)
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const double W = 1000;
|
||||
private const double NodeWidth = 16;
|
||||
private const double NodeGap = 12;
|
||||
private const double LeftPad = 8;
|
||||
private const double RightPad = 8;
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowNode> Nodes { get; set; } = [];
|
||||
|
||||
[Parameter, EditorRequired]
|
||||
public IReadOnlyList<FlowLink> Links { get; set; } = [];
|
||||
|
||||
[Parameter]
|
||||
public string Unit { get; set; } = "";
|
||||
|
||||
private string _svg = "";
|
||||
|
||||
protected override void OnParametersSet() => _svg = BuildSvg();
|
||||
|
||||
private string BuildSvg()
|
||||
{
|
||||
if (Nodes.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
var maxDepth = Nodes.Max(n => n.Depth);
|
||||
var columns = Nodes.GroupBy(n => n.Depth).ToDictionary(g => g.Key, g => g.OrderByDescending(n => n.Value).ToList());
|
||||
var maxCount = columns.Values.Max(c => c.Count);
|
||||
var height = Math.Max(320, (maxCount * 46) + 40);
|
||||
|
||||
// One value→pixel scale so every column fits (flow is conserved → column totals are ~equal;
|
||||
// the densest column with the most gaps constrains the scale).
|
||||
var scale = double.MaxValue;
|
||||
foreach (var col in columns.Values)
|
||||
{
|
||||
var sum = col.Sum(n => n.Value);
|
||||
if (sum > 0)
|
||||
{
|
||||
scale = Math.Min(scale, (height - ((col.Count - 1) * NodeGap) - 20) / sum);
|
||||
}
|
||||
}
|
||||
|
||||
if (double.IsInfinity(scale) || scale <= 0)
|
||||
{
|
||||
scale = 1;
|
||||
}
|
||||
|
||||
var colStep = maxDepth == 0 ? 0 : (W - LeftPad - RightPad - NodeWidth) / maxDepth;
|
||||
var geo = new Dictionary<string, NodeGeo>();
|
||||
foreach (var (depth, col) in columns)
|
||||
{
|
||||
var heights = col.Select(n => Math.Max(3, n.Value * scale)).ToList();
|
||||
var colHeight = heights.Sum() + ((col.Count - 1) * NodeGap);
|
||||
var y = (height - colHeight) / 2;
|
||||
var x = LeftPad + (depth * colStep);
|
||||
for (var i = 0; i < col.Count; i++)
|
||||
{
|
||||
geo[col[i].Id] = new NodeGeo(x, y, heights[i]);
|
||||
y += heights[i] + NodeGap;
|
||||
}
|
||||
}
|
||||
|
||||
// Ribbon band offsets: order each source's out-links by target y, each target's in-links by source y.
|
||||
var srcOffset = new Dictionary<string, double>();
|
||||
var dstOffset = new Dictionary<string, double>();
|
||||
var srcBand = new Dictionary<FlowLink, double>();
|
||||
var dstBand = new Dictionary<FlowLink, double>();
|
||||
foreach (var group in Links.GroupBy(l => l.From))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.To, out var g) ? g.Y : 0))
|
||||
{
|
||||
srcBand[link] = srcOffset.GetValueOrDefault(group.Key);
|
||||
srcOffset[group.Key] = srcBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var group in Links.GroupBy(l => l.To))
|
||||
{
|
||||
foreach (var link in group.OrderBy(l => geo.TryGetValue(l.From, out var g) ? g.Y : 0))
|
||||
{
|
||||
dstBand[link] = dstOffset.GetValueOrDefault(group.Key);
|
||||
dstOffset[group.Key] = dstBand[link] + (link.Value * scale);
|
||||
}
|
||||
}
|
||||
|
||||
var color = Nodes.ToDictionary(n => n.Id, n => n.ColorHex ?? "#607D8B");
|
||||
var label = Nodes.ToDictionary(n => n.Id, n => n.Label);
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<svg viewBox=\"0 0 {F(W)} {F(height)}\" width=\"100%\" style=\"height:{F(height)}px;min-width:520px;color:var(--mud-palette-text-primary)\" role=\"img\" aria-label=\"Flow diagram\">");
|
||||
|
||||
// Ribbons first (under nodes).
|
||||
foreach (var link in Links)
|
||||
{
|
||||
if (!geo.TryGetValue(link.From, out var s) || !geo.TryGetValue(link.To, out var t))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var band = link.Value * scale;
|
||||
var sy0 = s.Y + srcBand[link];
|
||||
var ty0 = t.Y + dstBand[link];
|
||||
var sx = s.X + NodeWidth;
|
||||
var tx = t.X;
|
||||
var midX = (sx + tx) / 2;
|
||||
var path =
|
||||
$"M{F(sx)},{F(sy0)} C{F(midX)},{F(sy0)} {F(midX)},{F(ty0)} {F(tx)},{F(ty0)} " +
|
||||
$"L{F(tx)},{F(ty0 + band)} C{F(midX)},{F(ty0 + band)} {F(midX)},{F(sy0 + band)} {F(sx)},{F(sy0 + band)} Z";
|
||||
var tip = Enc($"{label.GetValueOrDefault(link.From)} → {label.GetValueOrDefault(link.To)}: {Fmt(link.Value)}");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<path d=\"{path}\" fill=\"{Enc(color.GetValueOrDefault(link.From, "#607D8B"))}\" fill-opacity=\"0.38\"><title>{tip}</title></path>");
|
||||
}
|
||||
|
||||
// Nodes + labels.
|
||||
foreach (var node in Nodes)
|
||||
{
|
||||
if (!geo.TryGetValue(node.Id, out var g))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var rightmost = node.Depth == maxDepth;
|
||||
var labelX = rightmost ? g.X - 6 : g.X + NodeWidth + 6;
|
||||
var anchor = rightmost ? "end" : "start";
|
||||
var fill = Enc(node.ColorHex ?? "#607D8B");
|
||||
var name = Enc(node.Label);
|
||||
var val = Enc(Fmt(node.Value));
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<rect x=\"{F(g.X)}\" y=\"{F(g.Y)}\" width=\"{F(NodeWidth)}\" height=\"{F(g.H)}\" rx=\"2\" fill=\"{fill}\"><title>{name}: {val}</title></rect>");
|
||||
sb.Append(CultureInfo.InvariantCulture,
|
||||
$"<text x=\"{F(labelX)}\" y=\"{F(g.Y + (g.H / 2))}\" text-anchor=\"{anchor}\" dominant-baseline=\"middle\" font-size=\"13\" fill=\"currentColor\">" +
|
||||
$"<tspan>{name}</tspan><tspan x=\"{F(labelX)}\" dy=\"15\" font-size=\"11\" fill-opacity=\"0.65\">{val}</tspan></text>");
|
||||
}
|
||||
|
||||
sb.Append("</svg>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string Fmt(double value) => $"{value.ToString("N0", CultureInfo.GetCultureInfo("de-DE"))} {Unit}".Trim();
|
||||
|
||||
private static string F(double value) => value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
private static string Enc(string value) => WebUtility.HtmlEncode(value);
|
||||
|
||||
private readonly record struct NodeGeo(double X, double Y, double H);
|
||||
}
|
||||
Reference in New Issue
Block a user