Skip to content

Commit 8ac9e36

Browse files
authored
Telemetry Table & Plot (#20)
* Telemetry Table & Plot * Split table and plot views * Use uPlot chart from Grafana * Improve plot rendering and refresh * Refactor plot data message and add interpolation mode * Add background color to the plot toolbar * Fix plot initial render size * Do not retain context but retain base state * Added a legend table * Remove useless files and cleanup warnings * Added tooltip * Allow zero values to be plotted * Added a screenshot feature
1 parent 54ca028 commit 8ac9e36

37 files changed

+3375
-223
lines changed

build.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,18 @@ const builds = {
216216
entryPoints: ['app/evrs/index.tsx']
217217
},
218218

219+
'telemetry-table': {
220+
...browserOptions(),
221+
outfile: 'out/telemetry-table.js',
222+
entryPoints: ['app/telemetry/table.tsx']
223+
},
224+
225+
'telemetry-plot': {
226+
...browserOptions(),
227+
outfile: 'out/telemetry-plot.js',
228+
entryPoints: ['app/telemetry/plot.tsx']
229+
},
230+
219231
'connections': {
220232
...browserOptions(),
221233
outfile: 'out/connections.js',

src/extensions/core/app/evrs/SourceFilterMultiSelect.ts renamed to src/extensions/core/app/common/AnnotatedMultiSelect.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,16 @@ const __decorate = function (decorators: any[], target: any) {
1313
return r;
1414
};
1515

16-
class SourceFilterMultiSelect extends VscodeMultiSelect {
16+
class AnnotatedMultiSelect extends VscodeMultiSelect {
17+
multipleLabel: string = "Selected";
18+
19+
static get properties() {
20+
return {
21+
...super.properties,
22+
multipleLabel: { type: String }
23+
};
24+
}
25+
1726
constructor() {
1827
super();
1928

@@ -25,27 +34,27 @@ class SourceFilterMultiSelect extends VscodeMultiSelect {
2534
return html`<span class="select-face-badge">${this._opts.options[this._opts.selectedIndexes[0]].label}</span>`;
2635
default:
2736
return html`<span class="select-face-badge"
28-
>${this._opts.selectedIndexes.length} Sources</span
37+
>${this._opts.selectedIndexes.length} ${this.multipleLabel}</span
2938
>`;
3039
}
3140
};
3241
}
3342
}
3443

35-
(SourceFilterMultiSelect as any) = (__decorate as any)([
36-
customElement('source-filter-multi-select')
37-
], SourceFilterMultiSelect);
44+
(AnnotatedMultiSelect as any) = (__decorate as any)([
45+
customElement('annotated-multi-select')
46+
], AnnotatedMultiSelect);
3847

39-
const SourceFilterMultiSelectComp = createComponent({
40-
tagName: "source-filter-multi-select",
41-
elementClass: SourceFilterMultiSelect,
48+
const AnnotatedMultiSelectComp = createComponent({
49+
tagName: "annotated-multi-select",
50+
elementClass: AnnotatedMultiSelect,
4251
react: React,
43-
displayName: "SourceFilterMultiSelect",
52+
displayName: "AnnotatedMultiSelect",
4453
events: {
4554
onChange: "change",
4655
onInvalid: "invalid",
4756
onVscMultiSelectCreateOption: "vsc-multi-select-create-option",
4857
},
4958
});
5059

51-
export default SourceFilterMultiSelectComp;
60+
export default AnnotatedMultiSelectComp;

src/extensions/core/app/evrs/index.tsx

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
VscodeSingleSelect,
1313
VscodeOption,
1414
VscodeTextfield,
15-
VscodeMultiSelect,
1615
} from "@vscode-elements/react-elements";
1716

1817
import VscodeToolbarButton from "@vscode-elements/react-elements/dist/components/VscodeToolbarButton";
@@ -23,7 +22,7 @@ import { getMessages } from '@gov.nasa.jpl.hermes/vscode/browser';
2322
import type { BackendMessage, FrontendMessage } from '../../common/evrs';
2423

2524
import './style.css';
26-
import SourceFilterMultiSelect from './SourceFilterMultiSelect';
25+
import AnnotatedMultiSelect from '../common/AnnotatedMultiSelect';
2726

2827
const messages = getMessages<FrontendMessage, BackendMessage>();
2928

@@ -124,6 +123,19 @@ export function EvrTable() {
124123
});
125124
}, []);
126125

126+
// Resize the header table to match the virtualized table column widths
127+
if (tableBodyRef.current && tableHeaderRef.current) {
128+
resizeHeader();
129+
}
130+
131+
useEffect(() => {
132+
window.addEventListener('resize', resizeHeader);
133+
134+
return () => {
135+
window.removeEventListener('resize', resizeHeader);
136+
};
137+
}, []);
138+
127139
const virtualizer = useVirtualizer({
128140
count: filteredEvrs.length,
129141
getScrollElement: () => scrollRef.current,
@@ -165,11 +177,6 @@ export function EvrTable() {
165177
};
166178
}, [handleMessages]);
167179

