added tons of files for component functionality; manual component adding added temporarily

This commit is contained in:
Carter 2025-02-11 16:31:03 -06:00
parent 58830d480e
commit 45ff49bcea
121 changed files with 25883 additions and 65 deletions

View file

@ -0,0 +1,200 @@
import { useTheme } from "@mui/material";
import { blue, indigo, orange, red } from "@mui/material/colors";
import MaterialChartTooltip from "charts/MaterialChartTooltip";
import { usePrevious } from "hooks";
import moment from "moment";
import { useEffect, useState } from "react";
import {
Area,
AreaChart,
Legend,
ReferenceArea,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis
} from "recharts";
import { roundTo } from "utils/numbers";
export interface GrainDryingPoint {
timestamp: number;
dryScore: number;
}
interface Props {
data: GrainDryingPoint[];
showStroke?: boolean;
useGradient?: boolean;
displayY?: boolean;
newXDomain?: number[] | string[];
multiGraphZoom?: (domain: number[] | string[]) => void;
multiGraphZoomOut?: boolean;
}
export default function GrainDryingChart(props: Props) {
const { data, showStroke, useGradient, displayY, newXDomain, multiGraphZoom } = props;
const prevData = usePrevious(data);
const theme = useTheme();
const [offset, setOffset] = useState(0);
const now = moment();
const veryDry = red[800];
const dry = orange[500];
const damp = blue[500];
const veryDamp = indigo[800];
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [refLeft, setRefLeft] = useState<number | undefined>();
const [refRight, setRefRight] = useState<number | undefined>();
useEffect(() => {
if (newXDomain) {
setXDomain(newXDomain);
}
}, [newXDomain]);
useEffect(() => {
const gradientOffset = () => {
const vpdMax = Math.max(...data.map(p => p.dryScore));
const vpdMin = Math.min(...data.map(p => p.dryScore));
if (vpdMax <= 0) {
return 0;
}
if (vpdMin >= 0) {
return 1;
}
return vpdMax / (vpdMax - vpdMin);
};
if (!prevData || prevData !== data) {
setOffset(gradientOffset());
}
}, [data, prevData, now]);
const zoom = () => {
let newDomain: number[] | string[] = ["dataMin", "dataMax"];
if (refLeft && refRight && refLeft !== refRight) {
refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
setRefLeft(undefined);
setRefRight(undefined);
if (multiGraphZoom) {
multiGraphZoom(newDomain);
} else {
setXDomain(newDomain);
}
}
};
//just commenting this out as this charts zoom will likely never be controlled from inside
// const zoomOut = () => {
// setXDomain(["dataMin", "dataMax"]);
// };
if (data.length <= 1) return null;
return (
<ResponsiveContainer width={"100%"} height={270}>
<AreaChart
data={data}
onMouseDown={(e: any) => {
if (e) {
setRefLeft(e.activeLabel);
}
}}
onMouseMove={(e: any) => {
if (e) {
setRefRight(e.activeLabel);
}
}}
onMouseUp={() => {
setRefLeft(undefined);
setRefRight(undefined);
zoom();
}}>
{displayY && (
<YAxis
tickFormatter={vpd => {
let val = vpd.toFixed(2);
return val;
}}
type="number"
// label={{
// value: "Water Content",
// angle: -90,
// position: "insideLeft",
// fill: theme.palette.text.primary
// }}
domain={["auto", "auto"]}
tick={{ fill: theme.palette.text.primary }}
/>
)}
<XAxis
allowDataOverflow
dataKey="timestamp"
domain={xDomain}
name="Time"
tickFormatter={timestamp => {
let t = moment(timestamp);
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
}}
scale="time"
type="number"
tick={{ fill: theme.palette.text.primary }}
stroke={theme.palette.divider}
interval="preserveStartEnd"
/>
<Legend
verticalAlign="top"
payload={[
{ value: "Drying", type: "square", id: "drying", color: orange[800] },
{ value: "Hydrating", type: "square", id: "hydrating", color: blue[800] }
]}
/>
<Tooltip
animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
labelFormatter={timestamp => moment(timestamp).format("lll")}
content={(props: TooltipProps<any, any>) => (
<MaterialChartTooltip
{...props}
valueFormatter={score =>
`${roundTo(parseFloat(String(score)), 2).toString()} ${
score === 0 ? "" : score as number > 0 ? " (Drying)" : " (Hydrating)"
}`
}
/>
)}
/>
<defs>
<linearGradient id="gradientColour" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={veryDry} stopOpacity="90%" />
<stop offset={offset} stopColor={dry} stopOpacity="75%" />
<stop offset={offset} stopColor={damp} stopOpacity="75%" />
<stop offset="100%" stopColor={veryDamp} stopOpacity="90%" />
</linearGradient>
<linearGradient id="solidColour" x1="0" y1="0" x2="0" y2="1">
<stop offset={offset} stopColor={dry} stopOpacity="75%" />
<stop offset={offset} stopColor={damp} stopOpacity="75%" />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey={"dryScore"}
name="Score"
stroke={
showStroke
? useGradient
? "url(#gradientColour)"
: "url(#solidColour)"
: "transparent"
}
strokeWidth={3}
fill={useGradient ? "url(#gradientColour)" : "url(#solidColour)"}
/>
{refLeft && refRight ? (
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.3} />
) : null}
</AreaChart>
</ResponsiveContainer>
);
}

