Skip to content

Commit cf61a58

Browse files
authored
Load plotly as a module on metrics page (microsoft#4857)
1 parent de35684 commit cf61a58

6 files changed

Lines changed: 293 additions & 257 deletions

File tree

src/Aspire.Dashboard/Components/App.razor

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,5 @@
2828
<Routes @rendermode="@(new InteractiveServerRenderMode(prerender: false))" />
2929
<script src="_framework/blazor.web.js"></script>
3030
<script src="js/app.js"></script>
31-
<script src="js/theme.js" type="module"></script>
32-
<script src="js/plotly-2.32.0.min.js"></script>
3331
</body>
3432
</html>

src/Aspire.Dashboard/Components/Controls/Chart/ChartBase.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ protected override void OnInitialized()
6767

6868
protected override async Task OnAfterRenderAsync(bool firstRender)
6969
{
70-
if (InstrumentViewModel.Instrument is null || InstrumentViewModel.MatchedDimensions is null)
70+
if (InstrumentViewModel.Instrument is null || InstrumentViewModel.MatchedDimensions is null || !ReadyForData())
7171
{
7272
return;
7373
}
@@ -521,4 +521,6 @@ private string GetDisplayedUnit(OtlpInstrument instrument)
521521
}
522522

523523
protected abstract Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime);
524+
525+
protected virtual bool ReadyForData() => true;
524526
}

src/Aspire.Dashboard/Components/Controls/Chart/PlotlyChart.razor.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System.Diagnostics;
45
using System.Globalization;
56
using System.Web;
67
using Aspire.Dashboard.Components.Controls.Chart;
@@ -16,7 +17,7 @@
1617

1718
namespace Aspire.Dashboard.Components;
1819

