diff --git a/local-libs/traceviewer-libs/base/package.json b/local-libs/traceviewer-libs/base/package.json index 275d1e9c..97c31b99 100644 --- a/local-libs/traceviewer-libs/base/package.json +++ b/local-libs/traceviewer-libs/base/package.json @@ -16,7 +16,7 @@ "src" ], "dependencies": { - "tsp-typescript-client": "^0.7.0" + "tsp-typescript-client": "^0.8.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "^6.0.0", diff --git a/local-libs/traceviewer-libs/react-components/package.json b/local-libs/traceviewer-libs/react-components/package.json index 66085ab8..cae2c4b9 100644 --- a/local-libs/traceviewer-libs/react-components/package.json +++ b/local-libs/traceviewer-libs/react-components/package.json @@ -36,7 +36,7 @@ "react-virtualized": "^9.21.0", "timeline-chart": "^0.4.2", "traceviewer-base": "0.9.1", - "tsp-typescript-client": "^0.7.0" + "tsp-typescript-client": "^0.8.0" }, "devDependencies": { "@testing-library/react": "^15.0.6", diff --git a/local-libs/traceviewer-libs/react-components/src/components/abstract-tree-output-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/abstract-tree-output-component.tsx index 6edff056..9deb0ea0 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/abstract-tree-output-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/abstract-tree-output-component.tsx @@ -130,7 +130,8 @@ export abstract class AbstractTreeOutputComponent< public getTreeWidth(): number { // Make tree thinner when chart has a y-axis - const yAxisWidth = this.props.outputDescriptor.type === 'TREE_TIME_XY' ? this.getYAxisWidth() : 0; + const type = this.props.outputDescriptor.type; + const yAxisWidth = type === 'TREE_TIME_XY' || type === 'TREE_GENERIC_XY' ? this.getYAxisWidth() : 0; return Math.max(0, this.props.style.chartOffset - this.getHandleWidth() - yAxisWidth - this.getSashWidth()); } diff --git a/local-libs/traceviewer-libs/react-components/src/components/abstract-xy-output-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/abstract-xy-output-component.tsx index 5d0895a2..b98e13be 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/abstract-xy-output-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/abstract-xy-output-component.tsx @@ -5,15 +5,11 @@ import { ResponseStatus } from 'tsp-typescript-client/lib/models/response/respon import { QueryHelper } from 'tsp-typescript-client/lib/models/query/query-helper'; import { Entry } from 'tsp-typescript-client/lib/models/entry'; import ColumnHeader from './utils/filter-tree/column-header'; -import { scaleLinear } from 'd3-scale'; -import { axisLeft } from 'd3-axis'; -import { select } from 'd3-selection'; import { EntryTree } from './utils/filter-tree/entry-tree'; -import { XyEntry, XYSeries } from 'tsp-typescript-client/lib/models/xy'; +import { XYSeries } from 'tsp-typescript-client/lib/models/xy'; import * as React from 'react'; import { flushSync } from 'react-dom'; import { TimeRange } from 'traceviewer-base/lib/utils/time-range'; -import { BIMath } from 'timeline-chart/lib/bigint-utils'; import { XYChartFactoryParams, xyChartFactory, @@ -24,7 +20,17 @@ import { ChartOptions } from 'chart.js'; import { Line, Scatter } from 'react-chartjs-2'; import { debounce } from 'lodash'; import { isEqual } from 'lodash'; -import { getCollapsedNodesFromAutoExpandLevel, listToTree } from './utils/filter-tree/utils'; +import { + applyYAxis, + buildTreeStateFromModel, + ColorAllocator, + computeYRange, + getTimeForX as timeForX, + getXForTime as xForTime, + zoomRange, + panRange, + setSpinnerVisible +} from './utils/xy-shared'; export const ZOOM_IN_RATE = 0.8; export const ZOOM_OUT_RATE = 1.25; @@ -149,6 +155,8 @@ export abstract class AbstractXYOutputComponent< private _debouncedUpdateXY = debounce(() => this.updateXY(), 500); + private colors = new ColorAllocator(); + constructor(props: P) { super(props); @@ -294,28 +302,15 @@ export abstract class AbstractXYOutputComponent< const treeResponse = tspClientResponse.getModel(); if (tspClientResponse.isOk() && treeResponse) { if (treeResponse.model) { - const headers = treeResponse.model.headers; - const columns = []; - if (headers && headers.length > 0) { - headers.forEach(header => { - columns.push({ title: header.name, sortable: true, resizable: true, tooltip: header.tooltip }); - }); - } else { - columns.push({ title: 'Name', sortable: true }); - } - const checkedSeries = this.getAllCheckedIds(treeResponse.model.entries); - const autoCollapsedNodes = getCollapsedNodesFromAutoExpandLevel( - listToTree(treeResponse.model.entries, columns), - treeResponse.model.autoExpandLevel - ); + const built = buildTreeStateFromModel(treeResponse.model as any); this.setState( { outputStatus: treeResponse.status, - xyTree: treeResponse.model.entries, - defaultOrderedIds: treeResponse.model.entries.map(entry => entry.id), - collapsedNodes: autoCollapsedNodes, - checkedSeries, - columns + xyTree: built.xyTree, + defaultOrderedIds: built.defaultOrderedIds, + collapsedNodes: built.collapsedNodes, + checkedSeries: built.checkedSeries, + columns: built.columns as any }, () => { this.updateXY(); @@ -336,16 +331,6 @@ export abstract class AbstractXYOutputComponent< return ResponseStatus.FAILED; } - getAllCheckedIds(entries: Array): Array { - const checkedSeries: number[] = []; - for (const entry of entries) { - if (entry.isDefault) { - checkedSeries.push(entry.id); - } - } - return checkedSeries; - } - renderTree(): React.ReactNode | undefined { this.onToggleCheck = this.onToggleCheck.bind(this); this.onToggleCollapse = this.onToggleCollapse.bind(this); @@ -371,35 +356,17 @@ export abstract class AbstractXYOutputComponent< renderYAxis(): React.ReactNode { // Y axis with D3 const chartHeight = parseInt(this.props.style.height.toString()); - - const yScale = scaleLinear() - .domain([this.state.allMin, Math.max(this.state.allMax, 1)]) - .range([chartHeight - this.margin.bottom, this.margin.top]); + applyYAxis( + this.yAxisRef, + chartHeight, + this.margin.top, + this.margin.bottom, + this.state.allMin, + this.state.allMax + ); const yTransform = `translate(${this.margin.left}, 0)`; - // Abbreviate large numbers - const scaleYLabel = (d: number) => - d >= 1000000000000 - ? Math.round(d / 100000000000) / 10 + 'G' - : d >= 1000000000 - ? Math.round(d / 100000000) / 10 + 'B' - : d >= 1000000 - ? Math.round(d / 100000) / 10 + 'M' - : d >= 1000 - ? Math.round(d / 100) / 10 + 'K' - : Math.round(d * 10) / 10; - - if (this.state.allMax > 0) { - select(this.yAxisRef.current) - .call(axisLeft(yScale).tickSizeOuter(0).ticks(4)) - .call(g => g.select('.domain').remove()); - select(this.yAxisRef.current) - .selectAll('.tick text') - .style('font-size', '11px') - .text((d: any) => scaleYLabel(d)); - } - return ( @@ -492,12 +459,7 @@ export abstract class AbstractXYOutputComponent< } private viewSpinner(status: boolean): void { - if (document.getElementById(this.getOutputComponentDomId() + 'handleSpinner')) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - document.getElementById(this.getOutputComponentDomId() + 'handleSpinner')!.style.visibility = status - ? 'visible' - : 'hidden'; - } + setSpinnerVisible(this.getOutputComponentDomId(), status); } private buildScatterData(seriesObj: XYSeries[]) { @@ -506,7 +468,7 @@ export abstract class AbstractXYOutputComponent< const offset = this.props.viewRange.getOffset() ?? BigInt(0); seriesObj.forEach(series => { const color = this.getSeriesColor(series.seriesName); - xValues = series.xValues; + xValues = series.xValues as bigint[]; const yValues: number[] = series.yValues; let pairs: xyPair[] = []; @@ -546,7 +508,7 @@ export abstract class AbstractXYOutputComponent< let xValues: bigint[] = []; seriesObj.forEach(series => { const color = this.getSeriesColor(series.seriesName); - xValues = series.xValues; + xValues = series.xValues as bigint[]; dataSetArray.push({ label: series.seriesName, fill: false, @@ -570,117 +532,48 @@ export abstract class AbstractXYOutputComponent< } private getSeriesColor(key: string): string { - const colors = [ - 'rgba(191, 33, 30, 1)', - 'rgba(30, 56, 136, 1)', - 'rgba(71, 168, 189, 1)', - 'rgba(245, 230, 99, 1)', - 'rgba(255, 173, 105, 1)', - 'rgba(216, 219, 226, 1)', - 'rgba(212, 81, 19, 1)', - 'rgba(187, 155, 176 , 1)', - 'rgba(6, 214, 160, 1)', - 'rgba(239, 71, 111, 1)' - ]; - let colorIndex = this.colorMap.get(key); - if (colorIndex === undefined) { - colorIndex = this.currentColorIndex % colors.length; - this.colorMap.set(key, colorIndex); - this.currentColorIndex++; - } - return colors[colorIndex]; + return this.colors.get(key); } private calculateYRange() { - let localMax = 0; - let localMin = 0; - - if (this.state && this.state.xyData) { - this.state.xyData?.datasets?.forEach((dSet: any, i: number) => { - let rowMax; - let rowMin; - if (this.isScatterPlot) { - rowMax = Math.max(...dSet.data.map((d: any) => d.y)); - rowMin = Math.min(...dSet.data.map((d: any) => d.y)); - } else { - rowMax = Math.max(...dSet.data); - rowMin = Math.min(...dSet.data); - } - localMax = Math.max(localMax, rowMax); - localMin = i === 0 ? rowMin : Math.min(localMin, rowMin); - }); - } - + const { min, max } = computeYRange(this.state.xyData?.datasets as any, this.isScatterPlot); this.setState({ - allMax: localMax * 1.01, - allMin: localMin * 0.99 + allMax: max, + allMin: min }); } protected getTimeForX(x: number): bigint { - const range = this.getDisplayedRange(); - const offset = range.getOffset() ?? BigInt(0); - const duration = range.getDuration(); - const chartWidth = this.getChartWidth() === 0 ? 1 : this.getChartWidth(); - const time = range.getStart() - offset + BIMath.round((x / chartWidth) * Number(duration)); - return time; + return timeForX(this.getDisplayedRange(), this.getChartWidth() === 0 ? 1 : this.getChartWidth(), x); } protected getXForTime(time: bigint): number { - const range = this.getDisplayedRange(); - const start = range.getStart(); - const duration = range.getDuration(); - const chartWidth = this.getChartWidth() === 0 ? 1 : this.getChartWidth(); - const x = (Number(time - start) / Number(duration)) * chartWidth; - return x; + return xForTime(this.getDisplayedRange(), this.getChartWidth() === 0 ? 1 : this.getChartWidth(), time); } protected zoom(isZoomIn: boolean): void { if (this.props.unitController.viewRangeLength >= 1) { const zoomCenterTime = this.getZoomTime(); - const startDistance = zoomCenterTime - this.props.unitController.viewRange.start; - const zoomFactor = isZoomIn ? ZOOM_IN_RATE : ZOOM_OUT_RATE; - const newDuration = BIMath.clamp( - Number(this.props.unitController.viewRangeLength) * zoomFactor, - BigInt(2), - this.props.unitController.absoluteRange - ); - const newStartRange = BIMath.max(0, zoomCenterTime - BIMath.round(Number(startDistance) * zoomFactor)); - const newEndRange = newStartRange + newDuration; - this.updateRange(newStartRange, newEndRange); + const view = this.props.unitController.viewRange; + const abs = this.props.unitController.absoluteRange; + const newRange = zoomRange(view, abs, zoomCenterTime, isZoomIn, ZOOM_IN_RATE, ZOOM_OUT_RATE); + this.props.unitController.viewRange = newRange; } } protected pan(panLeft: boolean): void { - const panFactor = 0.1; - const percentRange = BIMath.round(Number(this.props.unitController.viewRangeLength) * panFactor); - const panNumber = panLeft ? BigInt(-1) : BigInt(1); - const startRange = this.props.unitController.viewRange.start + panNumber * percentRange; - const endRange = this.props.unitController.viewRange.end + panNumber * percentRange; - if (startRange < 0) { - this.props.unitController.viewRange = { - start: BigInt(0), - end: this.props.unitController.viewRangeLength - }; - } else if (endRange > this.props.unitController.absoluteRange) { - this.props.unitController.viewRange = { - start: this.props.unitController.absoluteRange - this.props.unitController.viewRangeLength, - end: this.props.unitController.absoluteRange - }; - } else { - this.props.unitController.viewRange = { - start: startRange, - end: endRange - }; - } + const view = this.props.unitController.viewRange; + const abs = this.props.unitController.absoluteRange; + const newRange = panRange(view, abs, panLeft); + this.props.unitController.viewRange = newRange; } protected tooltip(): void { const xPos = this.positionXMove; - const timeForX = this.getTimeForX(xPos); + const timeForXVal = this.getTimeForX(xPos); let timeLabel: string | undefined = timeForX.toString() + ' ns'; if (this.props.unitController.numberTranslator) { - timeLabel = this.props.unitController.numberTranslator(timeForX) + ' s'; + timeLabel = this.props.unitController.numberTranslator(timeForXVal) + ' s'; } const chartWidth = this.isBarPlot ? this.getChartWidth() : this.chartRef.current.chartInstance.width; const chartHeight = this.isBarPlot diff --git a/local-libs/traceviewer-libs/react-components/src/components/generic-xy-output-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/generic-xy-output-component.tsx new file mode 100644 index 00000000..7a0fa8f0 --- /dev/null +++ b/local-libs/traceviewer-libs/react-components/src/components/generic-xy-output-component.tsx @@ -0,0 +1,908 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import * as React from 'react'; +import { flushSync } from 'react-dom'; +import { Line, Scatter, Bar } from 'react-chartjs-2'; +import type { ChartOptions } from 'chart.js'; +import { ResponseStatus } from 'tsp-typescript-client/lib/models/response/responses'; +import { AbstractOutputProps } from './abstract-output-component'; +import { AbstractTreeOutputComponent, AbstractTreeOutputState } from './abstract-tree-output-component'; +import { Entry, QueryHelper, XYSeries } from 'tsp-typescript-client'; +import { validateNumArray } from './utils/filter-tree/utils'; +import { + isCategorySampling, + isRangeSampling, + isTimestampSampling, + Sampling +} from 'tsp-typescript-client/lib/models/sampling'; +import { signalManager } from 'traceviewer-base/lib/signals/signal-manager'; +import { EntryTree } from './utils/filter-tree/entry-tree'; +import { TimeRange } from 'traceviewer-base/src/utils/time-range'; +import { BIMath } from 'timeline-chart/lib/bigint-utils'; +import { debounce } from 'lodash'; + +import { + applyYAxis, + buildTreeStateFromModel, + ColorAllocator, + getTimeForX as timeForX, + zoomRange, + panRange, + setSpinnerVisible, + rowsToCsv, + computeYRange +} from './utils/xy-shared'; + +interface XYDataset { + id?: number | string; + label: string; + type: 'bar' | 'line' | 'scatter'; + data: number[]; + borderColor?: string; + backgroundColor?: string; + borderWidth?: number; + pointRadius?: number; + pointHoverRadius?: number; + pointHitRadius?: number; + barPercentage?: number; + categoryPercentage?: number; + yAxisID?: string; + stack?: string; + fill?: boolean; + showLine?: boolean; +} + +interface GenericXYData { + labels: string[]; // Note: Array is the same as string[] + labelIndices: number[]; + datasets: XYDataset[]; +} + +interface GenericXYState extends AbstractTreeOutputState { + outputStatus: ResponseStatus; + columns: Array<{ title: string; sortable?: boolean }>; + xyData: GenericXYData; + xyTree: Entry[]; + defaultOrderedIds: number[]; + checkedSeries: number[]; + collapsedNodes: number[]; + allMax: number; + allMin: number; + cursor: string; +} + +interface GenericXYProps extends AbstractOutputProps { + formatX?: (x: number | bigint | string) => string; + formatY?: (y: number) => string; + stacked?: boolean; +} + +enum ChartMode { + BAR = 'bar', + LINE = 'line', + SCATTER = 'scatter' +} + +/** + * Generic XY chart with possible time or categorical X-axis. + * - All chart types (Bar, Line, Scatter) are rendered using react-chartjs-2 for unified behavior. + * - One unified custom HTML tooltip used for all modes so UI matches. + */ +export class GenericXYOutputComponent extends AbstractTreeOutputComponent { + private readonly chartRef = React.createRef(); + private readonly yAxisRef: any; + private readonly divRef = React.createRef(); + + private readonly margin = { top: 15, right: 0, bottom: 6, left: this.getYAxisWidth() }; + + private mouseIsDown = false; + private isPanning = false; + private isSelecting = false; + private positionXMove = 0; + private startPositionMouseRightClick = BigInt(0); + + private resolution = 0; + private mousePanningStart = BigInt(0); + + private mode: ChartMode = ChartMode.BAR; + private isTimeAxis = false; + + private readonly ZOOM_IN_RATE = 0.8; + private readonly ZOOM_OUT_RATE = 1.25; + + // -1: None, 0: Left, 1: Middle, 2: Right + private clickedMouseButton = -1; + + private readonly colors = new ColorAllocator(); + + private readonly _debouncedUpdateXY = debounce(() => this.updateXY(), 500); + + constructor(props: GenericXYProps) { + super(props); + this.yAxisRef = React.createRef(); + this.state = { + outputStatus: ResponseStatus.RUNNING, + xyTree: [], + defaultOrderedIds: [], + checkedSeries: validateNumArray(this.props.persistChartState?.checkedSeries) + ? (this.props.persistChartState.checkedSeries as number[]) + : [], + collapsedNodes: validateNumArray(this.props.persistChartState?.collapsedNodes) + ? (this.props.persistChartState.collapsedNodes as number[]) + : [], + xyData: { labels: [], datasets: [], labelIndices: [] }, + columns: [{ title: 'Name', sortable: true }], + allMax: 0, + allMin: 0, + cursor: 'default', + showTree: true + }; + + this.addPinViewOptions(() => ({ + checkedSeries: this.state.checkedSeries, + collapsedNodes: this.state.collapsedNodes + })); + this.addOptions('Export table to CSV...', () => this.exportOutput()); + } + + private readonly onToggleCollapse = (id: number) => { + const collapsed = new Set(this.state.collapsedNodes); + if (collapsed.has(id)) collapsed.delete(id); + else collapsed.add(id); + this.setState({ collapsedNodes: Array.from(collapsed) }); + }; + + private readonly onOrderChange = (ids: number[]) => { + const ordered = this.state.xyTree.slice().sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); + this.setState({ xyTree: ordered }); + }; + + private readonly onOrderReset = () => { + this.onOrderChange(this.state.defaultOrderedIds); + }; + + private readonly onToggleCheck = (ids: number[]) => { + const checked = [...this.state.checkedSeries]; + ids.forEach(id => { + const i = checked.indexOf(id); + if (i >= 0) checked.splice(i, 1); + else checked.push(id); + }); + this.setState({ checkedSeries: checked }, () => { + if (this.getChartWidth() > 0) this._debouncedUpdateXY(); + }); + }; + + renderTree(): React.ReactNode | undefined { + return this.state.xyTree.length ? ( +
+ +
+ ) : undefined; + } + + renderYAxis(): React.ReactNode { + const chartHeight = parseInt(String(this.props.style.height), 10); + let yMin = this.state.allMin; + let yMax = this.state.allMax; + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { + yMin = 0; + yMax = 1; + } + + applyYAxis(this.yAxisRef, chartHeight, this.margin.top, this.margin.bottom, yMin, yMax); + + const yTransform = `translate(${this.margin.left},0)`; + return ( + + + + ); + } + + async fetchTree(): Promise { + setSpinnerVisible(this.getOutputComponentDomId(), true); + const parameters = QueryHelper.timeRangeQuery(this.props.range.getStart(), this.props.range.getEnd()); + const rsp = await this.props.tspClient.fetchGenericXYTree( + this.props.traceId, + this.props.outputDescriptor.id, + parameters + ); + const treeResponse = rsp.getModel(); + if (rsp.isOk() && treeResponse) { + if (treeResponse.model) { + const built = buildTreeStateFromModel(treeResponse.model); + this.setState( + { + outputStatus: treeResponse.status, + xyTree: built.xyTree, + defaultOrderedIds: built.defaultOrderedIds, + collapsedNodes: built.collapsedNodes, + checkedSeries: built.checkedSeries, + columns: built.columns as any + }, + () => { + this._debouncedUpdateXY(); + } + ); + } else { + this.setState({ outputStatus: treeResponse.status }); + } + setSpinnerVisible(this.getOutputComponentDomId(), false); + return treeResponse.status; + } + this.setState({ outputStatus: ResponseStatus.FAILED }); + setSpinnerVisible(this.getOutputComponentDomId(), false); + return ResponseStatus.FAILED; + } + + resultsAreEmpty(): boolean { + return this.state.xyTree.length === 0; + } + + private computeDesiredSampleNb(): number { + // TODO: Currently the number of samples is calculated only for bars. + // Modification is needed in the tsp to send display type in fetchTree + // endpoint, otherwise the display type is unknown here. + const plotW = Math.max(1, this.getChartWidth()); + const dpr = window.devicePixelRatio || 1; + const seriesPerGroup = Math.max(1, this.state.checkedSeries.length); + return this.computeDesiredSampleNbBar(plotW, dpr, seriesPerGroup); + } + + private computeDesiredSampleNbBar( + plotWidthCss: number, + dpr: number, + seriesPerGroup: number, + { minBarPx = 3, intraGapPx = 1, interGroupGapPx = 4, quantStep = 5, max = 50, min = 5 } = {} + ): number { + const px = Math.max(1, plotWidthCss) * Math.max(1, dpr || 1); + const perGroup = Math.max(1, seriesPerGroup); + const minGroupPx = Math.max(8, perGroup * minBarPx + (perGroup - 1) * intraGapPx + interGroupGapPx); + const rough = Math.floor(px / Math.max(1, minGroupPx)); + const quantize = (n: number) => Math.max(quantStep, Math.floor(n / quantStep) * quantStep); + return Math.max(min, Math.min(quantize(rough), max)); + } + + private async updateXY(): Promise { + if (!this.props.viewRange || this.props.viewRange.getEnd() <= this.props.viewRange.getStart()) { + return; + } + + let start = BigInt(0); + let end = BigInt(0); + if (this.props.viewRange) { + start = this.props.viewRange.getStart(); + end = this.props.viewRange.getEnd(); + } + + const params = QueryHelper.selectionTimeRangeQuery( + start, + end, + this.computeDesiredSampleNb(), + this.state.checkedSeries, + undefined, + true + ); + + const rsp = await this.props.tspClient.fetchGenericXY( + this.props.traceId, + this.props.outputDescriptor.id, + params + ); + const model = rsp.getModel(); + + if (!(rsp.isOk() && model?.model?.series)) { + this.setState({ outputStatus: ResponseStatus.FAILED }); + return; + } + + const series = model.model.series; + if (!series.length) { + flushSync(() => + this.setState({ + xyData: { labels: [], datasets: [], labelIndices: [] }, + outputStatus: model.status ?? ResponseStatus.COMPLETED + }) + ); + this.calculateYRange(); + return; + } + + const style = (series[0] as any)?.style?.values?.['series-type'] as string | undefined; + const st = style?.toLowerCase(); + const xv = series[0].xValues as Sampling; + this.isTimeAxis = isTimestampSampling(xv); + this.mode = st === 'scatter' ? ChartMode.SCATTER : st === 'line' ? ChartMode.LINE : ChartMode.BAR; + + const xy = this.buildXYData(series, this.mode); + flushSync(() => this.setState({ xyData: xy, outputStatus: model.status ?? ResponseStatus.COMPLETED })); + this.calculateYRange(); + } + + private buildXYData(seriesObj: XYSeries[], mode: ChartMode): GenericXYData { + if (!seriesObj.length) { + return { labels: [], labelIndices: [], datasets: [] }; + } + + if (mode === ChartMode.BAR) { + return this.buildBarChartData(seriesObj); + } else { + return this.buildLineOrScatterChartData(seriesObj, mode); + } + } + + private buildBarChartData(seriesObj: XYSeries[]): GenericXYData { + const xValues = seriesObj[0].xValues; + const unit = seriesObj[0]?.xValuesDescription?.unit || ''; + const labels = this.buildLabels(xValues, unit); + const datasets: GenericXYData['datasets'] = seriesObj.map(s => { + const color = this.colors.get(s.seriesName); + return { + label: s.seriesName, + type: 'bar', + data: s.yValues as number[], + backgroundColor: color, + borderColor: color + }; + }); + + const labelIndices = Array.from({ length: labels.length }, (_, i) => i); + return { labels, labelIndices, datasets }; + } + + private buildLineOrScatterChartData(seriesObj: XYSeries[], mode: ChartMode): GenericXYData { + const xValues = seriesObj[0].xValues; + const unit = seriesObj[0]?.xValuesDescription?.unit || ''; + const labels = this.buildLabels(xValues, unit); + + const datasets: GenericXYData['datasets'] = seriesObj.map(s => { + const color = this.colors.get(s.seriesName); + return { + label: s.seriesName, + // Use 'line' engine for both line and scatter-on-time/category + type: 'line', + data: s.yValues, + backgroundColor: color, + borderColor: color, + borderWidth: 2, + pointRadius: mode === ChartMode.SCATTER ? 3 : 0, + pointHoverRadius: mode === ChartMode.SCATTER ? 3 : 0, + showLine: mode === ChartMode.LINE, + fill: false + }; + }); + + const labelIndices = Array.from({ length: labels.length }, (_, i) => i); + return { labels, labelIndices, datasets }; + } + + private buildLabels(xValues: Sampling, unit: string): string[] { + if (isRangeSampling(xValues)) { + return xValues.map(range => `[${range[0]} ${unit}, ${range[1]} ${unit}]`); + } else if (isCategorySampling(xValues)) { + return xValues.map(val => `${val} ${unit}`); + } else if (isTimestampSampling(xValues)) { + const offset = this.props.viewRange.getOffset() ?? BigInt(0); + return xValues.map(val => { + const relativeTimeStamp = val - offset; + if (this.props.unitController.numberTranslator) { + return `${this.props.unitController.numberTranslator(relativeTimeStamp)} s`; + } + return String(val); + }); + } + return (xValues as any[]).map(v => String(v)); + } + + private exportOutput() { + const csv = rowsToCsv(this.state.columns as any, this.state.xyTree); + signalManager().emit('SAVE_AS_CSV', this.props.traceId, csv); + } + + private calculateYRange() { + const ds = this.state.xyData?.datasets ?? []; + if (!ds.length) { + this.setState({ allMin: 0, allMax: 1 }); + return; + } + const first = ds[0]?.data as any[]; + const isObjData = Array.isArray(first) && first.length > 0 && typeof first[0] === 'object'; + const { min, max } = computeYRange(ds as any, isObjData); + this.setState({ allMax: max, allMin: min }); + } + + componentDidMount(): void { + super.componentDidMount?.(); + this.waitAnalysisCompletion(); + } + + componentDidUpdate(prevProps: GenericXYProps, prevState: GenericXYState): void { + const sizeChanged = + prevProps.outputWidth !== this.props.outputWidth || + prevProps.style.height !== this.props.style.height || + prevProps.style.chartOffset !== this.props.style.chartOffset || + prevState.showTree !== this.state.showTree; + + const viewChanged = + prevProps.viewRange.getStart() !== this.props.viewRange.getStart() || + prevProps.viewRange.getEnd() !== this.props.viewRange.getEnd(); + + const checksChanged = prevState.checkedSeries !== this.state.checkedSeries; + + if (sizeChanged || viewChanged || checksChanged) { + if (this.getChartWidth() > 0) this._debouncedUpdateXY(); + } + } + + componentWillUnmount(): void { + super.componentWillUnmount(); + this._debouncedUpdateXY.cancel(); + } + + private computeAllYZeroForReact(): boolean { + const datasets = (this.state.xyData?.datasets ?? []) as any[]; + if (datasets.length === 0) return false; + return datasets.every(ds => { + const data = Array.isArray(ds.data) ? ds.data : []; + if (!data.length) return false; + const first = (data as any[])[0]; + const isPoint = typeof first === 'object' && first !== undefined && 'y' in first; + if (isPoint) return (data as any[]).every((pt: any) => Number(pt?.y) === 0); + return (data as any[]).every((v: any) => Number(v) === 0); + }); + } + + private makeChartOptions(allYZero: boolean): ChartOptions { + const yTickFix = allYZero ? { min: 0, suggestedMax: 1, beginAtZero: true } : { beginAtZero: true }; + + return { + responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + left: 0, + right: 0, + top: this.margin.top, + bottom: this.margin.bottom + } + }, + legend: { display: false }, + tooltips: { enabled: false }, + hover: { mode: 'nearest', intersect: false }, + elements: { line: { fill: false }, point: { radius: 0, hoverRadius: 0, hitRadius: 0 } }, + plugins: { filler: { propagate: false } } as any, + scales: { + xAxes: [ + { + type: 'category', + position: 'bottom', + display: false, + gridLines: { display: false, drawBorder: false, drawTicks: false }, + ticks: { + display: false + } + } + ], + yAxes: [ + { + display: false, + gridLines: { display: false, drawBorder: false, drawTicks: false }, + ticks: yTickFix as any + } + ] + } as any + }; + } + + private chooseReactChart(): JSX.Element { + const allYZero = this.computeAllYZeroForReact(); + const options = this.makeChartOptions(allYZero); + + const data = { + labels: (this.state.xyData.labels ?? []).map(v => String(v)), + datasets: this.state.xyData.datasets + }; + + const chartProps = { + data: data, + height: parseInt(String(this.props.style.height)), + options: options, + ref: this.chartRef + }; + + switch (this.mode) { + case ChartMode.BAR: + return ; + case ChartMode.SCATTER: + return ; + case ChartMode.LINE: + default: + return ; + } + } + + private getPlotGeom(): { left: number; width: number } { + const inst = this.chartRef.current?.chartInstance; + const ca = inst?.chartArea; + + // If the chart instance or its chartArea isn't available, + // fall back to the full width of the component. + if (!ca) { + return { left: 0, width: Math.max(1, this.getChartWidth()) }; + } + + const left = ca.left ?? 0; + const right = ca.right ?? Math.max(1, this.getChartWidth()); + const width = Math.max(1, right - left); + return { left, width }; + } + + private endSelection = (_e: MouseEvent): void => { + if (this.clickedMouseButton === 2) { + // Right-click + if (this.isTimeAxis) { + // zooming is disabled for non-time x-axis + const newStart = this.startPositionMouseRightClick; + const newEnd = this.getTimeForX(this.positionXMove); + this.updateViewRange(newStart, newEnd); + } + } + this.mouseIsDown = false; + this.isSelecting = false; + this.isPanning = false; + this.clickedMouseButton = -1; // None + this.setState({ cursor: 'default' }); + document.removeEventListener('mouseup', this.endSelection); + }; + + private onMouseDown = (ev: React.MouseEvent): void => { + this.mouseIsDown = true; + this.clickedMouseButton = ev.button; + const startTime = this.getTimeForX(ev.nativeEvent.offsetX); + if (this.clickedMouseButton === 2) { + // Right-click + if (this.isTimeAxis) { + // zooming is disabled for non-time x-axis + this.isSelecting = false; + this.setState({ cursor: 'col-resize' }); + this.startPositionMouseRightClick = startTime; + } + } else { + if ( + (ev.ctrlKey && !ev.shiftKey) || + (!(ev.shiftKey && ev.ctrlKey) && this.clickedMouseButton === 1) // Middle-click + ) { + const chartWidth = this.getChartWidth(); + const viewRangeLength = Number(this.props.unitController.viewRangeLength); + + if (chartWidth > 0 && viewRangeLength > 0) { + this.resolution = chartWidth / viewRangeLength; + this.mousePanningStart = + this.props.unitController.viewRange.start + + BIMath.round(ev.nativeEvent.clientX / this.resolution); + } else { + this.resolution = 0; + } + this.isPanning = true; + this.setState({ cursor: 'grabbing' }); + } + // TODO: Left-click selection feature is not implemented yet. + this.onMouseMove(ev); + } + document.addEventListener('mouseup', this.endSelection); + }; + + private onMouseMove = (ev: React.MouseEvent): void => { + this.positionXMove = ev.nativeEvent.offsetX; + if (this.mouseIsDown) { + if (this.isPanning) this.panHorizontally(ev); + else if (this.isSelecting) this.updateSelection(); + else this.forceUpdate(); + } else { + this.tooltip(); + } + }; + + private onMouseLeave = (ev: React.MouseEvent): void => { + const chartWidth = this.getChartWidth(); + this.positionXMove = Math.max(0, Math.min(ev.nativeEvent.offsetX, chartWidth)); + this.forceUpdate(); + if (this.mouseIsDown && this.clickedMouseButton !== 2) { + // Not Right-click + this.updateSelection(); + } + this.closeTooltip?.(); + }; + + private onWheel = (wheel: React.WheelEvent): void => { + if (this.isTimeAxis) { + if (wheel.shiftKey) { + if (wheel.deltaY < 0) this.pan(true); + else if (wheel.deltaY > 0) this.pan(false); + } else if (wheel.ctrlKey) { + if (wheel.deltaY < 0) this.zoom(true); + else if (wheel.deltaY > 0) this.zoom(false); + } + } + }; + + private onKeyDown = (key: React.KeyboardEvent): void => { + if (this.isTimeAxis) { + switch (key.key) { + case 'W': + case 'w': + case 'I': + case 'i': + this.zoom(true); + break; + case 'S': + case 's': + case 'K': + case 'k': + this.zoom(false); + break; + case 'A': + case 'a': + case 'J': + case 'j': + case 'ArrowLeft': + this.pan(true); + break; + case 'D': + case 'd': + case 'L': + case 'l': + case 'ArrowRight': + this.pan(false); + break; + } + } + switch (key.key) { + case 'Control': + if (!this.isSelecting && !this.isPanning) { + this.setState({ cursor: key.shiftKey ? 'default' : 'grabbing' }); + } + break; + } + }; + + private onKeyUp = (key: React.KeyboardEvent): void => { + if (!this.isSelecting && !this.isPanning) { + let cur = this.state.cursor ?? 'default'; + if (key.key === 'Shift') { + cur = key.ctrlKey ? 'grabbing' : !this.mouseIsDown ? 'default' : cur; + } else if (key.key === 'Control') { + cur = key.shiftKey ? 'crosshair' : !this.mouseIsDown ? 'default' : cur; + } + this.setState({ cursor: cur }); + } + }; + + private panHorizontally(ev: React.MouseEvent) { + if (this.isTimeAxis && this.resolution > 0) { + const xNow = ev.nativeEvent.clientX; + const newStartFloat = Number(this.mousePanningStart) - xNow / this.resolution; + + const min = BigInt(0); + const max = this.props.unitController.absoluteRange - this.props.unitController.viewRangeLength; + const start = BIMath.clamp(newStartFloat, min, max); + const end = start + this.props.unitController.viewRangeLength; + this.props.unitController.viewRange = { start, end }; + } + } + + private updateSelection(): void { + if (this.props.unitController.selectionRange) { + const xStart = this.props.unitController.selectionRange.start; + this.props.unitController.selectionRange = { + start: xStart, + end: this.getTimeForX(this.positionXMove) + }; + } + } + + renderChart(): React.ReactNode { + const isEmpty = + this.state.outputStatus === ResponseStatus.COMPLETED && (this.state.xyData?.datasets?.length ?? 0) === 0; + + if (isEmpty) { + return
Select a checkbox to see analysis results
; + } + + return ( +
this.onKeyDown(e)} + onKeyUp={e => this.onKeyUp(e)} + onWheel={e => this.onWheel(e)} + onMouseMove={e => this.onMouseMove(e)} + onContextMenu={e => e.preventDefault()} + onMouseLeave={e => this.onMouseLeave(e)} + onMouseDown={e => this.onMouseDown(e)} + style={{ height: this.props.style.height, position: 'relative', cursor: this.state.cursor }} + ref={this.divRef} + > + {this.chooseReactChart()} + {this.state.outputStatus === ResponseStatus.RUNNING && ( +
+
+ Analysis running +
+
+ )} +
+ ); + } + + private tooltip(): void { + const { datasets, labels } = this.state.xyData; + if (!datasets?.length || !labels?.length) { + this.closeTooltip?.(); + return; + } + + interface Pt { + label: string; + color?: string; + background?: string; + value: string; + _num?: number; + } + const points: Pt[] = []; + const zerosRef = { count: 0 }; + + const { width, left } = this.getPlotGeom(); + const xPlot = Math.max(0, Math.min(this.positionXMove - left, width)); + + // Calculate the data index based on the mouse's relative position. + const bins = labels.length; + const binW = width / Math.max(1, bins); + const index = Math.max(0, Math.min(bins - 1, Math.floor(xPlot / binW))); + const rawLabel = labels[index]; + const title = this.props.formatX ? this.props.formatX(rawLabel) : String(rawLabel); + + datasets.forEach(ds => { + const yVal = (ds.data as number[])[index]; + this.addTooltipPoint(points, zerosRef, ds, yVal); + }); + + points.sort((a, b) => (b._num ?? 0) - (a._num ?? 0)); + if (points.length || zerosRef.count > 0) { + this.setTooltipContent?.(this.generateXYTooltip(title, points, zerosRef.count)); + } else { + this.closeTooltip?.(); + } + } + + /** + * A helper to create a formatted data point and add it to the tooltip list, + * or to increment the zero count if conditions are met. + */ + private addTooltipPoint( + points: Array<{ label: string; color?: string; background?: string; value: string; _num?: number }>, + zerosRef: { count: number }, + dataset: GenericXYData['datasets'][0], + yVal: number | undefined + ): void { + if (yVal === undefined || !Number.isFinite(yVal)) { + return; + } + + const rounded = Math.round(yVal * 100) / 100; + + // For charts with many series, don't show individual entries for zero values. + if ((this.state.xyData.datasets?.length || 0) > 10 && rounded === 0) { + zerosRef.count++; + return; + } + + const value = this.props.formatY ? this.props.formatY(rounded) : new Intl.NumberFormat().format(rounded); + points.push({ + label: dataset.label, + color: dataset.borderColor as string, + background: dataset.backgroundColor as string, + value, + _num: rounded + }); + } + + private generateXYTooltip = ( + title: string, + points: Array<{ label: string; color?: string; background?: string; value: string }>, + zeros: number + ) => ( + <> +

{title}

+
    + {points.map((p, i) => ( +
  • +
    + + {p.label} {p.value} + +
  • + ))} +
+ {zeros > 0 && ( +

+ {zeros} other{zeros > 1 ? 's' : ''}: 0 +

+ )} + + ); + + public setFocus(): void { + this.divRef.current?.focus(); + } + + protected getOutputComponentDomId(): string { + return this.props.traceId + this.props.outputDescriptor.id; + } + + private getDisplayedRange(): TimeRange { + return this.props.viewRange; + } + + private getTimeForX(x: number): bigint { + const chartWidth = this.getChartWidth(); + return timeForX(this.getDisplayedRange(), chartWidth === 0 ? 1 : chartWidth, x); + } + + private updateViewRange(start: bigint, end: bigint): void { + if (this.isTimeAxis) { + const [s, e] = start < end ? [start, end] : [end, start]; + this.props.unitController.viewRange = { start: s, end: e }; + } + } + + private getZoomTime(): bigint { + return this.getTimeForX(this.positionXMove); + } + + private zoom(isZoomIn: boolean): void { + if (this.isTimeAxis) { + if (this.props.unitController.viewRangeLength >= 1) { + const vr = this.props.unitController.viewRange; + const abs = this.props.unitController.absoluteRange; + const center = this.getZoomTime(); + this.props.unitController.viewRange = zoomRange( + vr, + abs, + center, + isZoomIn, + this.ZOOM_IN_RATE, + this.ZOOM_OUT_RATE + ); + } + } + } + + private pan(left: boolean): void { + if (this.isTimeAxis) { + const vr = this.props.unitController.viewRange; + const abs = this.props.unitController.absoluteRange; + this.props.unitController.viewRange = panRange(vr, abs, left); + } + } +} diff --git a/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx b/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx index 19d39d9d..13e1fd5e 100644 --- a/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx +++ b/local-libs/traceviewer-libs/react-components/src/components/trace-context-component.tsx @@ -28,6 +28,7 @@ import { UnitControllerHistoryHandler } from './utils/unit-controller-history-ha import { TraceOverviewComponent } from './trace-overview-component'; import { TimeRangeUpdatePayload } from 'traceviewer-base/lib/signals/time-range-data-signal-payloads'; import { GanttChartOutputComponent } from './gantt-chart-output-component'; +import { GenericXYOutputComponent } from './generic-xy-output-component'; const ResponsiveGridLayout = WidthProvider(Responsive); @@ -755,6 +756,18 @@ export class TraceContextComponent extends React.Component ); + case ProviderType.TREE_GENERIC_XY: + if (this.chartPersistedState && this.chartPersistedState.output.id === output.id) { + outputProps.persistChartState = this.chartPersistedState.payload; + this.chartPersistedState = undefined; + } + return ( + + ); case ProviderType.TABLE: return ( ({ + title: h.name, + sortable: true, + resizable: true, + tooltip: h.tooltip + })); + } + return [{ title: 'Name', sortable: true }]; +} + +function computeAutoCollapsedNodes( + entries: Entry[], + columns: ColumnSpec[], + autoExpandLevel: number | undefined +): number[] { + return getCollapsedNodesFromAutoExpandLevel(listToTree(entries, columns as any), autoExpandLevel); +} + +export function buildTreeStateFromModel(model: { + headers?: HeaderSpec[]; + entries: XyEntry[]; + autoExpandLevel?: number; + status?: any; +}): TreeState { + const columns = buildColumns(model.headers); + const checkedSeries = model.entries.filter(e => (e as any).isDefault).map(e => e.id); + const collapsedNodes = computeAutoCollapsedNodes(model.entries, columns, model.autoExpandLevel); + const defaultOrderedIds = model.entries.map(e => e.id); + return { columns, checkedSeries, collapsedNodes, defaultOrderedIds, xyTree: model.entries }; +} + +function csvEscape(cell: any): string { + const s = String(cell ?? ''); + if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} + +/** Build a CSV string from columns + tree rows (rows use .labels like in your components). */ +export function rowsToCsv(columns: ColumnSpec[], rows: Entry[]): string { + const header = columns.map(c => csvEscape(c.title)).join(','); + const body = rows.map(r => (r as any).labels?.map(csvEscape).join(',') ?? '').join('\n'); + return header + '\n' + body; +} + +/** Abbreviate large magnitudes: 1.2K, 3.4M, 5.6B, 7.8G */ +function abbrNumber(n: number): string { + const v = Number(n); + if (!Number.isFinite(v)) return String(n); + const A = Math.abs(v); + if (A >= 1e12) return (Math.round(v / 1e11) / 10).toString() + 'G'; + if (A >= 1e9) return (Math.round(v / 1e8) / 10).toString() + 'B'; + if (A >= 1e6) return (Math.round(v / 1e5) / 10).toString() + 'M'; + if (A >= 1e3) return (Math.round(v / 1e2) / 10).toString() + 'K'; + return (Math.round(v * 10) / 10).toString(); +} + +export function applyYAxis( + yAxisRef: React.RefObject, + chartHeight: number, + marginTop: number, + marginBottom: number, + min: number, + max: number +): void { + if (!yAxisRef.current) return; + const yScale = scaleLinear() + .domain([min, Math.max(max, 1)]) + .range([chartHeight - marginBottom, marginTop]); + const axis = axisLeft(yScale).tickSizeOuter(0).ticks(4); + select(yAxisRef.current) + .call(axis as any) + .call(g => g.select('.domain').remove()); + select(yAxisRef.current) + .selectAll('.tick text') + .style('font-size', '11px') + .text((d: any) => abbrNumber(d)); +} + +export class ColorAllocator { + private readonly map = new Map(); + private i = 0; + constructor( + private readonly palette: string[] = [ + 'rgba(191, 33, 30, 1)', + 'rgba(30, 56, 136, 1)', + 'rgba(71, 168, 189, 1)', + 'rgba(245, 230, 99, 1)', + 'rgba(255, 173, 105, 1)', + 'rgba(216, 219, 226, 1)', + 'rgba(212, 81, 19, 1)', + 'rgba(187, 155, 176, 1)', + 'rgba(6, 214, 160, 1)', + 'rgba(239, 71, 111, 1)' + ] + ) {} + get(key: string): string { + let idx = this.map.get(key); + if (idx === undefined) { + idx = this.i % this.palette.length; + this.map.set(key, idx); + this.i++; + } + return this.palette[idx]; + } +} + +type DatasetLike = { data: number[] } | { data: Array<{ y: number }> }; + +export function computeYRange(datasets: DatasetLike[] | undefined, isScatter = false): { min: number; max: number } { + if (!datasets?.length) return { min: 0, max: 1 }; + let localMax = Number.NEGATIVE_INFINITY; + let localMin = Number.POSITIVE_INFINITY; + + datasets.forEach((d: any) => { + const arr: number[] = isScatter ? (d.data as any[]).map(p => Number(p?.y)) : (d.data as number[]); + const nums = arr.map(Number).filter(v => Number.isFinite(v)); + if (!nums.length) return; + const rMax = Math.max(...nums); + const rMin = Math.min(...nums); + localMax = Math.max(localMax, rMax); + localMin = Math.min(localMin, rMin); + }); + + if (!Number.isFinite(localMax) || !Number.isFinite(localMin) || localMax === localMin) { + return { min: 0, max: 1 }; + } + return { min: localMin * 0.99, max: localMax * 1.01 }; +} + +export function getTimeForX(range: TimeRange, chartWidth: number, x: number): bigint { + const offset = range.getOffset?.() ?? BigInt(0); + const duration = range.getDuration(); + const W = Math.max(1, chartWidth); + const clamped = Math.max(0, Math.min(x, W)); + return range.getStart() - offset + BIMath.round((clamped / W) * Number(duration)); +} + +export function getXForTime(range: TimeRange, chartWidth: number, time: bigint): number { + const start = range.getStart(); + const duration = range.getDuration(); + const W = Math.max(1, chartWidth); + return (Number(time - start) / Number(duration)) * W; +} + +export const ZOOM_IN_RATE = 0.8; +export const ZOOM_OUT_RATE = 1.25; + +export function zoomRange( + view: { start: bigint; end: bigint }, + absoluteRange: bigint, + center: bigint, + isZoomIn: boolean, + rateIn = ZOOM_IN_RATE, + rateOut = ZOOM_OUT_RATE +): { start: bigint; end: bigint } { + const length = view.end - view.start; + if (length < 1) return view; + const factor = isZoomIn ? rateIn : rateOut; + const startDist = center - view.start; + const newDuration = BIMath.clamp(Number(length) * factor, BigInt(2), absoluteRange); + const newStart = BIMath.max(0, center - BIMath.round(Number(startDist) * factor)); + const newEnd = newStart + newDuration; + return { start: newStart, end: newEnd }; +} + +export function panRange( + view: { start: bigint; end: bigint }, + absoluteRange: bigint, + panLeft: boolean, + panFactor = 0.1 +): { start: bigint; end: bigint } { + const length = view.end - view.start; + const step = BIMath.round(Number(length) * panFactor); + const dir = panLeft ? BigInt(-1) : BigInt(1); + let start = view.start + dir * step; + let end = view.end + dir * step; + if (start < BigInt(0)) { + start = BigInt(0); + end = length; + } else if (end > absoluteRange) { + end = absoluteRange; + start = end - length; + } + return { start, end }; +} + +export function setSpinnerVisible(outputDomId: string, visible: boolean): void { + const el = document.getElementById(outputDomId + 'handleSpinner'); + if (el) el.style.visibility = visible ? 'visible' : 'hidden'; +} diff --git a/vscode-trace-common/package.json b/vscode-trace-common/package.json index 65f87d3e..9be596db 100644 --- a/vscode-trace-common/package.json +++ b/vscode-trace-common/package.json @@ -10,7 +10,7 @@ ], "dependencies": { "traceviewer-base": "^0.9.1", - "tsp-typescript-client": "^0.7.0", + "tsp-typescript-client": "^0.8.0", "vscode-messenger": "^0.5.0" }, "devDependencies": {