View file

@ -0,0 +1,134 @@
import {
Button,
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
MenuItem,
TextField
} from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useComponentAPI, useSnackbar } from "hooks";
import { Component } from "models";
import { pond } from "protobuf-ts/pond";
// import { MatchParams } from "navigation/Routes";
// import { useRouteMatch } from "react-router";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
interface Props {
open: boolean;
newChart?: boolean;
closeDialog: () => void;
component: Component;
mutation?: pond.Mutator;
currentMin: number | string;
currentMax: number | string;
adjustMin: (val: number) => void;
adjustMax: (val: number) => void;
}
export default function GraphSettings(props: Props) {
const {
open,
component,
mutation,
closeDialog,
adjustMin,
adjustMax,
currentMin,
currentMax,
newChart
} = props;
const [newMutation, setNewMutation] = useState<pond.Mutator>(0);
const componentAPI = useComponentAPI();
const deviceID = useParams<{ deviceID: string }>()?.deviceID ?? "";
const { openSnack } = useSnackbar();
useEffect(() => {
if (mutation !== undefined) {
setNewMutation(mutation);
}
}, [mutation]);
const saveMutation = () => {
let comp = component;
let defaultMutations = comp.settings.defaultMutations;
if (!defaultMutations.includes(newMutation) && newMutation !== pond.Mutator.MUTATOR_NONE) {
defaultMutations.push(newMutation);
}
componentAPI
.update(parseInt(deviceID), comp.settings)
.then(() => openSnack("Mutations Updated"));
closeDialog();
};
const deleteDerivedChart = () => {
let comp = component;
let defaultMuts = comp.settings.defaultMutations;
if (mutation && defaultMuts.includes(mutation)) {
defaultMuts.splice(defaultMuts.indexOf(mutation));
}
componentAPI
.update(parseInt(deviceID), comp.settings)
.then(() => openSnack("Mutations Updated"));
closeDialog();
};
const handleChange = (event: any) => {
setNewMutation(event.target.value as pond.Mutator);
};
return (
<ResponsiveDialog open={open} onClose={closeDialog}>
<DialogTitle>Chart Settings</DialogTitle>
<DialogContent>
{newChart && (
<TextField select fullWidth value={newMutation} onChange={handleChange}>
{/* TODO: change the menu item to be from the available mutations in the component settings (doesn't exist yet) */}
<MenuItem value={pond.Mutator.MUTATOR_NONE}>None</MenuItem>
<MenuItem value={pond.Mutator.MUTATOR_EMC}>Grain Moisture(EMC)</MenuItem>
<MenuItem value={pond.Mutator.MUTATOR_CFM}>CFM</MenuItem>
</TextField>
)}
{!newChart && (
<Grid container direction="column">
<Grid>
<TextField
label="Min Range"
type="number"
fullWidth
defaultValue={currentMin}
onChange={e => adjustMin(parseInt(e.target.value))}
/>
</Grid>
<Grid>
<TextField
label="Max Range"
type="number"
fullWidth
defaultValue={currentMax}
onChange={e => adjustMax(parseInt(e.target.value))}
/>
</Grid>
</Grid>
)}
</DialogContent>
<DialogActions>
{mutation !== undefined && mutation > pond.Mutator.MUTATOR_NONE && (
<Button onClick={deleteDerivedChart} variant="contained" style={{ background: "red" }}>
Delete
</Button>
)}
<Button onClick={closeDialog} variant="contained" color="primary">
Close
</Button>
{mutation === pond.Mutator.MUTATOR_NONE && (
<Button variant="contained" onClick={saveMutation} color="primary">
Save
</Button>
)}
</DialogActions>
</ResponsiveDialog>
);
}

