Skip to content

Commit

Permalink
[Utilization] Render Timeseries Component (#6255)
Browse files Browse the repository at this point in the history
# Description
Add customized LineRectChart(TimeSeries+ TimeSegment) chart for
Utilization.
the main function to review is LineRectChart.tsx in
torchci/components/charts/line_rect_chart

## Why not use existing echarts?
Existing chart options such as apache echart has limits to provide what
this feature want to achieve:
 - display timeseries data (line) and segments (rectangular)
many echart product support combo visualization but the closest one is
`bar chart + line chart`, which cannot fulfil our needs.
- complicated interaction with component out of the charts

## Why D3.js
D3.js is the super powerful svg rendering library that most existing
echart product used to create charts from scratch. It's common library
tools, and most echart product is a wrapper of it

## is this one time thing?
This time series + rec chart can be a good base if we want to have more
customized echart for our visualization. many component I created here
is reusable, for instance, if we want customized line filter options
(instead of the echart's general legend one which does not fit UI when
there are many lines)

## About LineRectChart
LineRectChart contains:
- Svg elements: line, rectangulars, circle tooltip, vertical dash line
tooltip
- React checkbox list to group time series based on category "hardware"
and "stats"
 -  React tooltips for lines and rectangulars
 
LineRectChart functionality:
- visualize time series based on group option: "hardware" and
"statistics(stats)"
- visualize test segments
- display tooltip for test segment (name, starttime and endtime)
- display tooltip, mouse position (dashed line) for time series 
- disable test segment
- disable time series tooltip

## D3 vs Native React 
manage most of the logic using React Approach (useState etc), but for
complicated rendering such as Axis and mouseOn rendering for tooltips,
we use d3.js which is directly applying changes to DOM element, To learn
more about d3 https://d3js.org/


## Demo the feature:
live demo:
https://torchci-kbu4nenyq-fbopensource.vercel.app/utilization/12937937547/36088234580/1

### Visualize time series line with tooltip and dashline:

![ts1-demoe](https://github.com/user-attachments/assets/52b43a92-5029-4bc6-91d3-489f3eb4c7a4)

### Choose different option to enable and disable time series lines

![ts-optiondemo](https://github.com/user-attachments/assets/70b0cb35-f546-4240-afc1-c40f550ddf30)

### visualize the segment for test and its tooltip

![ts-seg](https://github.com/user-attachments/assets/8aff5192-0686-4e0f-a9a8-84ae671b51bf)
  • Loading branch information
yangw-dev authored Feb 6, 2025
1 parent 9839619 commit 79d088f
Show file tree
Hide file tree
Showing 23 changed files with 1,615 additions and 51 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.chartContainer {
display: flex; /* or inline-flex */
}
177 changes: 177 additions & 0 deletions torchci/components/charts/line_rect_chart/LineRectChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import * as d3 from "d3";
import { TimeSeriesWrapper } from "lib/utilization/types";
import { useEffect, useRef, useState } from "react";
import { TooltipElement } from "./component/helpers/Tooltip";
import RenderLinePickerOptions from "./component/RenderLinePickerOptions";
import RenderSvgLines from "./component/RenderSvgLine";
import RenderSvgLineTooltipElements from "./component/RenderSvgLineTooltipElements";
import RenderSvgRects from "./component/RenderSvgRect";
import { D3LineRecord, Line, PickerConfig, RectangleData } from "./lib/types";
import { processLineData, processRectData, setDimensions } from "./lib/utils";
import styles from "./LineChart.module.css";

type Props = {
onDataChange?: (data: any) => void;
inputLines?: TimeSeriesWrapper[];
rects?: {
name: string;
start_at: string;
end_at: string;
color?: string;
}[];
chartWidth?: number;
disableRect?: boolean;
disableLineTooltip?: boolean;
selectedLineId?: string;
lineFilterConfig?: PickerConfig[];
};

const LineRectChart = ({
onDataChange = (data: any) => void {},
inputLines,
rects,
chartWidth,
disableRect = false,
disableLineTooltip = false,
lineFilterConfig,
}: Props) => {
const dimensions = setDimensions(chartWidth);

// svg element state
const svgRef = useRef<SVGSVGElement | null>(null);
// d3 scales state
const [scales, setScales] = useState<{ xScale: any; yScale: any }>({
xScale: null,
yScale: null,
});
// line and rect states
const [lines, setLines] = useState<Line[]>([]);
const [lineConfigs, setLineConfigs] = useState<
{ name: string; id: string; hidden: boolean }[]
>([]);
const [rectangles, setRectangles] = useState<RectangleData[]>([]);

// tooltip state
const [lineTooltip, setLineTooltip] = useState<{
visible: boolean;
content: any;
position: { x: number; y: number };
}>({ visible: false, content: null, position: { x: 0, y: 0 } });
const [rectTooltip, setRectTooltip] = useState<{
visible: boolean;
content: any;
position: { x: number; y: number };
}>({ visible: false, content: null, position: { x: 0, y: 0 } });

useEffect(() => {
let lineData: Line[] = [];
if (inputLines) {
lineData = processLineData(inputLines);
setLines(lineData);
setLineConfigs(
lineData.map((line) => {
return { name: line.name, id: line.id, hidden: false };
})
);
}

if (rects) {
let recs = processRectData(rects);
setRectangles(recs);
}

if (lineData.length > 0) {
// set x axis for svg
const xScale = d3
.scaleTime()
.domain(
d3.extent(lineData[0].records, (d: D3LineRecord) => d.date) as [
Date,
Date
]
)
.range([0, dimensions.ctrWidth]);

// Set y axis scale for svg
const yScale = d3
.scaleLinear()
.domain([0, 100])
.range([dimensions.ctrHeight, 0])
.nice();
setScales({ xScale, yScale });
}
return () => {};
}, [inputLines, rects]);

useEffect(() => {
// only render svg axis when dom is ready.
if (svgRef.current && scales.xScale && scales.yScale) {
const container = d3.select(svgRef.current).select(".container");
const xAxis = d3.axisBottom(scales.xScale);
const yAxis = d3.axisLeft(scales.yScale);

container.select(".xAxis").call(xAxis as any);
container.select(".yAxis").call(yAxis as any);
}
}, [scales]);

// handle line events
return (
<div className={styles.chartContainer}>
<div>
<svg ref={svgRef} width={dimensions.width} height={dimensions.height}>
<g
className="container"
transform={`translate(${dimensions.margins}, ${dimensions.margins})`}
>
<g
className="xAxis"
transform={`translate(0,${dimensions.ctrHeight})`}
/>
<g className="yAxis" />
<RenderSvgLines
scales={scales}
lines={lines}
lineConfigs={lineConfigs}
/>
<RenderSvgLineTooltipElements
lines={lines}
lineConfigs={lineConfigs}
dimensions={dimensions}
scales={scales}
container={d3.select(svgRef.current).select(".container")}
disableLineTooltip={disableLineTooltip}
setLineTooltip={setLineTooltip}
/>
<RenderSvgRects
setRectTooltip={setRectTooltip}
rectangles={rectangles}
disableRect={disableRect}
dimensions={dimensions}
scales={scales}
/>
</g>
</svg>
<TooltipElement
isVisible={lineTooltip.visible}
content={lineTooltip.content}
position={lineTooltip.position}
/>
<TooltipElement
isVisible={rectTooltip.visible}
content={rectTooltip.content}
position={rectTooltip.position}
/>
</div>
{lineFilterConfig && (
<RenderLinePickerOptions
lines={lineConfigs}
setLines={setLineConfigs}
lineFilterConfig={lineFilterConfig}
/>
)}
</div>
);
};

export default LineRectChart;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.rowFlexCenter {
display: flex; /* or inline-flex */
align-items: center;
}

.tooltipline {
font-family: Arial, Helvetica, sans-serif;
line-height: 1;
padding: 12px;
background-color: white;
border-radius: 5px;
border: 1px solid grey;
}

.selected-rect {
fill: brown;
opacity: 0.7;
}

.rect {
opacity: 0.2;
}

.rect:hover {
fill: blue;
opacity: 0.5;
}
.rect:active {
fill: red;
}

.linePickerGroup {
height: 300px; /* fixed height */
overflow-y: auto;
scrollbar-width: thin;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { useEffect, useState } from "react";
import {
containsAllSubstrings,
PickerConfig,
PickerConfigType,
} from "../lib/types";
import ChartCheckboxGroups from "./helpers/ChartCheckboxGroups";
import { CheckboxItem } from "./helpers/CheckboxGroup";
import DropList from "./helpers/DropList";
import styles from "./RenderLineChartComponents.module.css";

const RenderLinePickerOptions = ({
lines,
setLines,
lineFilterConfig,
}: {
lines: { name: string; id: string; hidden: boolean }[];
setLines: (line: any[]) => void;
lineFilterConfig: PickerConfig[];
}) => {
const [category, setCategory] = useState<string>("");
const [options, setOptions] = useState<any>([]);
const [groups, setGroups] = useState<any>([]);

useEffect(() => {
render();
}, [lines, lineFilterConfig]);

function render() {
let options = lineFilterConfig.map((config) => {
return { value: config.category, name: config.category };
});
setOptions(options);

const config = lineFilterConfig.find((item) => item.category == category);
if (!config) {
setGroups([]);
return;
}
const res = config.types.map((type) => {
return {
parentName: type.name,
childGroup: getChildGroup(type, lines),
};
});
setGroups(res);
}

function resetLines() {
const newLines = lines.map((line) => {
line.hidden = true;
return line;
});
setLines(newLines);
}

useEffect(() => {
resetLines();
render();
}, [category]);

const getChildGroup = (
p: PickerConfigType,
lines: { name: string; id: string; hidden: boolean }[]
) => {
const res = lines
.filter((line) => containsAllSubstrings(line.id, p.tags))
.map((line) => {
return {
id: line.id,
name: line.name,
checked: !line.hidden,
};
});
return res;
};

const changeLineCateory = (category: string) => {
setCategory(category);
};

const changeLineVisilibity = (checked: CheckboxItem[]) => {
const newLines = lines.map((line) => {
const checkedItem = checked.find((item) => item.id === line.id);
if (checkedItem) {
line.hidden = !checkedItem.checked;
}
return line;
});
setLines(newLines);
};

return (
<div>
{options.length > 0 && (
<div className={styles.rowFlexCenter}>
<div>Group by:</div>
<DropList onChange={changeLineCateory} options={options}></DropList>
</div>
)}
{groups.length > 0 && (
<div className={styles.linePickerGroup}>
<ChartCheckboxGroups
groups={groups}
onChange={changeLineVisilibity}
/>
</div>
)}
</div>
);
};

export default RenderLinePickerOptions;
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import * as d3 from "d3";
import { getRandomColor } from "../lib/color";
import { D3LineRecord, Line } from "../lib/types";

/**
* handle svg line rendering for LineRectChart
*/
const RenderSvgLines = ({
scales,
lines,
lineConfigs,
}: {
scales: any;
lines: Line[];
lineConfigs: { name: string; id: string; hidden: boolean }[];
}) => {
const lineGenerator = d3
.line<D3LineRecord>()
.x((d: D3LineRecord) => scales.xScale(d.date))
.y((d: D3LineRecord) => scales.yScale(d.value))
.curve(d3.curveBasis);

return (
<g className="lines-group">
{lines.map((line, i) => {
const hidden =
lineConfigs.find((config) => config.id === line.id)?.hidden ?? false;
return (
<path
key={i}
d={lineGenerator(line.records)?.toString()}
id={line.name + "-line"}
className={"line"}
fill="none"
opacity={hidden ? 0.05 : 1}
stroke={line.color ? line.color : getRandomColor(i)}
strokeWidth={1.2}
/>
);
})}
</g>
);
};

export default RenderSvgLines;
Loading

0 comments on commit 79d088f

Please sign in to comment.