168-
// Resize the header table to match the virtualized table column widths
169-
if (tableBodyRef.current && tableHeaderRef.current) {
170-
resizeHeader();
171-
}
172-
173180
if (autoscroll) {
174181
scrollToBottom();
175182
}
@@ -231,14 +238,6 @@ export function EvrTable() {
231238
});
232239
}, []);
233240

234-
useEffect(() => {
235-
window.addEventListener('resize', resizeHeader);
236-
237-
return () => {
238-
window.removeEventListener('resize', resizeHeader);
239-
};
240-
}, []);
241-
242241
useEffect(() => {
243242
// If '*' is select, auto-select the other sources
244243
if (filteredSources.includes("*")) {
@@ -328,7 +327,7 @@ export function EvrTable() {
328327
<div style={{
329328
display: 'flex',
330329
gap: '6px',
331-
margin: "0 6px"
330+
margin: "6px"
332331
}}>
333332
<VscodeSingleSelect
334333
style={{ width: "5em" }}
@@ -341,23 +340,25 @@ export function EvrTable() {
341340
<VscodeOption value={TimeFormat.LOCAL}>Local</VscodeOption>
342341
<VscodeOption value={TimeFormat.UTC}>UTC</VscodeOption>
343342
</VscodeSingleSelect>
344-
{sources.length > 1 && <SourceFilterMultiSelect
343+
{sources.length > 1 && <AnnotatedMultiSelect
344+
multipleLabel="Sources"
345345
style={{ width: "10em" }}
346346
value={filteredSources}
347347
onChange={onSourceFilterChanged}
348348
label='Source'
349349
>
350350
<VscodeOption value='*'>All</VscodeOption>
351351
{sources?.map((src) => <VscodeOption key={src}>{src}</VscodeOption>)}
352-
</SourceFilterMultiSelect>}
353-
<VscodeMultiSelect
352+
</AnnotatedMultiSelect>}
353+
<AnnotatedMultiSelect
354+
multipleLabel="Severities"
354355
style={{ width: "10em" }}
355356
value={filteredSeverities}
356357
onChange={onSeverityFilterChanged}
357358
>
358359
<VscodeOption value='*'>All</VscodeOption>
359360
{severities?.map((src) => <VscodeOption key={src}>{src}</VscodeOption>)}
360-
</VscodeMultiSelect>
361+
</AnnotatedMultiSelect>
361362
<div style={{ flexGrow: 1, display: "flex" }}>
362363
<VscodeTextfield
363364
placeholder='Filter Events'
@@ -400,6 +401,7 @@ export function EvrTable() {
400401
>
401402
<td className='idx'>{evr.index + 1}</td>
402403
<td>{formatTime(evr, timeFormat)}</td>
404+
{sources.length > 1 && <td>{evr.source}</td>}
403405
<td>{evr.severity}</td>
404406
<td>{evr.component}</td>
405407
<td>{evr.name}</td>
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React, { useRef, useLayoutEffect, useState, useMemo } from 'react';
2+
3+
/**
4+
* Calculate tooltip position ensuring it stays within the window bounds.
5+
* Default position is to the right and below the cursor.
6+
*/
7+
export function calculateTooltipPosition(
8+
xPos: number,
9+
yPos: number,
10+
tooltipWidth: number,
11+
tooltipHeight: number,
12+
xOffset: number,
13+
yOffset: number,
14+
windowWidth: number,
15+
windowHeight: number
16+
): { x: number; y: number } {
17+
let x = xPos;
18+
let y = yPos;
19+
20+
const overflowRight = Math.max(xPos + xOffset + tooltipWidth - (windowWidth - xOffset), 0);
21+
const overflowLeft = Math.abs(Math.min(xPos - xOffset - tooltipWidth - xOffset, 0));
22+
const wouldOverflowRight = overflowRight > 0;
23+
const wouldOverflowLeft = overflowLeft > 0;
24+
25+
const overflowBelow = Math.max(yPos + yOffset + tooltipHeight - (windowHeight - yOffset), 0);
26+
const overflowAbove = Math.abs(Math.min(yPos - yOffset - tooltipHeight - yOffset, 0));
27+
const wouldOverflowBelow = overflowBelow > 0;
28+
const wouldOverflowAbove = overflowAbove > 0;
29+
30+
if (wouldOverflowRight && wouldOverflowLeft) {
31+
x = overflowRight > overflowLeft ? xOffset : windowWidth - xOffset - tooltipWidth;
32+
} else if (wouldOverflowRight) {
33+
x = xPos - xOffset - tooltipWidth;
34+
} else {
35+
x = xPos + xOffset;
36+
}
37+
38+
if (wouldOverflowBelow && wouldOverflowAbove) {
39+
y = overflowBelow > overflowAbove ? yOffset : windowHeight - yOffset - tooltipHeight;
40+
} else if (wouldOverflowBelow) {
41+
y = yPos - yOffset - tooltipHeight;
42+
} else {
43+
y = yPos + yOffset;
44+
}
45+
return { x, y };
46+
}
47+
48+
interface PlotTooltipContainerProps {
49+
position: { x: number; y: number };
50+
offset: { x: number; y: number };
51+
children: React.ReactNode;
52+
}
53+
54+
/**
55+
* Container component that positions the tooltip and handles boundary detection
56+
*/
57+
export function PlotTooltipContainer({ position, offset, children }: PlotTooltipContainerProps) {
58+
const tooltipRef = useRef<HTMLDivElement>(null);
59+
const [tooltipSize, setTooltipSize] = useState({ width: 0, height: 0 });
60+
const [placement, setPlacement] = useState({
61+
x: position.x + offset.x,
62+
y: position.y + offset.y,
63+
});
64+
65+
// Measure tooltip size
66+
const resizeObserver = useMemo(
67+
() =>
68+
new ResizeObserver((entries) => {
69+
for (const entry of entries) {
70+
const width = Math.floor(entry.contentRect.width + 2 * 8); // padding
71+
const height = Math.floor(entry.contentRect.height + 2 * 8);
72+
if (tooltipSize.width !== width || tooltipSize.height !== height) {
73+
setTooltipSize({ width, height });
74+
}
75+
}
76+
}),
77+
[tooltipSize]
78+
);
79+
80+
useLayoutEffect(() => {
81+
if (tooltipRef.current) {
82+
resizeObserver.observe(tooltipRef.current);
83+
}
84+
return () => {
85+
resizeObserver.disconnect();
86+
};
87+
}, [resizeObserver]);
88+
89+
// Calculate position to keep tooltip within window bounds
90+
useLayoutEffect(() => {
91+
if (tooltipRef.current && tooltipSize.width > 0 && tooltipSize.height > 0) {
92+
const { x, y } = calculateTooltipPosition(
93+
position.x,
94+
position.y,
95+
tooltipSize.width,
96+
tooltipSize.height,
97+
offset.x,
98+
offset.y,
99+
window.innerWidth,
100+
window.innerHeight
101+
);
102+
103+
setPlacement({ x, y });
104+
}
105+
}, [position.x, position.y, offset.x, offset.y, tooltipSize]);
106+
107+
return (
108+
<div
109+
ref={tooltipRef}
110+
className="plot-tooltip-container"
111+
style={{
112+
position: 'fixed',
113+
left: 0,
114+
top: 0,
115+
transform: `translate(${placement.x}px, ${placement.y}px)`,
116+
pointerEvents: 'none',
117+
zIndex: 1000,
118+
}}
119+
>
120+
{children}
121+
</div>
122+
);
123+
}
124+
125+
interface SeriesValueProps {
126+
color: string;
127+
label: string;
128+
value: string;
129+
}
130+
131+
function SeriesValue({ color, label, value }: SeriesValueProps) {
132+
return (
133+
<div className="tooltip-series-row">
134+
<div className="tooltip-series-color" style={{ backgroundColor: color }} />
135+
<div className="tooltip-series-label">{label}</div>
136+
<div className="tooltip-series-value">{value}</div>
137+
</div>
138+
);
139+
}
140+
141+
interface PlotTooltipContentProps {
142+
timestamp: string;
143+
series: SeriesValueProps[];
144+
}
145+
146+
/**
147+
* Displays tooltip content with timestamp and series values
148+
*/
149+
export function PlotTooltipContent({ timestamp, series }: PlotTooltipContentProps) {
150+
return (
151+
<div className="plot-tooltip-content">
152+
<div className="tooltip-timestamp">{timestamp}</div>
153+
<div className="tooltip-series-list">
154+
{series.map((s, i) => (
155+
<SeriesValue key={i} color={s.color} label={s.label} value={s.value} />
156+
))}
157+
</div>
158+
</div>
159+
);
160+
}
161+
162+
interface PlotTooltipProps {
163+
position: { x: number; y: number };
164+
offset: { x: number; y: number };
165+
content: React.ReactNode;
166+
}
167+
168+
/**
169+
* Main tooltip component that renders in a portal
170+
*/
171+
export function PlotTooltip({ position, offset, content }: PlotTooltipProps) {
172+
return (
173+
<div className="plot-tooltip-portal">
174+
<PlotTooltipContainer position={position} offset={offset}>
175+
{content}
176+
</PlotTooltipContainer>
177+
</div>
178+
);
179+
}

0 commit comments

Comments
 (0)