View file

@ -0,0 +1,47 @@
import {
Box,
Paper,
Typography
} from "@mui/material";
import React from "react";
import { TooltipProps } from "recharts";
interface Props extends TooltipProps<any, any> {
valueFormatter?: (
value: React.ReactText | string | number | readonly (string | number)[]
) => string;
}
export default function MaterialChartTooltip(props: Props) {
const { active, label, payload, labelFormatter, valueFormatter } = props;
const formattedLabel = () => {
if (!label) return "Unknown";
return labelFormatter ? labelFormatter(label, payload ?? []) : label;
};
const payloadContent = (p: any) => {
const { name, value, dataKey } = p;
//if (!value) return null; // this was preventing 0's from displaying correctly
return (
<Typography color="textPrimary" variant="subtitle1" key={dataKey}>
{p.payload.name ?? name ?? "Unknown"}
{": "}
<Box component="span">{valueFormatter ? valueFormatter(value) : value.toString()}</Box>
</Typography>
);
};
if (active) {
return (
<Paper variant="outlined" style={{ opacity: 0.9 }}>
<Box padding={1}>
{payload && payload.map(p => payloadContent(p))}
<Typography color="textSecondary" variant="caption">
{formattedLabel()}
</Typography>
</Box>
</Paper>
);
}
return null;
}

View file

