diff --git a/src/bin/binModes/DryingMode.tsx b/src/bin/binModes/DryingMode.tsx index 716e726..8712dca 100644 --- a/src/bin/binModes/DryingMode.tsx +++ b/src/bin/binModes/DryingMode.tsx @@ -465,7 +465,6 @@ export default function DryingMode(props: Props){ //this step will use the conflicting interactions and interactions to add to display them and allow users to make changes const interactionStep = () => { - console.log(interactionsToAdd) return ( Interactions @@ -474,13 +473,14 @@ export default function DryingMode(props: Props){ temp.settings = interaction let sink = sinkMap.get(componentIDToString(interaction.sink)) let source = sourceMap.get(componentIDToString(interaction.source)) - if(sink && source){ + if(sink && source && selectedDevice){ return ( { diff --git a/src/bin/binModes/HydratingMode.tsx b/src/bin/binModes/HydratingMode.tsx index 9f000ba..79a11ca 100644 --- a/src/bin/binModes/HydratingMode.tsx +++ b/src/bin/binModes/HydratingMode.tsx @@ -339,13 +339,14 @@ export default function DryingMode(props: Props){ temp.settings = interaction let sink = sinkMap.get(componentIDToString(interaction.sink)) let source = sourceMap.get(componentIDToString(interaction.source)) - if(sink && source){ + if(sink && source && selectedDevice){ return ( { diff --git a/src/bin/binModes/conditionDisplay.tsx b/src/bin/binModes/conditionDisplay.tsx index d44c3ba..0a26e51 100644 --- a/src/bin/binModes/conditionDisplay.tsx +++ b/src/bin/binModes/conditionDisplay.tsx @@ -5,19 +5,21 @@ import { Box, darken, Grid, + IconButton, Slider, Theme, + Tooltip, Typography } from "@mui/material"; - import { ExpandMore } from "@mui/icons-material"; + import { AddCircle, ExpandMore, RemoveCircleOutline } from "@mui/icons-material"; import { ExtractMoisture } from "grain"; import { cloneDeep } from "lodash"; - import { Component, Interaction } from "models"; + import { Component, Device, Interaction } from "models"; import { interactionConditionText, interactionResultText } from "pbHelpers/Interaction"; import { describeMeasurement } from "pbHelpers/MeasurementDescriber"; import { pond, quack } from "protobuf-ts/pond"; import { useGlobalState } from "providers"; - import React, { useEffect, useState } from "react"; + import React, { useEffect, useMemo, useState } from "react"; import { avg, fahrenheitToCelsius } from "utils"; import { makeStyles } from "@mui/styles"; import { Mark } from "@mui/material/Slider/useSlider.types"; @@ -38,7 +40,6 @@ import { height: 0, borderLeft: "10px solid transparent", borderRight: "10px solid transparent" - //borderTop: "10px solid yellow" }, sliderRoot: {}, sliderThumb: { @@ -71,12 +72,18 @@ import { }, sliderMarkLabel: { top: -25 + }, + groupHeader: { + marginTop: 10, + marginBottom: 5, + fontWeight: 650 } }); }); interface Props { interaction: Interaction; + device: Device; source: Component; sink: Component; grain?: pond.Grain; @@ -84,57 +91,214 @@ import { changeConditions?: (newSettings: pond.InteractionSettings) => void } + // A "corner" pairing: one temp condition + one humidity condition whose + // comparison directions are consistent with each other, so together they + // bound a single EMC value. + // temp > X && humid < Y -> bounds EMC from ABOVE (an upper limit) + // temp < X && humid > Y -> bounds EMC from BELOW (a lower limit) + interface EMCCornerPair { + temp: pond.InteractionCondition; + humid: pond.InteractionCondition; + } + + interface PairedConditions { + upperBoundPair?: EMCCornerPair; // temp > X && humid < Y + lowerBoundPair?: EMCCornerPair; // temp < X && humid > Y + unmatched: pond.InteractionCondition[]; + } + + const isGTorEQ = (op: quack.RelationalOperator) => + op === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN || + op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO; + + const isLTorEQ = (op: quack.RelationalOperator) => + op === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN || + op === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO; + + // Groups the flat condition array into (at most) two EMC-bounding corners, + // based purely on comparison direction, not array position/order. + function pairEMCConditions(conditions: pond.InteractionCondition[]): PairedConditions { + const temps = conditions.filter( + c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE + ); + const humids = conditions.filter( + c => c.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT + ); + + const hiTemp = temps.find(c => isGTorEQ(c.comparison)); // temp > X + const loTemp = temps.find(c => isLTorEQ(c.comparison)); // temp < X + const loHumid = humids.find(c => isLTorEQ(c.comparison)); // humid < Y + const hiHumid = humids.find(c => isGTorEQ(c.comparison)); // humid > Y + + const result: PairedConditions = { unmatched: [] }; + + if (hiTemp && loHumid) result.upperBoundPair = { temp: hiTemp, humid: loHumid }; + if (loTemp && hiHumid) result.lowerBoundPair = { temp: loTemp, humid: hiHumid }; + + const paired = new Set( + [hiTemp, loHumid, loTemp, hiHumid].filter((c): c is pond.InteractionCondition => !!c) + ); + result.unmatched = conditions.filter(c => !paired.has(c)); + + return result; + } + export default function ConditionDisplay(props: Props) { - const { interaction, source, sink, grain, customGrain, changeConditions} = props; - const [sliderVals, setSliderVals] = useState>(new Map()); + const { interaction, device, source, sink, grain, customGrain, changeConditions } = props; + const [sliderVals, setSliderVals] = useState>(new Map()); const [sliderMarks, setSliderMarks] = useState>(new Map()); - //this is the emc value calculated from the interactions temp and humidity conditions - const [baseEMC, setBaseEMC] = useState(); + // EMC bound(s) calculated from the temp/humidity condition pairs. + // If only one pair exists, only one of these will be set. + const [upperEMC, setUpperEMC] = useState(); + const [lowerEMC, setLowerEMC] = useState(); const [{ user }] = useGlobalState(); const classes = useStyles(); + // Bumped whenever conditions are added/removed so `pairs` recomputes. + // Needed because pushing into interaction.settings.conditions mutates + // the array in place without changing the `interaction` reference, so + // useMemo keyed only on [interaction] would never see the new conditions. + const [conditionsVersion, setConditionsVersion] = useState(0); + + const pairs = useMemo( + () => pairEMCConditions(interaction.conditions()), + [interaction, conditionsVersion] + ); useEffect(() => { let passedInteraction = interaction; - let sliderVals: Map = new Map(); - let sliderMarks: Map = new Map(); + let vals: Map = new Map(); + let marks: Map = new Map(); passedInteraction.conditions().forEach(condition => { let describer = describeMeasurement(condition.measurementType, source.type(), undefined, undefined, user); - //NOTE: toDisplay will convert the temp value to fahrenheit - sliderVals.set(condition.measurementType, describer.toDisplay(condition.value)); + // NOTE: toDisplay will convert the temp value to fahrenheit + // keyed by condition object itself (not measurementType) since there + // can now be more than one condition per measurement type + vals.set(condition, describer.toDisplay(condition.value)); }); source.status.lastGoodMeasurement.forEach(measurement => { let m = pond.UnitMeasurementsForComponent.fromObject(measurement); if (m.values[0]) { let markVal = avg(m.values[0].values); - //do this since this is how interactions handle the values so that the slider can use the toDisplay method of the describer for the marks + // do this since this is how interactions handle the values so that the slider can use the toDisplay method of the describer for the marks if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) markVal = markVal * 10; if (m.type === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) markVal = markVal * 100; - sliderMarks.set(m.type, markVal); + marks.set(m.type, markVal); } }); - setSliderVals(sliderVals); - setSliderMarks(sliderMarks); + setSliderVals(vals); + setSliderMarks(marks); }, [interaction, source]); - useEffect(() => { - let temp = sliderVals.get(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE); - let hum = sliderVals.get(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT); - if ( - grain !== undefined && - grain !== pond.Grain.GRAIN_INVALID && - temp && - hum - ) { - if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - //the emc calc needs the temp to be in celsius - temp = fahrenheitToCelsius(temp); - } - let emc = ExtractMoisture(grain, temp, hum, customGrain) - setBaseEMC(emc === hum ? undefined : emc); - + // Given a corner pair, calculate the EMC that pair implies. Returns + // undefined if grain info or either value isn't available yet. + const emcForPair = (pair?: EMCCornerPair): number | undefined => { + if (!pair || grain === undefined || grain === pond.Grain.GRAIN_INVALID) return undefined; + + let temp = sliderVals.get(pair.temp); + let hum = sliderVals.get(pair.humid); + if (temp === undefined || hum === undefined) return undefined; + + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + // the emc calc needs the temp to be in celsius + temp = fahrenheitToCelsius(temp); } - }, [sliderVals, grain, user]); + let emc = ExtractMoisture(grain, temp, hum, customGrain); + return emc === hum ? undefined : emc; + }; + + useEffect(() => { + setUpperEMC(emcForPair(pairs.upperBoundPair)); + setLowerEMC(emcForPair(pairs.lowerBoundPair)); + }, [sliderVals, grain, user, pairs]); + + // Builds a new InteractionCondition with sensible defaults, offset away + // from an existing reference condition's value so the new slider doesn't + // start stacked exactly on top of the one it's paired against. + // ASSUMPTION: InteractionCondition is a plain settable class instance, + // matching how the rest of this file mutates `condition.value` directly. + // If your codebase instead uses a factory (e.g. pond.InteractionCondition.create(...)), + // swap the `new ...()` + assignment below for that factory call. + const buildCondition = ( + measurementType: quack.MeasurementType, + comparison: quack.RelationalOperator, + displayValue: number + ): pond.InteractionCondition => { + let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user); + const condition = new pond.InteractionCondition(); + condition.measurementType = measurementType; + condition.comparison = comparison; + condition.value = Math.round(describer.toStored(displayValue)); + return condition; + }; + + // Adds the missing opposite corner pair (temp + humidity condition) so + // the EMC display becomes a "between" range instead of a single bound. + // Only relevant when exactly one clean corner pair currently exists. + const addOppositeCornerPair = () => { + const existing = pairs.upperBoundPair ?? pairs.lowerBoundPair; + if (!existing) return; // nothing to mirror off of -- shouldn't happen if button is only shown in this case + + const addingLowerBound = !!pairs.upperBoundPair; // if we have the "less than" pair, we're adding the "greater than" one + const tempComparison = addingLowerBound + ? quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN + : quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN; + const humidComparison = addingLowerBound + ? quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN + : quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN; + + const tempDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, source?.type(), source.subType(), undefined, user); + const humidDescriber = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, source?.type(), source.subType(), undefined, user); + + const existingTempVal = sliderVals.get(existing.temp) ?? tempDescriber.toDisplay(existing.temp.value); + const existingHumidVal = sliderVals.get(existing.humid) ?? humidDescriber.toDisplay(existing.humid.value); + + // offset 10% of the slider's range away from the existing threshold, + // in the direction that keeps the two thresholds from overlapping + const tempRange = tempDescriber.max() - tempDescriber.min(); + const humidRange = humidDescriber.max() - humidDescriber.min(); + const tempOffsetDir = addingLowerBound ? -1 : 1; + const humidOffsetDir = addingLowerBound ? 1 : -1; + + const newTempVal = existingTempVal + tempOffsetDir * tempRange * 0.1; + const newHumidVal = existingHumidVal + humidOffsetDir * humidRange * 0.1; + + const newTemp = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempComparison, newTempVal); + const newHumid = buildCondition(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidComparison, newHumidVal); + + // ASSUMPTION: interaction.settings.conditions is the backing array for + // interaction.conditions() -- push directly so changeConditions() sends + // the full, updated list. Adjust if conditions are stored/added differently. + interaction.settings.conditions.push(newTemp, newHumid); + + const sliders = cloneDeep(sliderVals); + sliders.set(newTemp, newTempVal); + sliders.set(newHumid, newHumidVal); + setSliderVals(sliders); + setConditionsVersion(v => v + 1); + + if (changeConditions) { + changeConditions(interaction.settings); + } + }; + + // Removes a corner pair's two conditions, collapsing back to a single + // bound. Only relevant when both pairs currently exist. + const removeCornerPair = (pair: EMCCornerPair) => { + interaction.settings.conditions = interaction.settings.conditions.filter( + c => c !== pair.temp && c !== pair.humid + ); + + const sliders = cloneDeep(sliderVals); + sliders.delete(pair.temp); + sliders.delete(pair.humid); + setSliderVals(sliders); + setConditionsVersion(v => v + 1); + + if (changeConditions) { + changeConditions(interaction.settings); + } + }; const customMark = (val: string, arrowColor: string) => { return ( @@ -152,152 +316,221 @@ import { ); }; - - - const conditionDisplay = () => { + // Renders a single condition's slider. Pulled out so both the grouped + // (paired) rendering path and the fallback flat rendering path can share it. + // onRemove/removeTooltip, when provided, render a small remove icon inline + // with this specific slider -- used when two pairs exist, so each row's + // control is unambiguous about which single condition it sits on, even + // though clicking it removes that condition's whole paired counterpart too. + const renderConditionSlider = ( + condition: pond.InteractionCondition, + key: React.Key, + onRemove?: () => void, + removeTooltip?: string + ) => { + let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user); + let labelTail = describer.unit(); + let marks: Mark[] = []; + + let mark = sliderMarks.get(condition.measurementType); + if (mark !== undefined) { + marks.push({ + label: customMark(describer.toDisplay(mark) + labelTail, describer.colour()), + value: describer.toDisplay(mark) + }); + } + return ( - - {interaction.conditions().map((condition, i) => { - let describer = describeMeasurement(condition.measurementType, source?.type(), source.subType(), undefined, user); - let labelTail = describer.unit(); - let marks: Mark[] = []; - - // if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { - // if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { - // labelTail = "°F"; - // } else { - // labelTail = "°C"; - // } - // } - if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) { - labelTail = "%"; - } - - let mark = sliderMarks.get(condition.measurementType); - if (mark !== undefined) { - marks.push({ - label: customMark(describer.toDisplay(mark) + labelTail, describer.colour()), - value: describer.toDisplay(mark) - }); - } - return ( - - - {interactionConditionText(source, condition, false, user)} - - - { - if ( - condition.measurementType === - quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE - ) { - if ( - user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT - ) { - return value.toFixed(1) + "°F"; - } else { - return value.toFixed(1) + "°C"; - } - } - if ( - condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT - ) { - return value.toFixed(1) + "%"; - } - }} - marks={marks} - min={describer.min()} - max={describer.max()} - value={sliderVals.get(condition.measurementType) ?? describer.min()} - onChange={(_, val) => { - //note that changing it here like this is what is changing it in the interaction itself - condition.value = Math.round(describer.toStored(val as number)); - // if(changeConditions){ - // changeConditions(interaction.settings) - // } - let sliders = cloneDeep(sliderVals); - sliders.set(condition.measurementType, val as number); - setSliderVals(sliders); - }} - onChangeCommitted={(_, val) => { - if(changeConditions){ - changeConditions(interaction.settings) - } - }} - /> - - - ); - })} + + + { + if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) { + if (user.tempUnit() === pond.TemperatureUnit.TEMPERATURE_UNIT_FAHRENHEIT) { + return value.toFixed(1) + "°F"; + } else { + return value.toFixed(1) + "°C"; + } + } + if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) { + return value.toFixed(1) + "%"; + } + }} + marks={marks} + min={describer.min()} + max={describer.max()} + value={sliderVals.get(condition) ?? describer.min()} + onChange={(_, val) => { + // note that changing it here like this is what is changing it in the interaction itself + condition.value = Math.round(describer.toStored(val as number)); + let sliders = cloneDeep(sliderVals); + sliders.set(condition, val as number); + setSliderVals(sliders); + }} + onChangeCommitted={() => { + if (changeConditions) { + changeConditions(interaction.settings); + } + }} + /> + + {onRemove && ( + + + + + + + + )} ); }; - const determineEMCRelation = () => { - let relation = "Approximately:"; - let tempRelation: quack.RelationalOperator | undefined = undefined; - let humidRelation: quack.RelationalOperator | undefined = undefined; - interaction.conditions().forEach(condition => { - if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) - tempRelation = condition.comparison; - if (condition.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_PERCENT) - humidRelation = condition.comparison; - }); + // Text like "Humidity is below 32.5% and above 60.0%" / "Humidity between 32.5% and 60.0%" + // Falls back gracefully if only one of the pair exists yet (e.g. mid-edit). + const groupLabel = ( + measurementType: quack.MeasurementType, + loCondition: pond.InteractionCondition | undefined, + hiCondition: pond.InteractionCondition | undefined + ) => { + let describer = describeMeasurement(measurementType, source?.type(), source.subType(), undefined, user); + let name = measurementType === quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE ? "Temperature" : "Humidity"; + let unit = describer.unit(); - if ( - (tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN || - tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) && - (humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN || - humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) - ) { - relation = "Less Than: "; + if (loCondition && hiCondition) { + let loVal = sliderVals.get(loCondition); + let hiVal = sliderVals.get(hiCondition); + if (loVal !== undefined && hiVal !== undefined) { + return `${name} is between ${loVal.toFixed(1)}${unit} and ${hiVal.toFixed(1)}${unit}`; + } } - if ( - (tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_LESS_THAN || - tempRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) && - (humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_GREATER_THAN || - humidRelation === quack.RelationalOperator.RELATIONAL_OPERATOR_EQUAL_TO) - ) { - relation = "Greater Than: "; - } - - return relation; + // fall back to whichever single condition is present + let only = loCondition ?? hiCondition; + if (only) return interactionConditionText(source, only, false, user); + return name; }; + const conditionDisplay = () => { + // If both corner pairs exist, we have two temp conditions and two humid + // conditions -- group them together (temp with temp, humid with humid) + // instead of rendering per-condition like the single-pair case. + const hasTwoPairs = !!pairs.upperBoundPair && !!pairs.lowerBoundPair; + + if (hasTwoPairs) { + // upperBoundPair.temp is "temp > X" (the low end of the temp range) + // lowerBoundPair.temp is "temp < X" (the high end of the temp range) + const tempLo = pairs.upperBoundPair!.temp; + const tempHi = pairs.lowerBoundPair!.temp; + // upperBoundPair.humid is "humid < Y" (the high end doesn't apply here - + // it's the low end of the humidity range) + const humidLo = pairs.upperBoundPair!.humid; + const humidHi = pairs.lowerBoundPair!.humid; + + return ( + + + {groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_TEMPERATURE, tempLo, tempHi)} + + {renderConditionSlider(tempLo, "temp-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching humidity threshold)")} + {renderConditionSlider(tempHi, "temp-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching humidity threshold)")} + + + {groupLabel(quack.MeasurementType.MEASUREMENT_TYPE_PERCENT, humidLo, humidHi)} + + {renderConditionSlider(humidLo, "humid-lo", () => removeCornerPair(pairs.upperBoundPair!), "Remove less than range (also removes matching temperature threshold)")} + {renderConditionSlider(humidHi, "humid-hi", () => removeCornerPair(pairs.lowerBoundPair!), "Remove greater than range (also removes matching temperature threshold)")} + + {pairs.unmatched.map((c, i) => ( + + + {interactionConditionText(source, c, false, user)} + + {renderConditionSlider(c, `unmatched-slider-${i}`)} + + ))} + + ); + } + + // single pair (or no clean pair) -- original flat rendering + const hasExactlyOnePair = + (!!pairs.upperBoundPair && !pairs.lowerBoundPair) || + (!pairs.upperBoundPair && !!pairs.lowerBoundPair); + + return ( + + {interaction.conditions().map((condition, i) => ( + + + {interactionConditionText(source, condition, false, user)} + + {renderConditionSlider(condition, `slider-${i}`)} + + ))} + {hasExactlyOnePair && ( + + + + + + + + )} + + ); + }; + + const determineEMCLabel = () => { + if (upperEMC !== undefined && lowerEMC !== undefined) return "Between: "; + if (upperEMC !== undefined) return "Less Than: "; + if (lowerEMC !== undefined) return "Greater Than: "; + return "Approximately: "; + }; + + const emcColour = describeMeasurement(quack.MeasurementType.MEASUREMENT_TYPE_GRAIN_EMC).colour(); + + const emcValueDisplay = () => { + if (upperEMC !== undefined && lowerEMC !== undefined) { + // present low-to-high regardless of which pair produced which, + // since "greater than" bound is the smaller number and + // "less than" bound is the larger number + const lo = Math.min(upperEMC, lowerEMC); + const hi = Math.max(upperEMC, lowerEMC); + return `${lo.toFixed(1)}% and ${hi.toFixed(1)}%`; + } + const single = upperEMC ?? lowerEMC; + return single !== undefined ? `${single.toFixed(1)}%` : ""; + }; + + const hasAnyEMC = upperEMC !== undefined || lowerEMC !== undefined; + return ( - {interactionResultText(interaction, sink)} + {interactionResultText(interaction, sink)} - {baseEMC !== undefined ? ( + {hasAnyEMC ? ( }> - EMC {determineEMCRelation()} - - {baseEMC.toFixed(1)}% + EMC {determineEMCLabel()} + + {emcValueDisplay()} @@ -309,5 +542,4 @@ import { ); - } - \ No newline at end of file + } \ No newline at end of file