19-
public partial class PlotlyChart : ChartBase, IDisposable
20+
public partial class PlotlyChart : ChartBase, IAsyncDisposable
2021
{
2122
[Inject]
2223
public required IJSRuntime JS { get; init; }
@@ -28,6 +29,7 @@ public partial class PlotlyChart : ChartBase, IDisposable
2829
public required IDialogService DialogService { get; init; }
2930

3031
private DotNetObjectReference<ChartInterop>? _chartInteropReference;
32+
private IJSObjectReference? _jsModule;
3133

3234
private string FormatTooltip(string title, double yValue, DateTimeOffset xValue)
3335
{
@@ -45,6 +47,8 @@ private string FormatTooltip(string title, double yValue, DateTimeOffset xValue)
4547

4648
protected override async Task OnChartUpdated(List<ChartTrace> traces, List<DateTimeOffset> xValues, List<ChartExemplar> exemplars, bool tickUpdate, DateTimeOffset inProgressDataTime)
4749
{
50+
Debug.Assert(_jsModule != null, "The module should be initialized before chart data is sent to control.");
51+
4852
var traceDtos = traces.Select(t => new PlotlyTrace
4953
{
5054
Name = t.Name,
@@ -71,7 +75,7 @@ protected override async Task OnChartUpdated(List<ChartTrace> traces, List<DateT
7175
_chartInteropReference?.Dispose();
7276
_chartInteropReference = DotNetObjectReference.Create(new ChartInterop(this));
7377

74-
await JS.InvokeVoidAsync("initializeChart",
78+
await _jsModule.InvokeVoidAsync("initializeChart",
7579
"plotly-chart-container",
7680
traceDtos,
7781
exemplarTraceDto,
@@ -82,7 +86,7 @@ await JS.InvokeVoidAsync("initializeChart",
8286
}
8387
else
8488
{
85-
await JS.InvokeVoidAsync("updateChart",
89+
await _jsModule.InvokeVoidAsync("updateChart",
8690
"plotly-chart-container",
8791
traceDtos,
8892
exemplarTraceDto,
@@ -154,13 +158,28 @@ private PlotlyTrace CalculateExemplarsTrace(List<DateTimeOffset> xValues, List<C
154158
return exemplarTraceDto;
155159
}
156160

157-
public void Dispose()
161+
protected override async Task OnAfterRenderAsync(bool firstRender)
162+
{
163+
if (firstRender)
164+
{
165+
_jsModule = await JS.InvokeAsync<IJSObjectReference>("import", "/js/app-metrics.js");
166+
}
167+
168+
await base.OnAfterRenderAsync(firstRender);
169+
}
170+
171+
// The first data is used to initialize the chart. The module needs to be ready first.
172+
protected override bool ReadyForData() => _jsModule != null;
173+
174+
public async ValueTask DisposeAsync()
158175
{
159176
if (_chartInteropReference != null)
160177
{
161178
_chartInteropReference.Value.Dispose();
162179
_chartInteropReference.Dispose();
163180
}
181+
182+
await JSInteropHelpers.SafeDisposeAsync(_jsModule);
164183
}
165184

166185
/// <summary>
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
import './plotly-2.32.0.min.js'
2+
3+
export function initializeChart(id, traces, exemplarTrace, rangeStartTime, rangeEndTime, serverLocale, chartInterop) {
4+
registerLocale(serverLocale);
5+
6+
var chartContainerDiv = document.getElementById(id);
7+
8+
// Reusing a div can create issues with chart lines appearing beyond the end range.
9+
// Workaround this issue by replacing the chart div. Ensures we start from a new state.
10+
var chartDiv = document.createElement("div");
11+
chartContainerDiv.replaceChildren(chartDiv);
12+
13+
var themeColors = getThemeColors();
14+
15+
var data = [];
16+
for (var i = 0; i < traces.length; i++) {
17+
var name = traces[i].name || "Value";
18+
var t = {
19+
x: traces[i].x,
20+
y: traces[i].y,
21+
name: name,
22+
text: traces[i].tooltips,
23+
hoverinfo: 'text',
24+
stackgroup: "one"
25+
};
26+
data.push(t);
27+
}
28+
29+
var points = {
30+
x: exemplarTrace.x,
31+
y: exemplarTrace.y,
32+
name: exemplarTrace.name,
33+
text: exemplarTrace.tooltips,
34+
hoverinfo: 'text',
35+
traceData: exemplarTrace.traceData,
36+
mode: 'markers',
37+
type: 'scatter',
38+
marker: {
39+
size: 16,
40+
color: themeColors.pointColor,
41+
line: {
42+
color: themeColors.backgroundColor,
43+
width: 1
44+
}
45+
}
46+
};
47+
data.push(points);
48+
49+
// Explicitly set the width and height based on the container div.
50+
// If there is no explicit width and height, Plotly will use the rendered container size.
51+
// However, if the container isn't visible then it uses a default size.
52+
// Being explicit ensures the chart is always the correct size.
53+
var width = parseInt(chartContainerDiv.style.width);
54+
var height = parseInt(chartContainerDiv.style.height);
55+
56+
var layout = {
57+
width: width,
58+
height: height,
59+
paper_bgcolor: themeColors.backgroundColor,
60+
plot_bgcolor: themeColors.backgroundColor,
61+
margin: { t: 0, r: 0, b: 40, l: 50 },
62+
xaxis: {
63+
type: 'date',
64+
range: [rangeEndTime, rangeStartTime],
65+
fixedrange: true,
66+
tickformat: "%X",
67+
color: themeColors.textColor
68+
},
69+
yaxis: {
70+
rangemode: "tozero",
71+
fixedrange: true,
72+
color: themeColors.textColor
73+
},
74+
hovermode: "closest",
75+
showlegend: true,
76+
legend: {
77+
orientation: "h",
78+
font: {
79+
color: themeColors.textColor
80+
},
81+
traceorder: "normal",
82+
itemclick: false,
83+
itemdoubleclick: false
84+
}
85+
};
86+
87+
var options = { scrollZoom: false, displayModeBar: false };
88+
89+
Plotly.newPlot(chartDiv, data, layout, options);
90+
91+
fixTraceLineRendering(chartDiv);
92+
93+
// We only want a pointer cursor when the mouse is hovering over an exemplar point.
94+
// Set the drag layer cursor back to the default and then use plotly_hover/ploty_unhover events to set to pointer.
95+
var dragLayer = document.getElementsByClassName('nsewdrag')[0];
96+
dragLayer.style.cursor = 'default';
97+
98+
// Use mousedown instead of plotly_click event because plotly_click has issues with updating charts.
99+
// The current point is tracked by setting it with hover/unhover events and then mousedown uses the current value.
100+
var currentPoint = null;
101+
chartDiv.on('plotly_hover', function (data) {
102+
var point = data.points[0];
103+
if (point.fullData.name == exemplarTrace.name) {
104+
currentPoint = point;
105+
var pointTraceData = point.data.traceData[point.pointIndex];
106+
dragLayer.style.cursor = 'pointer';
107+
}
108+
});
109+
chartDiv.on('plotly_unhover', function (data) {
110+
var point = data.points[0];
111+
if (point.fullData.name == exemplarTrace.name) {
112+
currentPoint = null;
113+
dragLayer.style.cursor = 'default';
114+
}
115+
});
116+
chartDiv.addEventListener("mousedown", (e) => {
117+
if (currentPoint) {
118+
var point = currentPoint;
119+
var pointTraceData = point.data.traceData[point.pointIndex];
120+
121+
chartInterop.invokeMethodAsync('ViewSpan', pointTraceData.traceId, pointTraceData.spanId);
122+
}
123+
});
124+
}
125+
126+
export function updateChart(id, traces, exemplarTrace, rangeStartTime, rangeEndTime) {
127+
var chartContainerDiv = document.getElementById(id);
128+
var chartDiv = chartContainerDiv.firstChild;
129+
130+
var themeColors = getThemeColors();
131+
132+
var xUpdate = [];
133+
var yUpdate = [];
134+
var tooltipsUpdate = [];
135+
var traceData = [];
136+
for (var i = 0; i < traces.length; i++) {
137+
xUpdate.push(traces[i].x);
138+
yUpdate.push(traces[i].y);
139+
tooltipsUpdate.push(traces[i].tooltips);
140+
traceData.push(traces.traceData);
141+
}
142+
143+
xUpdate.push(exemplarTrace.x);
144+
yUpdate.push(exemplarTrace.y);
145+
tooltipsUpdate.push(exemplarTrace.tooltips);
146+
traceData.push(exemplarTrace.traceData);
147+
148+
var data = {
149+
x: xUpdate,
150+
y: yUpdate,
151+
text: tooltipsUpdate,
152+
traceData: traceData
153+
};
154+
155+
var layout = {
156+
xaxis: {
157+
type: 'date',
158+
range: [rangeEndTime, rangeStartTime],
159+
fixedrange: true,
160+
tickformat: "%X",
161+
color: themeColors.textColor
162+
}
163+
};
164+
165+
Plotly.update(chartDiv, data, layout);
166+
167+
fixTraceLineRendering(chartDiv);
168+
}
169+
170+
function getThemeColors() {
171+
// Get colors from the current light/dark theme.
172+
var style = getComputedStyle(document.body);
173+
return {
174+
backgroundColor: style.getPropertyValue("--fill-color"),
175+
textColor: style.getPropertyValue("--neutral-foreground-rest"),
176+
pointColor: style.getPropertyValue("--accent-fill-rest")
177+
};
178+
}
179+
180+
function fixTraceLineRendering(chartDiv) {
181+
// In stack area charts Plotly orders traces so the top line area overwrites the line of areas below it.
182+
// This isn't the effect we want. When the P50, P90 and P99 values are the same, the line displayed is P99
183+
// on the P50 area.
184+
//
185+
// The fix is to reverse the order of traces so the correct line is on top. There isn't a way to do this
186+
// with CSS because SVG doesn't support z-index. Node order is what determines the rendering order.
187+
//
188+
// https://github.com/plotly/plotly.js/issues/6579
189+
var parent = chartDiv.querySelector(".scatterlayer");
190+
191+
if (parent.childNodes.length > 0) {
192+
for (var i = 1; i < parent.childNodes.length; i++) {
193+
var child = parent.childNodes[i];
194+
parent.insertBefore(child, parent.firstChild);
195+
}
196+
197+
// Check if there is a trace with points. It should be top most.
198+
for (var i = 0; i < parent.childNodes.length; i++) {
199+
var child = parent.childNodes[i];
200+
if (child.querySelector(".point")) {
201+
// Append trace to parent to move to the last in the collection.
202+
parent.appendChild(child);
203+
}
204+
}
205+
}
206+
}
207+
208+
function registerLocale(serverLocale) {
209+
// Register the locale for Plotly.js. This is to enable localization of time format shown by the charts.
210+
// Changing plotly.js time formatting is better than supplying values from the server which is very difficult to do correctly.
211+
212+
// Right now necessary changes are to:
213+
// -Update AM/PM
214+
// -Update time format to 12/24 hour.
215+
var locale = {
216+
moduleType: 'locale',
217+
name: 'en',
218+
dictionary: {
219+
'Click to enter Colorscale title': 'Click to enter Colourscale title'
220+
},
221+
format: {
222+
days: ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'],
223+
shortDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
224+
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
225+
shortMonths: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
226+
periods: serverLocale.periods,
227+
dateTime: '%a %b %e %X %Y',
228+
date: '%d/%m/%Y',
229+
time: serverLocale.time,
230+
decimal: '.',
231+
thousands: ',',
232+
grouping: [3],
233+
currency: ['$', ''],
234+
year: '%Y',
235+
month: '%b %Y',
236+
dayMonth: '%b %-d',
237+
dayMonthYear: '%b %-d, %Y'
238+
}
239+
};
240+
Plotly.register(locale);
241+
}

0 commit comments

Comments
 (0)