@ -0,0 +1,410 @@
import React, { useEffect, useState } from "react";
import { extension, GraphFilters, showMultilineCable } from "pbHelpers/ComponentType";
import { Component, Interaction } from "models";
import { pond } from "protobuf-ts/pond";
import { describeMeasurement } from "pbHelpers/MeasurementDescriber";
import { quack } from "protobuf-ts/quack";
import { GraphType } from "common/Graph";
import AreaGraph, { AreaData } from "./measurementCharts/AreaGraph";
import moment, { Moment } from "moment";
import MultiLineGraph, { LineData } from "./measurementCharts/MultiLineGraph";
import { ReferenceArea, ReferenceLine } from "recharts";
import { UnitMeasurement } from "models/UnitMeasurement";
import {
Box,
Button,
Card,
CardContent,
Checkbox,
FormControlLabel,
Grid2 as Grid,
IconButton,
Typography
} from "@mui/material";
import GroupSettingsIcon from "@mui/icons-material/Settings";
import { green } from "@mui/material/colors";
import GraphSettings from "./GraphSettings";
import TimeBar from "common/time/TimeBar";
import { useMobile } from "hooks";
import { useGlobalState } from "providers";
interface Props {
startDate: Moment;
endDate: Moment;
component: Component;
interactions: Interaction[];
filters?: GraphFilters;
unitMeasurements: UnitMeasurement[];
sampleLimit?: number;
updateDateRange: (start: Moment, end: Moment, live: boolean) => void;
showOriginal: boolean;
showAveraged: boolean;
}
interface Range {
min: number | string;
max: number | string;
}
export default function MeasurementsChart(props: Props) {
const {
component,
interactions,
filters,
unitMeasurements,
updateDateRange,
startDate,
endDate,
showOriginal,
showAveraged,
sampleLimit
} = props;
const [currentMutator, setCurrentMutator] = useState<pond.Mutator | undefined>();
const [graphSettingsDialog, setGraphSettingsDialog] = useState(false);
const [graphRanges, setGraphRanges] = useState<Range[]>([]);
const [rangeIndex, setRangeIndex] = useState(0);
const [currentMin, setCurrentMin] = useState<number | string>("dataMin");
const [currentMax, setCurrentMax] = useState<number | string>("dataMax");
const [newChart, setNewChart] = useState(false);
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [{ showErrors }, dispatch] = useGlobalState();
const isMobile = useMobile();
// const [zoomStart, setZoomStart] = useState<Moment>();
// const [zoomEnd, setZoomEnd] = useState<Moment>();
// const [zoomed, setZoomed] = useState<boolean>(false);
// const zoomOut = () => {
// setXDomain(["dataMin", "dataMax"]);
// setZoomed(false);
// };
const zoomIn = (domain: number[] | string[]) => {
let start;
let end;
if (domain[0]) {
start = moment(domain[0]);
}
if (domain[1]) {
//add 1 second to the end time to prevent the last measurement from being cut off due to the milliseconds on the time in the database
end = moment(domain[1]).add(1, "second");
}
setXDomain(domain);
//setZoomed(true);
//console.log(sampleLimit);
//console.log(unitMeasurements);
//check if the sample limit was set and we have unit measurements
if (sampleLimit && unitMeasurements.length > 0) {
//if the number of measurements in the unit measurement is less than half of the sample limit it means there are no more measurements to get,
//so if it is bigger then will need to hit the backend to get a new set of measurements
if (unitMeasurements[0].values.length >= sampleLimit / 2) {
if (start && end) {
updateDateRange(start, end, false);
setXDomain(["dataMin", "dataMax"]);
}
}
}
};
useEffect(() => {
let ranges: Range[] = [];
for (let i = 0; i < unitMeasurements.length; i++) {
ranges.push({
min: "dataMax",
max: "dataMin"
});
}
setGraphRanges(ranges);
}, [unitMeasurements]);
//builds the reference lines to show on the graph
const getInteractionLines = (measurementType: quack.MeasurementType) => {
let selectedInteractions: JSX.Element[] = [];
interactions.forEach(interaction => {
if (filters?.selectedInteractions?.includes(interaction.key())) {
interaction.settings.conditions.forEach((condition, j) => {
if (condition.measurementType === measurementType) {
let describer = describeMeasurement(condition.measurementType);
selectedInteractions.push(
<ReferenceLine
key={interaction.key() + ":condition" + j}
y={describer.toDisplay(condition.value)}
ifOverflow="extendDomain"
stroke="red"
strokeDasharray={"5 2"}
/>
);
}
});
}
});
return selectedInteractions;
};
//builds the overlays to show on the graph
const getOverlays = (measurementType: quack.MeasurementType) => {
let overlays: JSX.Element[] = [];
component.settings.overlays.forEach((overlay, i) => {
if (
overlay.measurementType === measurementType &&
filters?.selectedOverlays?.includes(i.toString())
) {
overlays.push(
<ReferenceArea
key={i}
y1={overlay.max}
y2={overlay.min}
stroke={overlay.colour}
strokeWidth={2}
opacity={0.75}
fill={overlay.colour}
ifOverflow="extendDomain">
{/* could possibly show overlay message here */}
</ReferenceArea>
);
}
});
return overlays;
};
const buildGraphs = (
unitMeasurements: UnitMeasurement[],
component: Component,
filters?: GraphFilters
) => {
let graphs: JSX.Element[] = [];
let hasData = false;
let noData: JSX.Element = (
<Box width="100%" height={350} paddingTop={15}>
<Typography variant="h6" color="textPrimary" align="center">
Not enough data to display
</Typography>
<Typography variant="subtitle2" color="textSecondary" align="center">
{"(Queried between " +
moment(startDate).calendar() +
" and " +
moment(endDate).calendar() +
")"}
</Typography>
</Box>
);
let elem: JSX.Element;
let graph: JSX.Element = noData;
let numBasicGraphs = unitMeasurements.length - 1;
let ext = extension(component.type(), component.subType());
unitMeasurements.forEach((measurements, i) => {
let m = pond.UnitMeasurementsForComponent.fromObject(measurements);
let describer = describeMeasurement(
m.type,
component.settings.type,
component.settings.subtype
);
let min: string | number = "dataMin";
let max: string | number = "dataMax";
if (graphRanges[i]) {
min = graphRanges[i].min;
max = graphRanges[i].max;
}
if (showMultilineCable(component.type(), filters)) {
let lineChartData = ext.lineChartData(m, component.settings.smoothingAverages, filters);
let lineData: LineData[] = lineChartData.lines;
let averagedData: LineData[] = lineChartData.average;
if (lineData.length > 0) {
hasData = true;
graph = (
<MultiLineGraph
yMin={min}
yMax={max}
customHeight={isMobile ? 200 : 350}
lineData={showOriginal ? lineData : []}
averagedData={showAveraged ? averagedData : []}
numLines={m.values.length > 1 ? m.values[0].values.length : 0}
describer={describer}
interactionLines={getInteractionLines(m.type)}
overlays={getOverlays(m.type)}
tooltip
newXDomain={xDomain}
multiGraphZoom={domain => {
zoomIn(domain);
}}
multiGraphZoomOut
/>
);
}
} else {
switch (describer.graph()) {
case GraphType.AREA:
let chartData = ext.areaChartData(m, component.settings.smoothingAverages, filters);
let areaData: AreaData[] = chartData.area;
let avgData: AreaData[] = chartData.average;
if (areaData.length > 0) {
hasData = true;
graph = (
<AreaGraph
customHeight={isMobile ? 200 : 350}
yMin={min}
yMax={max}
data={showOriginal ? areaData : []}
averagedData={showAveraged ? avgData : []}
describer={describer}
interactionLines={getInteractionLines(m.type)}
overlays={getOverlays(m.type)}
tooltip
newXDomain={xDomain}
multiGraphZoom={domain => {
zoomIn(domain);
}}
multiGraphZoomOut
/>
);
}
break;
//for the moment not going to worry about these graphs and possibly just make them as needed
case GraphType.SCATTER:
break;
case GraphType.RADAR:
break;
case GraphType.BAR:
break;
default:
//line graph
let lineChartData = ext.lineChartData(m, component.settings.smoothingAverages, filters);
let lineData: LineData[] = lineChartData.lines;
let averagedData: LineData[] = lineChartData.average;
if (lineData.length > 0) {
hasData = true;
graph = (
<MultiLineGraph
yMin={min}
yMax={max}
customHeight={isMobile ? 200 : 350}
lineData={showOriginal ? lineData : []}
averagedData={showAveraged ? averagedData : []}
numLines={m.values.length > 1 ? m.values[0].values.length : 0}
describer={describer}
interactionLines={getInteractionLines(m.type)}
overlays={getOverlays(m.type)}
tooltip
newXDomain={xDomain}
multiGraphZoom={domain => {
zoomIn(domain);
}}
multiGraphZoomOut
/>
);
}
break;
}
}
elem = (
<React.Fragment key={i}>
<Card raised style={{ width: "100%", margin: 5 }}>
<CardContent>
<Box display="flex">
<Box width="97%">{graph}</Box>
<Box justifyContent="center">
<IconButton
onClick={() => {
if (i + 1 > numBasicGraphs) {
setCurrentMutator(component.settings.defaultMutations[i - numBasicGraphs]);
} else {
setCurrentMutator(undefined);
}
setGraphSettingsDialog(true);
setRangeIndex(i);
setNewChart(false);
if (graphRanges[i]) {
setCurrentMax(max);
setCurrentMin(min);
}
}}>
<GroupSettingsIcon />
</IconButton>
</Box>
</Box>
</CardContent>
</Card>
</React.Fragment>
);
graphs.push(elem);
});
return hasData ? graphs : noData;
};
return (
<React.Fragment>
{!isMobile ? (
<Grid container direction="row" justifyContent="space-between" style={{ marginBottom: 10 }}>
<Grid>
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
</Grid>
<Grid>
<FormControlLabel
label="Show Errors"
control={
<Checkbox
checked={showErrors}
onChange={() => {
//setShowErrors(!showErrors);
dispatch({ key: "showErrors", value: !showErrors });
}}
/>
}
/>
</Grid>
{/* <Grid item>
{zoomed && (
<Button
variant="outlined"
onClick={() => {
if (zoomStart && zoomEnd) {
updateDateRange(zoomStart, zoomEnd, false);
setXDomain(["dataMin", "dataMax"]);
}
}}>
Enhance
</Button>
)}
{zoomed && (
<Button style={{ marginLeft: 5 }} variant="outlined" onClick={zoomOut}>
Zoom Out
</Button>
)}
</Grid> */}
</Grid>
) : (
<TimeBar updateDateRange={updateDateRange} startDate={startDate} endDate={endDate} />
)}
{buildGraphs(unitMeasurements, component, filters)}
<Button
onClick={() => {
setCurrentMutator(pond.Mutator.MUTATOR_NONE);
setGraphSettingsDialog(true);
setNewChart(true);
}}
variant="contained"
style={{ width: "100%", height: 50, background: green["500"] }}>
+
</Button>
<GraphSettings
open={graphSettingsDialog}
closeDialog={() => {
setGraphSettingsDialog(false);
}}
newChart={newChart}
component={component}
mutation={currentMutator}
currentMin={currentMin}
currentMax={currentMax}
adjustMin={val => {
graphRanges[rangeIndex].min = val;
}}
adjustMax={val => {
graphRanges[rangeIndex].max = val;
}}
/>
</React.Fragment>
);
}

