frontend/src/common/Graph.tsx

848 lines
23 KiB
TypeScript

import React from "react";
import { Range } from "common/RangeInput";
import moment from "moment";
import AutoSizer from "react-virtualized-auto-sizer";
import { abbreviateNum } from "utils/numbers";
import { or } from "utils/types";
import {
createContainer,
ScalePropType,
VictoryArea,
VictoryAxis,
VictoryChart,
VictoryLabel,
VictoryLegend,
VictoryLine,
VictoryPie,
VictoryScatter,
VictoryTheme,
VictoryTooltip,
VictoryTooltipProps
} from "victory";
import {
ResponsiveContainer,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
Radar,
Legend
} from "recharts";
import { pond } from "protobuf-ts/pond";
import { getTextPrimary, getTextSecondary } from "theme/text";
import { darken, useTheme } from "@mui/material";
import { useWidth } from "hooks";
import { useNavigate } from "react-router-dom";
const VictoryCursorVoronoiContainer: any = createContainer("cursor", "voronoi");
export enum GraphOrientation {
GRAPH_ORIENTATION_PORTRAIT = 0,
GRAPH_ORIENTATION_LANDSCAPE = 1
}
export enum GraphType {
LINEAR = 0,
AREA = 1,
BAR = 2,
MULTILINE = 3,
SCATTER = 4,
PIE = 5,
RADAR = 6
}
export interface vXAxis {
scale: ScalePropType;
label: string;
}
export interface GraphPoint {
x: any;
y: any;
y0?: any;
bubble?: number;
colour?: string;
}
export interface PieSlice {
x: string;
y: number;
fill: string;
linkToOnClick?: string;
}
export interface InteractionLine {
value: any;
colour: any;
}
export interface GraphComponent {
label: string;
data: Array<GraphPoint> | Array<Array<GraphPoint>> | Array<PieSlice>;
overlays?: pond.ComponentOverlays[];
tick?: string;
colour: any;
type: GraphType;
range?: Range;
interactionLines?: Array<InteractionLine>;
isBoolean?: boolean;
states?: Array<string>;
isPercent?: boolean;
xAxis?: vXAxis;
decimals?: number;
horizontalBars?: boolean;
}
interface CustomVictoryTooltipProps extends VictoryTooltipProps {
height: any;
width: any;
}
class CustomVictoryTooltip extends React.Component<CustomVictoryTooltipProps> {
render() {
const { x, height, width, text } = this.props;
let y = height / 2;
let orientation: "right" | "left" = x && x < width * 0.66 ? "right" : "left";
let xOffset: number = orientation === "right" ? 5 : -5;
return (
<VictoryTooltip
active
x={x}
y={y}
dx={xOffset}
orientation={orientation}
pointerLength={0}
flyoutStyle={{ stroke: "none", strokeWidth: 0, fill: "#323232" }}
style={{ fill: "#fff" }}
text={text}
/>
);
}
}
interface Props /*extends WithWidth, RouteComponentProps, WithTheme*/ {
orientation?: GraphOrientation;
graphComponent?: GraphComponent;
}
interface State {
interactionComponents: Array<any>;
dataComponent: any;
updateCounter: number;
overlayComponents: Array<any>;
}
//a linear graph with the option for two y-axis
class Graph extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
interactionComponents: [],
dataComponent: null,
updateCounter: 0,
overlayComponents: []
};
}
componentDidMount = () => {
this.setupGraph();
};
componentDidUpdate = (prevProps: Props) => {
if (prevProps.graphComponent !== this.props.graphComponent) {
this.setupGraph();
this.setState(prevState => ({
updateCounter: prevState.updateCounter + 1
}));
}
};
setupGraph = () => {
const { graphComponent } = this.props;
if (graphComponent && or(graphComponent.data, []).length > 0) {
let dataComponent = this.getDataComponent(graphComponent, "graphComponent");
switch (graphComponent.type) {
case GraphType.PIE:
this.setState({
interactionComponents: [],
dataComponent: dataComponent
});
break;
case GraphType.RADAR:
this.setState({
interactionComponents: [],
dataComponent: dataComponent
});
break;
default:
let timeData, startDate: string, endDate: string;
timeData =
graphComponent.type === GraphType.MULTILINE
? (graphComponent.data[0] as Array<GraphPoint>)
: (graphComponent.data as Array<GraphPoint>);
startDate = timeData[timeData.length - 1].x;
endDate = timeData[0].x;
timeData.forEach((d: GraphPoint) => {
if (moment(d.x).isBefore(moment(startDate))) {
startDate = d.x;
}
if (moment(d.x).isAfter(moment(endDate))) {
endDate = d.x;
}
});
this.setState({
interactionComponents: this.getInteractionComponents(
graphComponent.interactionLines ? graphComponent.interactionLines : [],
startDate,
endDate
),
dataComponent: dataComponent,
overlayComponents: this.getOverlayComponents(startDate, endDate)
});
break;
}
}
};
getLegend = () => {
const { graphComponent } = this.props;
const textColour = getTextSecondary()
if (!graphComponent) {
return [];
}
//prepares the initial object
var legend = [
{
name: graphComponent.label,
symbol: { fill: graphComponent.colour },
labels: { fill: textColour }
}
];
return legend;
};
//uses the material theme from victory as a base
customTheme = () => {
let graphTheme = VictoryTheme.material as any; //need to cast as 'any' because Victory has an issue with their ThemeInterface
//removing the grid
graphTheme.axis.style.grid = {
fill: "none",
stroke: "none"
};
graphTheme.voronoi.style.flyout = {
...graphTheme.voronoi.style.flyout,
...{ fill: "#323232", stroke: "none", strokeWidth: 0 }
};
graphTheme.voronoi.style.labels = {
...graphTheme.voronoi.style.labels,
...{ fill: "#fff" }
};
return graphTheme;
};
getInteractionData = (interactionLine: InteractionLine, startDate: any, endDate: any) => {
return [
{ x: startDate, y: interactionLine.value },
{ x: endDate, y: interactionLine.value }
];
};
getInteractionComponents = (
interactionLines: Array<InteractionLine>,
startDate: any,
endDate: any
) => {
var interactionComponents = [];
for (let i = 0; i < or(interactionLines, []).length; i++) {
interactionComponents.push(
<VictoryLine
key={i}
name={"interactionLine-" + i.toString()}
style={{
data: {
stroke: interactionLines[i].colour,
strokeOpacity: 0.3,
strokeDasharray: "5, 5"
}
}}
data={this.getInteractionData(interactionLines[i], startDate, endDate)}
x="x"
y="y"
/>
);
}
return interactionComponents;
};
getOverlayData = (overlay: pond.ComponentOverlays, startDate: any, endDate: any) => {
return [
{ x: startDate, y: overlay.max, y0: overlay.min },
{ x: endDate, y: overlay.max, y0: overlay.min }
];
};
getOverlayComponents = (startDate: any, endDate: any) => {
var overlayComponents: JSX.Element[] = [];
if (this.props.graphComponent?.overlays) {
let o = this.props.graphComponent?.overlays;
o.forEach((overlay, i) => {
overlayComponents.push(
<VictoryArea
key={"overlay" + i}
style={{
data: {
fill: overlay.colour,
opacity: 0.15
}
}}
data={this.getOverlayData(overlay, startDate, endDate)}
x="x"
y="y"
/>
);
});
}
return overlayComponents;
};
getAreaComponent = (data: Array<GraphPoint>, colour: any, key: string) => {
return (
<VictoryArea
key={key}
style={{
data: { fill: colour, fillOpacity: 0.33 }
}}
data={data}
x="x"
y="y"
interpolation="monotoneX"
/>
);
};
// getBarComponent = (
// data: Array<GraphPoint>,
// colour: any,
// key: string,
// horizontal: boolean = false
// ) => {
// return (
// <VictoryBar
// key={key}
// horizontal={horizontal}
// style={{
// data: {
// fill: (d: GraphPoint) => or(d.colour, colour),
// stroke: (d: GraphPoint) => or(d.colour, colour),
// fillOpacity: 0.6,
// strokeWidth: 1
// }
// }}
// alignment="start"
// barWidth={7}
// cornerRadius={0}
// data={data}
// x="x"
// y="y"
// />
// );
// };
//data should contain an array of arrays (lines)
getMultilineComponent = (data: Array<Array<GraphPoint>>, colour: any, key: string) => {
let lines = [];
for (var i = 0; i < or(data, []).length; i++) {
lines.push(this.getLineComponent(data[i], colour, key + "-" + i.toString()));
}
return lines;
};
getScatterComponent = (data: Array<GraphPoint>, colour: any, key: string) => {
// const { theme } = this.props;
return (
<VictoryScatter
key={key}
maxBubbleSize={50}
size={datum => datum.bubble * 1.5 + 5}
style={{
data: {
fill: colour,
fillOpacity: (datum: any) => (datum.bubble > 0 ? 0.65 : 0.25),
stroke: darken(colour, 0.25),
strokeOpacity: (datum: any) => (datum.bubble > 0 ? 0.65 : 0.25),
strokeWidth: 1
},
labels: {
fill: getTextPrimary(),
fontSize: 10
}
}}
labels={(datum: any) => (datum.bubble > 0 ? datum.bubble : "")}
labelComponent={<VictoryLabel dy={13} />}
data={data}
x="x"
y="y"
/>
);
};
getRadarComponent = (data: Array<GraphPoint>, colour: any, key: string) => {
let dataTemplate = this.getWindDirTemplate();
return (
<RadarChart data={this.assembleRadarData(data, dataTemplate)} key={key}>
<Legend />
<PolarGrid />
<PolarAngleAxis tick={{ fill: colour }} dataKey="key" />
<PolarRadiusAxis />
<Radar
name="Wind Direction"
dataKey="value"
stroke={colour}
fill={colour}
fillOpacity={0.6}
/>
</RadarChart>
);
};
assembleRadarData = (
data: Array<GraphPoint>,
dataTemplate: Array<{ key: string; value: number }>
) => {
let aData = dataTemplate;
data.forEach(point => {
if (point.y !== 0) {
aData[point.y - 1].value = aData[point.y - 1].value + 1;
}
});
return aData;
};
getWindDirTemplate = () => {
return [
{
key: "N",
value: 0
},
{
key: "NNE",
value: 0
},
{
key: "NE",
value: 0
},
{
key: "ENE",
value: 0
},
{
key: "E",
value: 0
},
{
key: "ESE",
value: 0
},
{
key: "SE",
value: 0
},
{
key: "SSE",
value: 0
},
{
key: "S",
value: 0
},
{
key: "SSW",
value: 0
},
{
key: "SW",
value: 0
},
{
key: "WSW",
value: 0
},
{
key: "W",
value: 0
},
{
key: "WNW",
value: 0
},
{
key: "NW",
value: 0
},
{
key: "NNW",
value: 0
}
];
};
getLineComponent = (data: Array<GraphPoint>, colour: any, key: string) => {
return (
<VictoryLine
key={key}
style={{
data: { stroke: colour }
}}
data={data}
x="x"
y="y"
interpolation="monotoneX"
/>
);
};
//dynamically loads a data component based on type (area, bar, line), defaults to line*/
getDataComponent(graphComponent: GraphComponent, key: string): any {
const { type, data, colour } = graphComponent;
switch (type) {
case GraphType.AREA:
return this.getAreaComponent(data as Array<GraphPoint>, colour, key);
// case GraphType.BAR:
// return this.getBarComponent(
// data as Array<GraphPoint>,
// colour,
// key,
// graphComponent.horizontalBars
// );
case GraphType.MULTILINE:
return this.getMultilineComponent(data as Array<Array<GraphPoint>>, colour, key);
case GraphType.SCATTER:
return this.getScatterComponent(data as Array<GraphPoint>, colour, key);
case GraphType.PIE:
return;
case GraphType.RADAR:
return this.getRadarComponent(data as Array<GraphPoint>, colour, key);
default:
//line component is default
return this.getLineComponent(data as Array<GraphPoint>, colour, key);
}
}
getContainerHeight = () => {
const { orientation } = this.props;
const width = useWidth()
if (orientation === GraphOrientation.GRAPH_ORIENTATION_LANDSCAPE) {
switch (width) {
case "xs":
return 250;
case "sm":
return 300;
case "md":
return 350;
case "lg":
return 375;
case "xl":
return 400;
default:
return 300;
}
} else {
//default portrait view
return 300;
}
};
emptyPieSlice(): PieSlice {
// const { theme } = this.props;
const theme = useTheme()
return {
x: "",
y: 1,
fill: theme.palette.grey.A700
} as PieSlice;
}
// fillCallback: VictoryStringOrNumberCallback = (d: PieSlice) => {
// return d.fill
// }
getPieChart(width: any, height: any) {
const { graphComponent } = this.props;
const theme = useTheme()
const navigate = useNavigate()
const { data } = graphComponent ? graphComponent : { data: [] };
const { label } = graphComponent ? graphComponent : { label: "" };
const legendData = (data as PieSlice[]).map(d => {
return {
name: d.x + " (" + d.y + ")",
symbol: { fill: d.fill },
labels: { fill: theme.palette.text.primary, fontSize: width >= 300 ? 16 : 14 }
};
});
//filter out zero-slices
const filteredData: PieSlice[] = (data as PieSlice[]).filter(d => d.y > 0);
let hasActions = false;
filteredData.forEach(d => {
if (d.linkToOnClick) hasActions = true;
});
//pie chart is empty
if (filteredData.length <= 0) {
filteredData.push(this.emptyPieSlice());
}
return (
<svg width={width} height={height}>
<VictoryLegend
standalone={false}
gutter={10}
orientation="horizontal"
itemsPerRow={width >= 600 ? 3 : width >= 300 ? 2 : 1}
data={legendData}
/>
<VictoryPie
standalone={false}
data={filteredData}
x="x"
y="y"
style={{
data: {
// fill: (d: PieSlice): VictoryStringOrNumberCallback => {
// return (d.fill)
// },
cursor: hasActions ? "pointer" : "default"
},
labels: {
fontSize: "16px",
fill: theme.palette.text.primary
}
}}
height={height}
width={width}
padAngle={2}
innerRadius={height * 0.25}
padding={{
top: height * 0.25,
right: 0,
bottom: height * 0.1,
left: 0
}}
labels={() => ""}
events={[
{
target: "data",
eventHandlers: {
onClick: () => ({
mutation: (props: any) => {
// const { history } = this.props;
const { datum } = props;
if (datum.linkToOnClick) {
navigate(datum.linkToOnClick);
}
}
}),
onMouseOver: () => ({
mutation: props => ({
radius: hasActions ? props.radius * 1.05 : props.radius
})
}),
onMouseOut: () => ({
mutation: () => null
})
}
}
]}
/>
{label && label !== "" && (
<VictoryLabel
textAnchor="middle"
style={{ fontSize: 24, fill: theme.palette.text.primary }}
x={width / 2}
y={(height / 2) * 1.15}
text={label}
/>
)}
</svg>
);
}
render() {
const theme = useTheme()
const { graphComponent } = this.props;
const { interactionComponents, overlayComponents, dataComponent, updateCounter } = this.state;
if (!graphComponent || !graphComponent.data) {
return null;
}
const { range, isBoolean, isPercent } = graphComponent;
const textColour = theme.palette.text.secondary;
const legend = this.getLegend();
const height = this.getContainerHeight();
const hideCursor: boolean = [GraphType.BAR].includes(graphComponent.type);
const disableCursorVoronoi: boolean = [GraphType.AREA, GraphType.MULTILINE].includes(
graphComponent.type
);
return (
<AutoSizer disableHeight key={updateCounter}>
{({ width }: any) => (
<React.Fragment>
{/* Don't need the entire graph for pie charts */}
{graphComponent.type !== GraphType.PIE ? (
<React.Fragment>
{/*
// @ts-ignore */}
{graphComponent.type === GraphType.RADAR ? (
<ResponsiveContainer minHeight={450} width={width}>
{dataComponent}
</ResponsiveContainer>
) : (
<VictoryChart
theme={this.customTheme()}
containerComponent={
<VictoryCursorVoronoiContainer
voronoiDimension="x"
cursorDimension="x"
disable={width === "xs" || disableCursorVoronoi}
responsive={false}
cursorComponent={
hideCursor ? (
<React.Fragment />
) : (
<line
style={{
stroke: "#323232",
strokeWidth: 2
}}
/>
)
}
labels={(datum: any) => {
if (
graphComponent &&
or<vXAxis>(graphComponent.xAxis, { scale: "time" } as vXAxis).scale ===
"time"
) {
let decimals = graphComponent.decimals ? graphComponent.decimals : 0;
return `${moment(or(datum.x, undefined)).calendar()}, ${datum.y.toFixed(
decimals
)}`;
}
return datum.y;
}}
labelComponent={
hideCursor ? (
<VictoryTooltip />
) : (
<CustomVictoryTooltip width={width} height={height} />
)
}
style={{ touchAction: "auto" }}
voronoiBlacklist={["interactionLine"]}
/>
}
animate={{ duration: 1000, easing: "expInOut", onLoad: { duration: 500 } }}
scale={{
x: or(graphComponent.xAxis, { scale: "time" }).scale,
y: "linear"
}}
width={width}
height={height}
padding={{ top: 30, bottom: 30, left: 60, right: 60 }}
// @ts-ignore */}
minDomain={{
y:
range && range.min !== undefined
? range.min
: isBoolean || isPercent
? 0
: undefined
}}
maxDomain={{
y:
range && range.max !== undefined
? range.max
: isPercent
? 100
: isBoolean
? 1
: undefined
}}
domainPadding={isBoolean || isPercent ? undefined : { y: 50 }}>
<VictoryLegend
x={75}
centerTitle
orientation="horizontal"
style={{ labels: { fontSize: 12 } }}
data={or(legend, { name: "" } as any)}
/>
{/* X Axis */}
<VictoryAxis
crossAxis
style={{
axis: { stroke: textColour },
ticks: { stroke: textColour, fontSize: 7 },
tickLabels: { fontSize: 12, fill: textColour },
axisLabel: { fontSize: 12, fill: textColour }
}}
standalone={false}
fixLabelOverlap={true}
orientation="bottom"
label={or(graphComponent.xAxis, { label: "" }).label}
/>
{/* Y Axis */}
<VictoryAxis
crossAxis={false}
style={{
axis: { stroke: textColour },
ticks: { stroke: textColour, fontSize: 4 },
tickLabels: {
fontSize: 12,
padding: 1,
fill: textColour
}
}}
dependentAxis
standalone={false}
fixLabelOverlap={true}
tickValues={graphComponent.isBoolean ? [0, 1] : undefined}
tickFormat={(tick: any) =>
graphComponent.isBoolean
? tick === 1
? or(graphComponent.states, ["Off", "On"])[1]
: or(graphComponent.states, ["Off", "On"])[0]
: `${abbreviateNum(tick)}` + or(graphComponent.tick, "")
}
/>
{interactionComponents}
{overlayComponents}
{dataComponent}
</VictoryChart>
)}
</React.Fragment>
) : (
this.getPieChart(width, height)
)}
</React.Fragment>
)}
</AutoSizer>
);
}
}
export default ((Graph));