modified the SingleSetAreaChart to take in a few more parameters to be able to customize the tooltip a little better as well as have multicolour for above 0 and below 0 for the new graph on the gate page for showing the delta temp over time

This commit is contained in:
csawatzky 2026-02-05 11:39:21 -06:00
parent 5f44cf6f09
commit caf9fada15
6 changed files with 235 additions and 9 deletions

View file

@ -1,9 +1,10 @@
import { useTheme } from "@mui/material"; import { useTheme } from "@mui/material";
import moment from "moment"; import moment from "moment";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { import {
Area, Area,
AreaChart, AreaChart,
Legend,
ReferenceArea, ReferenceArea,
ReferenceLine, ReferenceLine,
ResponsiveContainer, ResponsiveContainer,
@ -13,25 +14,38 @@ import {
YAxis YAxis
} from "recharts"; } from "recharts";
import MaterialChartTooltip from "./MaterialChartTooltip"; import MaterialChartTooltip from "./MaterialChartTooltip";
import { blue, orange } from "@mui/material/colors";
import { Payload } from "recharts/types/component/DefaultLegendContent";
export interface SSAreaDataPoint { export interface SSAreaDataPoint {
timestamp: number; timestamp: number;
value: number; value: number;
} }
export interface ColourData {
colour: string,
label: string
}
interface Props { interface Props {
data: SSAreaDataPoint[]; data: SSAreaDataPoint[];
tooltipLabel: string
tooltipUnit?: string
yAxisLabel: string
maxRef?: number; maxRef?: number;
minRef?: number; minRef?: number;
newXDomain?: number[] | string[]; newXDomain?: number[] | string[];
multiGraphZoom?: (domain: number[] | string[]) => void; multiGraphZoom?: (domain: number[] | string[]) => void;
colourAboveZero?: ColourData
colourBelowZero?: ColourData
} }
export default function SingleSetAreaChart(props: Props) { export default function SingleSetAreaChart(props: Props) {
const { data, maxRef, minRef, newXDomain, multiGraphZoom } = props; const { data, maxRef, minRef, newXDomain, multiGraphZoom, yAxisLabel, colourAboveZero, colourBelowZero, tooltipLabel, tooltipUnit } = props;
const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]); const [xDomain, setXDomain] = useState<string[] | number[]>(["dataMin", "dataMax"]);
const [refLeft, setRefLeft] = useState<number | undefined>(); const [refLeft, setRefLeft] = useState<number | undefined>();
const [refRight, setRefRight] = useState<number | undefined>(); const [refRight, setRefRight] = useState<number | undefined>();
const [legendPayload, setLegendPayload] = useState<Payload[]>([])
const theme = useTheme(); const theme = useTheme();
const now = moment(); const now = moment();
@ -41,6 +55,40 @@ export default function SingleSetAreaChart(props: Props) {
} }
}, [newXDomain]); }, [newXDomain]);
useEffect(() => {
let legend: Payload[] = []
if(colourAboveZero){
legend.push({
value: colourAboveZero.label,
color: colourAboveZero.colour,
id: colourAboveZero.label,
type: "square"
})
}
if(colourBelowZero){
legend.push({
value: colourBelowZero.label,
color: colourBelowZero.colour,
id: colourBelowZero.label,
type: "square"
})
}
setLegendPayload(legend)
}, [colourAboveZero, colourBelowZero])
const gradientOffset = useMemo(() => {
if (!data.length) return 0;
const values = data.map(p => p.value);
const max = Math.max(...values);
const min = Math.min(...values);
if (max <= 0) return 0;
if (min >= 0) return 1;
return max / (max - min);
}, [data]);
const zoom = () => { const zoom = () => {
let newDomain: number[] | string[] = ["dataMin", "dataMax"]; let newDomain: number[] | string[] = ["dataMin", "dataMax"];
if (refLeft && refRight && refLeft !== refRight) { if (refLeft && refRight && refLeft !== refRight) {
@ -75,12 +123,16 @@ export default function SingleSetAreaChart(props: Props) {
setRefRight(undefined); setRefRight(undefined);
zoom(); zoom();
}}> }}>
<Legend
verticalAlign="top"
payload={legendPayload}
/>
<Tooltip <Tooltip
animationEasing="ease-out" animationEasing="ease-out"
cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }} cursor={{ fill: theme.palette.text.primary, opacity: "0.15" }}
labelFormatter={timestamp => moment(timestamp).format("lll")} labelFormatter={timestamp => moment(timestamp).format("lll")}
content={(props: TooltipProps<any, any>) => ( content={(props: TooltipProps<any, any>) => (
<MaterialChartTooltip {...props} valueFormatter={value => `${value}`} /> <MaterialChartTooltip {...props} valueFormatter={value => `${value}` + (tooltipUnit ? tooltipUnit : "")} />
)} )}
/> />
<XAxis <XAxis
@ -102,13 +154,26 @@ export default function SingleSetAreaChart(props: Props) {
type="number" type="number"
domain={["auto", "auto"]} domain={["auto", "auto"]}
label={{ label={{
value: "Mass Flow (kg/s)", value: yAxisLabel,
position: "insideLeft", position: "insideLeft",
angle: -90, angle: -90,
fill: theme.palette.text.primary fill: theme.palette.text.primary
}} }}
/> />
<Area dataKey={"value"} strokeWidth={3} /> <defs>
{colourAboveZero && colourBelowZero &&
<linearGradient id="solidColour" x1="0" y1="0" x2="0" y2="1">
<stop offset={gradientOffset} stopColor={colourAboveZero.colour} stopOpacity="75%" />
<stop offset={gradientOffset} stopColor={colourBelowZero.colour} stopOpacity="75%" />
</linearGradient>
}
</defs>
<Area dataKey={"value"} strokeWidth={3}
type="monotone"
name={tooltipLabel}
stroke={(colourAboveZero && colourBelowZero) && "url(#solidColour)"}
fill={(colourAboveZero && colourBelowZero) && "url(#solidColour)"}
/>
<ReferenceLine <ReferenceLine
ifOverflow="extendDomain" ifOverflow="extendDomain"
y={maxRef} y={maxRef}

View file

@ -0,0 +1,152 @@
import { Box, Card, CardHeader, Grid2, Typography } from "@mui/material"
import { blue, orange } from "@mui/material/colors"
import SingleSetAreaChart, { SSAreaDataPoint } from "charts/SingleSetAreaChart"
import { useComponentAPI } from "hooks"
import { cloneDeep } from "lodash"
import { Component } from "models"
import { UnitMeasurement } from "models/UnitMeasurement"
import moment from "moment"
import { Moment } from "moment"
import { pond } from "protobuf-ts/pond"
import { quack } from "protobuf-ts/quack"
import { useGlobalState } from "providers"
import { useEffect, useState } from "react"
import { getTemperatureUnit } from "utils"
interface Props {
//the key for the temperature chain, this may be overhauled to only be a single node in the future for the outlet and the 'inlet' would be the ambient
deviceID: number
tempComponent: Component
ambient?: Component
start: Moment;
end: Moment;
}
export default function GateDeltaTempGraph(props: Props){
const {
tempComponent,
ambient,
deviceID,
start,
end
} = props
const [data, setData] = useState<SSAreaDataPoint[]>([])
const [{user}] = useGlobalState()
const componentAPI = useComponentAPI();
const [recent, setRecent] = useState<SSAreaDataPoint>()
const warmingColour = orange["500"]
const coolingColour = blue["500"]
/**
* the use effect will use the temp component passed in get the measurements, then use the first node and the last node to determine the difference,
* if there is only one node, or only one node is reading due to errors, then it will use that as both the inlet and outlet so the delta will be 0,
*
* //NOT IMPLEMENTED YET//
* if an ambient is passed in it will use the readings from the ambient as the inlet and the temp component as the outlet
*/
useEffect(()=>{
//get the measurements for the temp component
componentAPI.listUnitMeasurements(
deviceID,
tempComponent.key(),
start.toISOString(),
end.toISOString(),
500,
0,
"timestamp",
).then(resp => {
if(resp.data.measurements){
let tempCompMeasurements = resp.data.measurements.map(um => UnitMeasurement.any(um, user));
//if there is an ambient component, then treat the temp component like a single sensor and not a chain, and use the ambient measurements as the inlet
if(ambient){
//TODO: this is just an idea of how to handle a possible path for the overhaul of gates, if that is the route we take then this needs to be coded
}else{//else no ambient is passed in treat the temp component like a chain
let deltas: SSAreaDataPoint[] = []
tempCompMeasurements.forEach(um => {
if (um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE){
um.values.forEach((nodes, index) => {
if (nodes.values && nodes.values.length > 0 && um.timestamps[index]){
//use the first node as the inlet
let inletNode = nodes.values[0]
//use the second node as the outlet, if there is an error or there is only one node then it will be used as both and the delta will be 0
let outletNode = nodes.values[nodes.values.length -1]
let newDelta: SSAreaDataPoint = {
value: Math.round((outletNode - inletNode)*100)/100,
timestamp: moment(um.timestamps[index]).valueOf(),
}
deltas.push(newDelta)
}
})
}
})
setData(deltas)
//use the components status to set the recent to show in the header of the card
let recent: SSAreaDataPoint | undefined
if(tempComponent.status.measurement){
tempComponent.status.measurement.forEach(um => {
if(um.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE){
let clone = cloneDeep(um)
let m = UnitMeasurement.create(clone, user)
if(m.values.length > 0){
let lastReading = m.values[m.values.length - 1]
if (lastReading.values.length > 0){
recent = {
value: lastReading.values[lastReading.values.length -1] - lastReading.values[0],
timestamp: moment(m.timestamps[m.values.length - 1]).valueOf()
}
}
}
}
})
}
setRecent(recent)
}
}
})
},[tempComponent, ambient, deviceID, start, end])
return (
<Card raised style={{ padding: 10, marginBottom: 15, marginTop: 15 }}>
<CardHeader
title={<Typography style={{ fontSize: 25, fontWeight: 650 }}>Delta Temp</Typography>}
subheader={
recent ?
(
<Grid2 container>
<Grid2 size={12}>
<span>
{"Delta Temp: "}
<span style={{ color: recent.value > 0 ? warmingColour : coolingColour, fontWeight: 500 }}>
{recent.value.toFixed(2) + (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")}
</span>
<br />
</span>
</Grid2>
<Grid2 size={12}>
<Typography color="textSecondary" variant={"caption"}>
{moment(recent.timestamp).fromNow()}
</Typography>
</Grid2>
</Grid2>
)
: (
<Typography variant="body1" color="textPrimary">
No Data
</Typography>
)
}
/>
<SingleSetAreaChart
data={data}
tooltipLabel="Delta Temp"
tooltipUnit={getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C"}
yAxisLabel={"Delta T" + (getTemperatureUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT ? " °F" : " °C")}
colourAboveZero={{colour: warmingColour, label: "Heating"}}
colourBelowZero={{colour: coolingColour, label: "Cooling"}}/>
</Card>
)
}

View file

@ -209,6 +209,8 @@ export default function GateFlowGraph(props: Props) {
{flowData.length !== 0 ? ( {flowData.length !== 0 ? (
<SingleSetAreaChart <SingleSetAreaChart
data={flowData} data={flowData}
tooltipLabel="Flow"
yAxisLabel="Mass Flow (kg/s)"
maxRef={gate.upperFlow()} maxRef={gate.upperFlow()}
minRef={gate.lowerFlow()} minRef={gate.lowerFlow()}
newXDomain={newXDomain} newXDomain={newXDomain}

View file

@ -28,12 +28,13 @@ import { avg } from "utils";
import GateFlowGraph from "./GateFlowGraph"; import GateFlowGraph from "./GateFlowGraph";
import { makeStyles } from "@mui/styles"; import { makeStyles } from "@mui/styles";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import GateDeltaTempGraph from "./GateDeltaTempGraph";
interface Props { interface Props {
gate: Gate; gate: Gate;
display: "analytics" | "sensors"; display: "analytics" | "sensors";
compMap: Map<string, Component>; compMap: Map<string, Component>;
device: string | number; device: number;
pressure: string; pressure: string;
ambient: string; ambient: string;
tempChain: string; tempChain: string;
@ -297,7 +298,7 @@ export default function GateGraphs(props: Props) {
} }
/** /**
* This function creates the charts for the gate page using the MAIN components on the gate, it DOES NOT show any other components that could be on the device * This function creates the charts for the gate page using the MAIN components on the gate, and delta time, it DOES NOT show any other components that could be on the device
* @returns list of cards for the charts * @returns list of cards for the charts
*/ */
const sensorGraphs = () => { const sensorGraphs = () => {
@ -309,6 +310,8 @@ export default function GateGraphs(props: Props) {
if(pressureComp && pressureReadings){ if(pressureComp && pressureReadings){
graphs.push(graphCard(pressureComp, pressureReadings)) graphs.push(graphCard(pressureComp, pressureReadings))
} }
// maybe also put the delta time graph here as well
//T/H chain //T/H chain
let tempComp = compMap.get(tempChain) let tempComp = compMap.get(tempChain)
let tempReadings = compMeasurements.get(tempChain) let tempReadings = compMeasurements.get(tempChain)
@ -337,6 +340,7 @@ export default function GateGraphs(props: Props) {
} }
const analyticGraphs = () => { const analyticGraphs = () => {
let tempComp = compMap.get(tempChain)
return ( return (
<Box> <Box>
<GateFlowGraph <GateFlowGraph
@ -355,6 +359,10 @@ export default function GateGraphs(props: Props) {
setZoomed(true); setZoomed(true);
}} }}
/> />
{/* add a new chart here for the delta temp over time */}
{tempComp &&
<GateDeltaTempGraph deviceID={device} start={startDate} end={endDate} tempComponent={tempComp}/>
}
</Box> </Box>
); );
}; };

View file

@ -311,7 +311,7 @@ export default function GateList(props: Props) {
<Typography>{terminalMap.get(gate.terminal()) ?? "None"}</Typography> <Typography>{terminalMap.get(gate.terminal()) ?? "None"}</Typography>
</Box> </Box>
<Box padding={2} display="flex" flexDirection="row" justifyContent="space-between"> <Box padding={2} display="flex" flexDirection="row" justifyContent="space-between">
<Typography>Conditioning:</Typography> <Typography>Delta Temp:</Typography>
{conditionDisplay(gate)} {conditionDisplay(gate)}
</Box> </Box>
{/* taking these out for now because we are not sure if the customer wants them */} {/* taking these out for now because we are not sure if the customer wants them */}

View file

@ -134,7 +134,6 @@ export default function Gate(props: Props) {
gateAPI gateAPI
.getGatePageData(id, as) .getGatePageData(id, as)
.then(resp => { .then(resp => {
console.log(resp.data);
let p = new Map<number, pond.GateDeviceType>(); let p = new Map<number, pond.GateDeviceType>();
Object.keys(resp.data.preferences).forEach(k => { Object.keys(resp.data.preferences).forEach(k => {
let prefKey = parseInt(k); let prefKey = parseInt(k);