View file

@ -0,0 +1,213 @@
import {
Box,
Button,
useTheme
} from "@mui/material";
import MaterialChartTooltip from "charts/MaterialChartTooltip";
import moment from "moment";
import { MeasurementDescriber } from "pbHelpers/MeasurementDescriber";
import { useGlobalState } from "providers";
import { useEffect, useState } from "react";
import {
AreaChart,
Area,
XAxis,
ResponsiveContainer,
YAxis,
Tooltip,
TooltipProps,
ReferenceArea
} from "recharts";
import { roundTo } from "utils";
export interface AreaData {
timestamp: number;
value: [number, number];
}
interface Props {
data: AreaData[];
averagedData?: AreaData[];
describer: MeasurementDescriber;
interactionLines?: JSX.Element[];
overlays?: JSX.Element[];
yMin?: number | string;
yMax?: number | string;
tooltip?: boolean;
newXDomain?: number[] | string[];
multiGraphZoom?: (domain: number[] | string[]) => void;
multiGraphZoomOut?: boolean;
customHeight?: string | number;
}
export default function AreaGraph(props: Props) {
const {
data,
describer,
interactionLines,
overlays,
yMin,
yMax,
tooltip,
newXDomain,
multiGraphZoom,
multiGraphZoomOut,
customHeight,
averagedData
} = props;
const theme = useTheme();
const now = moment();
const [refLeft, setRefLeft] = useState<number | undefined>();
const [refRight, setRefRight] = useState<number | undefined>();
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
// const [{ newStructure }] = useGlobalState();
const [displayData, setDisplayData] = useState<any[]>([]);
useEffect(() => {
if (newXDomain) {
setXDomain(newXDomain);
}
}, [newXDomain]);
useEffect(() => {
let d: any[] = [];
if (data) {
data.forEach(areaData => {
let pointSet: any = {
timestamp: areaData.timestamp
};
pointSet[describer.label()] = areaData.value;
d.push(pointSet);
});
}
if (averagedData) {
averagedData.forEach(areaData => {
let pointSet: any = {
timestamp: areaData.timestamp
};
pointSet[describer.label() + "(avg)"] = areaData.value;
d.push(pointSet);
});
}
setDisplayData(d);
}, [data, averagedData, describer]);
const zoom = () => {
let newDomain: number[] | string[] = ["dataMin", "dataMax"];
if (refLeft && refRight && refLeft !== refRight) {
refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
setRefLeft(undefined);
setRefRight(undefined);
if (multiGraphZoom) {
multiGraphZoom(newDomain);
} else {
setXDomain(newDomain);
}
}
};
const zoomOut = () => {
setXDomain(["dataMin", "dataMax"]);
};
return (
<Box>
{!multiGraphZoomOut && (
<Button variant="outlined" onClick={zoomOut}>
Zoom Out
</Button>
)}
<ResponsiveContainer width={"100%"} height={customHeight ?? 350}>
<AreaChart
data={displayData}
onMouseDown={(e: any) => {
if (e) {
setRefLeft(e.activeLabel);
}
}}
onMouseMove={(e: any) => {
if (e) {
setRefRight(e.activeLabel);
}
}}
onMouseUp={() => {
setRefLeft(undefined);
setRefRight(undefined);
zoom();
}}>
<YAxis
domain={[yMin || yMin === 0 ? yMin : "auto", yMax || yMax === 0 ? yMax : "auto"]}
label={{
value: describer.label() + " (" + describer.unit() + ")",
position: "insideLeft",
angle: -90,
fill: theme.palette.text.primary
}}
tick={{ fill: theme.palette.text.primary }}
allowDataOverflow
/>
{tooltip && (
<Tooltip
animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
labelFormatter={timestamp => moment(timestamp).format("lll")}
content={(props: TooltipProps<any, any>) => {
return (
<MaterialChartTooltip
{...props}
valueFormatter={value => {
let str = "";
if (Array.isArray(value)) {
str = value[0].toFixed(1) + ", " + value[1].toFixed(1);
} else {
str = describer.convertWithoutUnits(
roundTo(parseFloat(String(value)), 2),
// newStructure
);
}
return str + describer.GetUnit();
}}
/>
);
}}
/>
)}
<XAxis
allowDataOverflow
dataKey="timestamp"
domain={xDomain}
name="Time"
tickFormatter={timestamp => {
let t = moment(timestamp);
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
}}
scale="time"
type="number"
tick={{ fill: theme.palette.text.primary }}
stroke={theme.palette.divider}
interval="preserveStartEnd"
/>
<Area
dataKey={describer.label()}
fill={describer.colour() + "75"}
stroke={describer.colour()}
strokeWidth={3}
type="monotone"
/>
<Area
dataKey={describer.label() + "(avg)"}
fill={"white"}
stroke={"white"}
strokeWidth={3}
type="monotone"
/>
{refLeft && refRight ? (
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.3} />
) : null}
{interactionLines}
{overlays}
</AreaChart>
</ResponsiveContainer>
</Box>
);
}

View file

@ -0,0 +1,259 @@
import { Box, Button, useTheme } from "@material-ui/core";
import MaterialChartTooltip from "charts/MaterialChartTooltip";
import moment from "moment";
import { MeasurementDescriber } from "pbHelpers/MeasurementDescriber";
import { useGlobalState } from "providers";
import React, { useEffect, useState } from "react";
import {
Legend,
Line,
LineChart,
ReferenceArea,
ResponsiveContainer,
Tooltip,
TooltipProps,
XAxis,
YAxis
} from "recharts";
import { roundTo } from "utils";
export interface LineData {
timestamp: number;
points: Point[];
}
export interface Point {
value: number;
node: number;
}
interface Props {
lineData: LineData[];
averagedData?: LineData[];
numLines: number;
describer: MeasurementDescriber;
interactionLines?: JSX.Element[];
overlays?: JSX.Element[];
customHeight?: string | number;
tooltip?: boolean;
yMin?: number | string;
yMax?: number | string;
hideY?: boolean;
newXDomain?: number[] | string[];
multiGraphZoom?: (domain: number[] | string[]) => void;
multiGraphZoomOut?: boolean;
}
export default function MultiLineGraph(props: Props) {
const {
lineData,
numLines,
describer,
interactionLines,
overlays,
customHeight,
tooltip,
yMin,
yMax,
hideY,
newXDomain,
multiGraphZoom,
multiGraphZoomOut,
averagedData
} = props;
const [data, setData] = useState<any[]>([]);
const theme = useTheme();
const now = moment();
const [refLeft, setRefLeft] = useState<number | undefined>();
const [refRight, setRefRight] = useState<number | undefined>();
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [{ newStructure }] = useGlobalState();
useEffect(() => {
if (newXDomain) {
setXDomain(newXDomain);
}
}, [newXDomain]);
useEffect(() => {
let ld: any[] = [];
lineData.forEach(line => {
let lines = {
timestamp: line.timestamp
} as any;
line.points.forEach(point => {
let nodeLabel = (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label();
let nodeDetails = describer.nodeDetails();
if (nodeDetails) {
nodeLabel = nodeDetails.labels[point.node];
}
lines[nodeLabel] = point.value;
});
ld.push(lines);
});
if (averagedData) {
averagedData.forEach(line => {
let lines = {
timestamp: line.timestamp
} as any;
line.points.forEach(point => {
let nodeLabel =
(numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label() + "(avg)";
let nodeDetails = describer.nodeDetails();
if (nodeDetails) {
nodeLabel = nodeDetails.labels[point.node] + "(avg)";
}
lines[nodeLabel] = point.value;
});
ld.push(lines);
});
}
setData(ld);
}, [lineData, describer, numLines, averagedData]);
const buildLines = () => {
let lines: JSX.Element[] = [];
if (lineData.length > 0) {
lineData[0].points.forEach(point => {
let nodeLabel = (numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label();
let nodeColour = describer.colour();
let nodeDetails = describer.nodeDetails();
if (nodeDetails) {
nodeLabel = nodeDetails.labels[point.node];
nodeColour = nodeDetails.colours[point.node];
}
lines.push(
<Line
strokeWidth={4}
key={nodeLabel}
dataKey={nodeLabel}
stroke={nodeColour}
type="monotone"
/>
);
});
}
if (averagedData && averagedData.length > 0) {
averagedData[0].points.forEach(point => {
let nodeLabel =
(numLines > 1 ? "Node " + (point.node + 1) + " " : "") + describer.label() + "(avg)";
let nodeDetails = describer.nodeDetails();
if (nodeDetails) {
nodeLabel = nodeDetails.labels[point.node] + "(avg)";
}
lines.push(
<Line
strokeWidth={5}
key={nodeLabel}
dataKey={nodeLabel}
stroke={"white"}
type="monotone"
/>
);
});
}
return lines;
};
const zoom = () => {
let newDomain: number[] | string[] = ["dataMin", "dataMax"];
if (refLeft && refRight && refLeft !== refRight) {
refLeft < refRight ? (newDomain = [refLeft, refRight]) : (newDomain = [refRight, refLeft]);
setRefLeft(undefined);
setRefRight(undefined);
if (multiGraphZoom) {
multiGraphZoom(newDomain);
} else {
setXDomain(newDomain);
}
}
};
const zoomOut = () => {
setXDomain(["dataMin", "dataMax"]);
};
return (
<Box height={customHeight ?? 350}>
{!multiGraphZoomOut && (
<Button variant="outlined" onClick={zoomOut}>
Zoom Out
</Button>
)}
<ResponsiveContainer width={"100%"} height={"100%"}>
<LineChart
data={data}
onMouseDown={(e: any) => {
if (e) {
setRefLeft(e.activeLabel);
}
}}
onMouseMove={(e: any) => {
if (e) {
setRefRight(e.activeLabel);
}
}}
onMouseUp={() => {
setRefLeft(undefined);
setRefRight(undefined);
zoom();
}}>
{describer.nodeDetails() && <Legend verticalAlign="top" />}
{tooltip && (
<Tooltip
animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
labelFormatter={timestamp => moment(timestamp).format("lll")}
content={(props: TooltipProps<any, any>) => {
return (
<MaterialChartTooltip
{...props}
valueFormatter={value => {
return describer.convertWithUnits(
roundTo(parseFloat(String(value)), 2),
newStructure
);
}}
/>
);
}}
/>
)}
<YAxis
axisLine={hideY}
domain={[yMin || yMin === 0 ? yMin : "auto", yMax || yMax === 0 ? yMax : "auto"]}
label={{
value: describer.label() + " (" + describer.unit() + ")",
position: "insideLeft",
angle: -90,
fill: theme.palette.text.primary
}}
tick={hideY ? false : { fill: theme.palette.text.primary }}
allowDataOverflow
/>
<XAxis
allowDataOverflow
dataKey="timestamp"
domain={xDomain}
name="Time"
tickFormatter={timestamp => {
let t = moment(timestamp);
return now.isSame(t, "day") ? t.format("LT") : t.format("MMM DD");
}}
scale="time"
type="number"
tick={{ fill: theme.palette.text.primary }}
stroke={theme.palette.divider}
interval="preserveStartEnd"
/>
{refLeft && refRight ? (
<ReferenceArea x1={refLeft} x2={refRight} strokeOpacity={0.3} />
) : null}
{buildLines()}
{interactionLines}
{overlays}
</LineChart>
</ResponsiveContainer>
</Box>
);
}