diff --git a/src/client/app/components/BarChartComponent.tsx b/src/client/app/components/BarChartComponent.tsx index 755509308..bcccceb67 100644 --- a/src/client/app/components/BarChartComponent.tsx +++ b/src/client/app/components/BarChartComponent.tsx @@ -8,7 +8,7 @@ import { PlotRelayoutEvent } from 'plotly.js'; import * as React from 'react'; import Plot from 'react-plotly.js'; import { TimeInterval } from '../../../common/TimeInterval'; -import { updateSliderRange } from '../redux/actions/extraActions'; +import { updateSliderRange } from '../redux/slices/graphSlice'; import { readingsApi, stableEmptyBarReadings } from '../redux/api/readingsApi'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectPlotlyBarDataFromResult, selectPlotlyBarDeps } from '../redux/selectors/barChartSelectors'; @@ -115,14 +115,15 @@ export default function BarChartComponent() { const startTS = utc(e['xaxis.range[0]']); const endTS = utc(e['xaxis.range[1]']); const workingTimeInterval = new TimeInterval(startTS, endTS); - dispatch(updateSliderRange(workingTimeInterval)); + dispatch(updateSliderRange(workingTimeInterval.toString())); } else if (e['xaxis.range']) { // this case is when the slider knobs are dragged. const range = e['xaxis.range']!; const startTS = range && range[0]; const endTS = range && range[1]; - dispatch(updateSliderRange(new TimeInterval(utc(startTS), utc(endTS)))); + const interval = new TimeInterval(utc(startTS), utc(endTS)).toString(); + dispatch(updateSliderRange(interval)); } }, 500, { leading: false, trailing: true })} diff --git a/src/client/app/components/ChartSelectComponent.tsx b/src/client/app/components/ChartSelectComponent.tsx index 9625734ae..296d37048 100644 --- a/src/client/app/components/ChartSelectComponent.tsx +++ b/src/client/app/components/ChartSelectComponent.tsx @@ -2,17 +2,13 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { sortBy, values } from 'lodash'; import * as React from 'react'; import { useState } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useSelector } from 'react-redux'; import { Dropdown, DropdownItem, DropdownMenu, DropdownToggle } from 'reactstrap'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { graphSlice, selectChartToRender } from '../redux/slices/graphSlice'; -import { SelectOption } from '../types/items'; import { ChartTypes } from '../types/redux/graph'; -import { State } from '../types/redux/state'; import translate from '../utils/translate'; import TooltipMarkerComponent from './TooltipMarkerComponent'; @@ -24,10 +20,10 @@ export default function ChartSelectComponent() { const currentChartToRender = useAppSelector(selectChartToRender); const dispatch = useAppDispatch(); const [expand, setExpand] = useState(false); - const mapsById = useSelector((state: State) => state.maps.byMapID); - const sortedMaps = sortBy(values(mapsById).map(map => ( - { value: map.id, label: map.name, isDisabled: !(map.origin && map.opposite) } as SelectOption - )), 'label'); + // const mapsById = useAppSelector(selectMapDataById); + // const sortedMaps = sortBy(values(mapsById).map(map => ( + // { value: map.id, label: map.name, isDisabled: !(map.origin && map.opposite) } as SelectOption + // )), 'label'); return ( <> @@ -52,11 +48,6 @@ export default function ChartSelectComponent() { key={chartType} onClick={() => { dispatch(graphSlice.actions.changeChartToRender(chartType)); - if (chartType === ChartTypes.map && Object.keys(sortedMaps).length === 1) { - // If there is only one map, selectedMap is the id of the only map. ie; display map automatically if only 1 map - dispatch({ type: 'UPDATE_SELECTED_MAPS', mapID: sortedMaps[0].value }); - - } }} > {translate(`${chartType}`)} diff --git a/src/client/app/components/DateRangeComponent.tsx b/src/client/app/components/DateRangeComponent.tsx index 22f7846ed..ceffc9e14 100644 --- a/src/client/app/components/DateRangeComponent.tsx +++ b/src/client/app/components/DateRangeComponent.tsx @@ -9,9 +9,8 @@ import * as React from 'react'; import 'react-calendar/dist/Calendar.css'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; -import { changeSliderRange, selectQueryTimeInterval, updateTimeInterval, selectChartToRender} from '../redux/slices/graphSlice'; +import { changeSliderRange, selectQueryTimeInterval, updateTimeInterval, selectChartToRender } from '../redux/slices/graphSlice'; import '../styles/DateRangeCustom.css'; -import { Dispatch } from '../types/redux/actions'; import { dateRangeToTimeInterval, timeIntervalToDateRange } from '../utils/dateRangeCompatibility'; import translate from '../utils/translate'; import TooltipMarkerComponent from './TooltipMarkerComponent'; @@ -22,15 +21,15 @@ import { ChartTypes } from '../types/redux/graph'; * @returns Date Range Calendar Picker */ export default function DateRangeComponent() { - const dispatch: Dispatch = useAppDispatch(); + const dispatch = useAppDispatch(); const queryTimeInterval = useAppSelector(selectQueryTimeInterval); const locale = useAppSelector(selectSelectedLanguage); const chartType = useAppSelector(selectChartToRender); - const datePickerVisible = chartType !== ChartTypes.compare; + const datePickerVisible = chartType !== ChartTypes.compare; const handleChange = (value: Value) => { - dispatch(updateTimeInterval(dateRangeToTimeInterval(value))); - dispatch(changeSliderRange(dateRangeToTimeInterval(value))); + dispatch(updateTimeInterval(dateRangeToTimeInterval(value).toString())); + dispatch(changeSliderRange(dateRangeToTimeInterval(value).toString())); }; diff --git a/src/client/app/components/HeaderButtonsComponent.tsx b/src/client/app/components/HeaderButtonsComponent.tsx index a48b18f56..60a46cdff 100644 --- a/src/client/app/components/HeaderButtonsComponent.tsx +++ b/src/client/app/components/HeaderButtonsComponent.tsx @@ -8,7 +8,6 @@ import { FormattedMessage } from 'react-intl'; import { Link, useLocation } from 'react-router-dom'; import { DropdownItem, DropdownMenu, DropdownToggle, Nav, NavLink, Navbar, UncontrolledDropdown } from 'reactstrap'; import TooltipHelpComponent from '../components/TooltipHelpComponent'; -import { clearGraphHistory } from '../redux/actions/extraActions'; import { authApi } from '../redux/api/authApi'; import { selectOEDVersion } from '../redux/api/versionApi'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; @@ -19,6 +18,7 @@ import { UserRole } from '../types/items'; import translate from '../utils/translate'; import LanguageSelectorComponent from './LanguageSelectorComponent'; import TooltipMarkerComponent from './TooltipMarkerComponent'; +import { clearGraphHistory } from '../redux/slices/graphSlice'; /** * React Component that defines the header buttons at the top of a page diff --git a/src/client/app/components/HistoryComponent.tsx b/src/client/app/components/HistoryComponent.tsx index 3af439a63..7534ca6f2 100644 --- a/src/client/app/components/HistoryComponent.tsx +++ b/src/client/app/components/HistoryComponent.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectForwardHistory, selectPrevHistory } from '../redux/slices/graphSlice'; -import { historyStepBack, historyStepForward } from '../redux/actions/extraActions'; +import { historyStepBack, historyStepForward } from '../redux/slices/graphSlice'; import TooltipMarkerComponent from './TooltipMarkerComponent'; /** diff --git a/src/client/app/components/LineChartComponent.tsx b/src/client/app/components/LineChartComponent.tsx index f71058629..2c909609e 100644 --- a/src/client/app/components/LineChartComponent.tsx +++ b/src/client/app/components/LineChartComponent.tsx @@ -8,7 +8,7 @@ import { PlotRelayoutEvent } from 'plotly.js'; import * as React from 'react'; import Plot from 'react-plotly.js'; import { TimeInterval } from '../../../common/TimeInterval'; -import { updateSliderRange } from '../redux/actions/extraActions'; +import { updateSliderRange } from '../redux/slices/graphSlice'; import { readingsApi, stableEmptyLineReadings } from '../redux/api/readingsApi'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectLineChartQueryArgs } from '../redux/selectors/chartQuerySelectors'; @@ -101,14 +101,15 @@ export default function LineChartComponent() { const startTS = utc(e['xaxis.range[0]']); const endTS = utc(e['xaxis.range[1]']); const workingTimeInterval = new TimeInterval(startTS, endTS); - dispatch(updateSliderRange(workingTimeInterval)); + dispatch(updateSliderRange(workingTimeInterval.toString())); } else if (e['xaxis.range']) { // this case is when the slider knobs are dragged. const range = e['xaxis.range']!; const startTS = range && range[0]; const endTS = range && range[1]; - dispatch(updateSliderRange(new TimeInterval(utc(startTS), utc(endTS)))); + const interval = new TimeInterval(utc(startTS), utc(endTS)); + dispatch(updateSliderRange(interval.toString())); } }, 500, { leading: false, trailing: true }) diff --git a/src/client/app/components/MapChartComponent.tsx b/src/client/app/components/MapChartComponent.tsx index e1a5dde28..8d23dd1bd 100644 --- a/src/client/app/components/MapChartComponent.tsx +++ b/src/client/app/components/MapChartComponent.tsx @@ -5,11 +5,10 @@ import { orderBy } from 'lodash'; import * as moment from 'moment'; import * as React from 'react'; -import Plot from 'react-plotly.js'; -import { useSelector } from 'react-redux'; import { selectAreaUnit, selectBarWidthDays, selectGraphAreaNormalization, selectSelectedGroups, + selectSelectedMap, selectSelectedMeters, selectSelectedUnit } from '../redux/slices/graphSlice'; import { selectGroupDataById } from '../redux/api/groupsApi'; @@ -19,7 +18,6 @@ import { selectUnitDataById } from '../redux/api/unitsApi'; import { useAppSelector } from '../redux/reduxHooks'; import { selectMapChartQueryArgs } from '../redux/selectors/chartQuerySelectors'; import { DataType } from '../types/Datasources'; -import { State } from '../types/redux/state'; import { UnitRepresentType } from '../types/redux/units'; import { CartesianPoint, @@ -34,6 +32,8 @@ import { AreaUnitType, getAreaUnitConversion } from '../utils/getAreaUnitConvers import getGraphColor from '../utils/getGraphColor'; import translate from '../utils/translate'; import SpinnerComponent from './SpinnerComponent'; +import { selectMapById } from '../redux/api/mapsApi'; +import Plot from 'react-plotly.js'; /** * @returns map component @@ -57,285 +57,271 @@ export default function MapChartComponent() { // RTK Types Disagree with maps ts types so, use old until migration completer for maps. // This is also an issue when trying to refactor maps reducer into slice. - const selectedMap = useSelector((state: State) => state.maps.selectedMap); - const byMapID = useSelector((state: State) => state.maps.byMapID); - const editedMaps = useSelector((state: State) => state.maps.editedMaps); + const selectedMap = useAppSelector(selectSelectedMap); + const map = useAppSelector(state => selectMapById(state, selectedMap)); + if (meterIsFetching || groupIsFetching) { return ; } - - // Map to use. - let map; // Holds Plotly mapping info. const data = []; - // Holds the image to use. - let image; - if (selectedMap !== 0) { - const mapID = selectedMap; - if (byMapID[mapID]) { - map = byMapID[mapID]; - if (editedMaps[mapID]) { - map = editedMaps[mapID]; + + // Holds the hover text for each point for Plotly + const hoverText: string[] = []; + // Holds the size of each circle for Plotly. + const size: number[] = []; + // Holds the color of each circle for Plotly. + const colors: string[] = []; + // If there is no map then use a new, empty image as the map. I believe this avoids errors + // and gives the blank screen. + // Arrays to hold the Plotly grid location (x, y) for circles to place on map. + const x: number[] = []; + const y: number[] = []; + + // const timeInterval = state.graph.queryTimeInterval; + // const barDuration = state.graph.barDuration + // Make sure there is a map with values so avoid issues. + if (map && map.origin && map.opposite) { + // The size of the original map loaded into OED. + const imageDimensions: Dimensions = { + width: map.imgWidth, + height: map.imgHeight + }; + // Determine the dimensions so within the Plotly coordinates on the user map. + const imageDimensionNormalized = normalizeImageDimensions(imageDimensions); + // This is the origin & opposite from the calibration. It is the lower, left + // and upper, right corners of the user map. + const origin = map.origin; + const opposite = map.opposite; + // Get the GPS degrees per unit of Plotly grid for x and y. By knowing the two corners + // (or really any two distinct points) you can calculate this by the change in GPS over the + // change in x or y which is the map's width & height in this case. + const scaleOfMap = calculateScaleFromEndpoints(origin, opposite, imageDimensionNormalized, map.northAngle); + // Loop over all selected meters. Maps only work for meters at this time. + // The y-axis label depends on the unit which is in selectUnit state. + let unitLabel: string = ''; + // If graphingUnit is -99 then none selected and nothing to graph so label is empty. + // This will probably happen when the page is first loaded. + if (unitID !== -99) { + const selectUnitState = unitDataById[unitID]; + if (selectUnitState !== undefined) { + // Quantity and flow units have different unit labels. + // Look up the type of unit if it is for quantity/flow (should not be raw) and decide what to do. + // Bar graphics are always quantities. + if (selectUnitState.unitRepresent === UnitRepresentType.quantity) { + // If it is a quantity unit then that is the unit you are graphing but it is normalized to per day. + unitLabel = selectUnitState.identifier + ' / day'; + } else if (selectUnitState.unitRepresent === UnitRepresentType.flow) { + // If it is a flow meter then you need to multiply by time to get the quantity unit then show as per day. + // The quantity/time for flow has varying time so label by multiplying by time. + // To make sure it is clear, also indicate it is a quantity. + // Note this should not be used for raw data. + // It might not be usual to take a flow and make it into a quantity so this label is a little different to + // catch people's attention. If sites/users don't like OED doing this then we can eliminate flow for these types + // of graphics as we are doing for rate. + unitLabel = selectUnitState.identifier + ' * time / day ≡ quantity / day'; + } + if (areaNormalization) { + unitLabel += ' / ' + translate(`AreaUnitType.${selectedAreaUnit}`); + } } } - // Holds the hover text for each point for Plotly - const hoverText: string[] = []; - // Holds the size of each circle for Plotly. - const size: number[] = []; - // Holds the color of each circle for Plotly. - const colors: string[] = []; - // If there is no map then use a new, empty image as the map. I believe this avoids errors - // and gives the blank screen. - image = (map) ? map.image : new Image(); - // Arrays to hold the Plotly grid location (x, y) for circles to place on map. - const x: number[] = []; - const y: number[] = []; - // const timeInterval = state.graph.queryTimeInterval; - // const barDuration = state.graph.barDuration - // Make sure there is a map with values so avoid issues. - if (map && map.origin && map.opposite) { - // The size of the original map loaded into OED. - const imageDimensions: Dimensions = { - width: image.width, - height: image.height - }; - // Determine the dimensions so within the Plotly coordinates on the user map. - const imageDimensionNormalized = normalizeImageDimensions(imageDimensions); - // This is the origin & opposite from the calibration. It is the lower, left - // and upper, right corners of the user map. - const origin = map.origin; - const opposite = map.opposite; - // Get the GPS degrees per unit of Plotly grid for x and y. By knowing the two corners - // (or really any two distinct points) you can calculate this by the change in GPS over the - // change in x or y which is the map's width & height in this case. - const scaleOfMap = calculateScaleFromEndpoints(origin, opposite, imageDimensionNormalized, map.northAngle); - // Loop over all selected meters. Maps only work for meters at this time. - // The y-axis label depends on the unit which is in selectUnit state. - let unitLabel: string = ''; - // If graphingUnit is -99 then none selected and nothing to graph so label is empty. - // This will probably happen when the page is first loaded. - if (unitID !== -99) { - const selectUnitState = unitDataById[unitID]; - if (selectUnitState !== undefined) { - // Quantity and flow units have different unit labels. - // Look up the type of unit if it is for quantity/flow (should not be raw) and decide what to do. - // Bar graphics are always quantities. - if (selectUnitState.unitRepresent === UnitRepresentType.quantity) { - // If it is a quantity unit then that is the unit you are graphing but it is normalized to per day. - unitLabel = selectUnitState.identifier + ' / day'; - } else if (selectUnitState.unitRepresent === UnitRepresentType.flow) { - // If it is a flow meter then you need to multiply by time to get the quantity unit then show as per day. - // The quantity/time for flow has varying time so label by multiplying by time. - // To make sure it is clear, also indicate it is a quantity. - // Note this should not be used for raw data. - // It might not be usual to take a flow and make it into a quantity so this label is a little different to - // catch people's attention. If sites/users don't like OED doing this then we can eliminate flow for these types - // of graphics as we are doing for rate. - unitLabel = selectUnitState.identifier + ' * time / day ≡ quantity / day'; - } + for (const meterID of selectedMeters) { + // Get meter id number. + // Get meter GPS value. + const gps = meterDataById[meterID].gps; + // filter meters with actual gps coordinates. + if (gps !== undefined && gps !== null && meterReadings !== undefined) { + let meterArea = meterDataById[meterID].area; + // we either don't care about area, or we do in which case there needs to be a nonzero area + if (!areaNormalization || (meterArea > 0 && meterDataById[meterID].areaUnit != AreaUnitType.none)) { if (areaNormalization) { - unitLabel += ' / ' + translate(`AreaUnitType.${selectedAreaUnit}`); + // convert the meter area into the proper unit, if needed + meterArea *= getAreaUnitConversion(meterDataById[meterID].areaUnit, selectedAreaUnit); } - } - } - - for (const meterID of selectedMeters) { - // Get meter id number. - // Get meter GPS value. - const gps = meterDataById[meterID].gps; - // filter meters with actual gps coordinates. - if (gps !== undefined && gps !== null && meterReadings !== undefined) { - let meterArea = meterDataById[meterID].area; - // we either don't care about area, or we do in which case there needs to be a nonzero area - if (!areaNormalization || (meterArea > 0 && meterDataById[meterID].areaUnit != AreaUnitType.none)) { - if (areaNormalization) { - // convert the meter area into the proper unit, if needed - meterArea *= getAreaUnitConversion(meterDataById[meterID].areaUnit, selectedAreaUnit); - } - // Convert the gps value to the equivalent Plotly grid coordinates on user map. - // First, convert from GPS to grid units. Since we are doing a GPS calculation, this happens on the true north map. - // It must be on true north map since only there are the GPS axis parallel to the map axis. - // To start, calculate the user grid coordinates (Plotly) from the GPS value. This involves calculating - // it coordinates on the true north map and then rotating/shifting to the user map. - const meterGPSInUserGrid: CartesianPoint = gpsToUserGrid(imageDimensionNormalized, gps, origin, scaleOfMap, map.northAngle); - // Only display items within valid info and within map. - if (itemMapInfoOk(meterID, DataType.Meter, map, gps) && itemDisplayableOnMap(imageDimensionNormalized, meterGPSInUserGrid)) { - // The x, y value for Plotly to use that are on the user map. - x.push(meterGPSInUserGrid.x); - y.push(meterGPSInUserGrid.y); - // Make sure the bar reading data is available. The timeInterval should be fine (but checked) but the barDuration might have changed - // and be fetching. The unit could change from that menu so also need to check. - // Get the bar data to use for the map circle. - // const readingsData = meterReadings[timeInterval.toString()][barDuration.toISOString()][unitID]; - const readingsData = meterReadings[meterID]; - // This protects against there being no readings or that the data is being updated. - if (readingsData !== undefined && !meterIsFetching) { - // Meter name to include in hover on graph. - const label = meterDataById[meterID].identifier; - // The usual color for this meter. - colors.push(getGraphColor(meterID, DataType.Meter)); - if (!readingsData) { - throw new Error('Unacceptable condition: readingsData.readings is undefined.'); + // Convert the gps value to the equivalent Plotly grid coordinates on user map. + // First, convert from GPS to grid units. Since we are doing a GPS calculation, this happens on the true north map. + // It must be on true north map since only there are the GPS axis parallel to the map axis. + // To start, calculate the user grid coordinates (Plotly) from the GPS value. This involves calculating + // it coordinates on the true north map and then rotating/shifting to the user map. + const meterGPSInUserGrid: CartesianPoint = gpsToUserGrid(imageDimensionNormalized, gps, origin, scaleOfMap, map.northAngle); + // Only display items within valid info and within map. + if (itemMapInfoOk(meterID, DataType.Meter, map, gps) && itemDisplayableOnMap(imageDimensionNormalized, meterGPSInUserGrid)) { + // The x, y value for Plotly to use that are on the user map. + x.push(meterGPSInUserGrid.x); + y.push(meterGPSInUserGrid.y); + // Make sure the bar reading data is available. The timeInterval should be fine (but checked) but the barDuration might have changed + // and be fetching. The unit could change from that menu so also need to check. + // Get the bar data to use for the map circle. + // const readingsData = meterReadings[timeInterval.toString()][barDuration.toISOString()][unitID]; + const readingsData = meterReadings[meterID]; + // This protects against there being no readings or that the data is being updated. + if (readingsData !== undefined && !meterIsFetching) { + // Meter name to include in hover on graph. + const label = meterDataById[meterID].identifier; + // The usual color for this meter. + colors.push(getGraphColor(meterID, DataType.Meter)); + if (!readingsData) { + throw new Error('Unacceptable condition: readingsData.readings is undefined.'); + } + // Use the most recent time reading for the circle on the map. + // This has the limitations of the bar value where the last one can include ranges without + // data (GitHub issue on this). + // TODO: It might be better to do this similarly to compare. (See GitHub issue) + const readings = orderBy(readingsData, ['startTimestamp'], ['desc']); + const mapReading = readings[0]; + let timeReading: string; + let averagedReading = 0; + if (readings.length === 0) { + // No data. The next lines causes an issue so set specially. + // There may be a better overall fix for no data. + timeReading = 'no data to display'; + size.push(0); + } else { + // only display a range of dates for the hover text if there is more than one day in the range + // Shift to UTC since want database time not local/browser time which is what moment does. + timeReading = `${moment.utc(mapReading.startTimestamp).format('ll')}`; + if (barDuration.asDays() != 1) { + // subtracting one extra day caused by day ending at midnight of the next day. + // Going from DB unit timestamp that is UTC so force UTC with moment, as usual. + timeReading += ` - ${moment.utc(mapReading.endTimestamp).subtract(1, 'days').format('ll')}`; } - // Use the most recent time reading for the circle on the map. - // This has the limitations of the bar value where the last one can include ranges without - // data (GitHub issue on this). - // TODO: It might be better to do this similarly to compare. (See GitHub issue) - const readings = orderBy(readingsData, ['startTimestamp'], ['desc']); - const mapReading = readings[0]; - let timeReading: string; - let averagedReading = 0; - if (readings.length === 0) { - // No data. The next lines causes an issue so set specially. - // There may be a better overall fix for no data. - timeReading = 'no data to display'; - size.push(0); - } else { - // only display a range of dates for the hover text if there is more than one day in the range - // Shift to UTC since want database time not local/browser time which is what moment does. - timeReading = `${moment.utc(mapReading.startTimestamp).format('ll')}`; - if (barDuration.asDays() != 1) { - // subtracting one extra day caused by day ending at midnight of the next day. - // Going from DB unit timestamp that is UTC so force UTC with moment, as usual. - timeReading += ` - ${moment.utc(mapReading.endTimestamp).subtract(1, 'days').format('ll')}`; - } - // The value for the circle is the average daily usage. - averagedReading = mapReading.reading / barDuration.asDays(); - if (areaNormalization) { - averagedReading /= meterArea; - } - // The size is the reading value. It will be scaled later. - size.push(averagedReading); + // The value for the circle is the average daily usage. + averagedReading = mapReading.reading / barDuration.asDays(); + if (areaNormalization) { + averagedReading /= meterArea; } - // The hover text. - hoverText.push(` ${timeReading}
${label}: ${averagedReading.toPrecision(6)} ${unitLabel}`); + // The size is the reading value. It will be scaled later. + size.push(averagedReading); } + // The hover text. + hoverText.push(` ${timeReading}
${label}: ${averagedReading.toPrecision(6)} ${unitLabel}`); } } } } + } - for (const groupID of selectedGroups) { - // Get group id number. - // Get group GPS value. - const gps = groupDataById[groupID].gps; - // Filter groups with actual gps coordinates. - if (gps !== undefined && gps !== null && groupData !== undefined) { - let groupArea = groupDataById[groupID].area; - if (!areaNormalization || (groupArea > 0 && groupDataById[groupID].areaUnit != AreaUnitType.none)) { - if (areaNormalization) { - // convert the meter area into the proper unit, if needed - groupArea *= getAreaUnitConversion(groupDataById[groupID].areaUnit, selectedAreaUnit); - } - // Convert the gps value to the equivalent Plotly grid coordinates on user map. - // First, convert from GPS to grid units. Since we are doing a GPS calculation, this happens on the true north map. - // It must be on true north map since only there are the GPS axis parallel to the map axis. - // To start, calculate the user grid coordinates (Plotly) from the GPS value. This involves calculating - // it coordinates on the true north map and then rotating/shifting to the user map. - const groupGPSInUserGrid: CartesianPoint = gpsToUserGrid(imageDimensionNormalized, gps, origin, scaleOfMap, map.northAngle); - // Only display items within valid info and within map. - if (itemMapInfoOk(groupID, DataType.Group, map, gps) && itemDisplayableOnMap(imageDimensionNormalized, groupGPSInUserGrid)) { - // The x, y value for Plotly to use that are on the user map. - x.push(groupGPSInUserGrid.x); - y.push(groupGPSInUserGrid.y); - // Make sure the bar reading data is available. The timeInterval should be fine (but checked) but the barDuration might have changed - // and be fetching. The unit could change from that menu so also need to check. - // Get the bar data to use for the map circle. - const readingsData = groupData[groupID]; - // This protects against there being no readings or that the data is being updated. - if (readingsData && !groupIsFetching) { - // Group name to include in hover on graph. - const label = groupDataById[groupID].name; - // The usual color for this group. - colors.push(getGraphColor(groupID, DataType.Group)); - if (!readingsData) { - throw new Error('Unacceptable condition: readingsData.readings is undefined.'); + for (const groupID of selectedGroups) { + // Get group id number. + // Get group GPS value. + const gps = groupDataById[groupID].gps; + // Filter groups with actual gps coordinates. + if (gps !== undefined && gps !== null && groupData !== undefined) { + let groupArea = groupDataById[groupID].area; + if (!areaNormalization || (groupArea > 0 && groupDataById[groupID].areaUnit != AreaUnitType.none)) { + if (areaNormalization) { + // convert the meter area into the proper unit, if needed + groupArea *= getAreaUnitConversion(groupDataById[groupID].areaUnit, selectedAreaUnit); + } + // Convert the gps value to the equivalent Plotly grid coordinates on user map. + // First, convert from GPS to grid units. Since we are doing a GPS calculation, this happens on the true north map. + // It must be on true north map since only there are the GPS axis parallel to the map axis. + // To start, calculate the user grid coordinates (Plotly) from the GPS value. This involves calculating + // it coordinates on the true north map and then rotating/shifting to the user map. + const groupGPSInUserGrid: CartesianPoint = gpsToUserGrid(imageDimensionNormalized, gps, origin, scaleOfMap, map.northAngle); + // Only display items within valid info and within map. + if (itemMapInfoOk(groupID, DataType.Group, map, gps) && itemDisplayableOnMap(imageDimensionNormalized, groupGPSInUserGrid)) { + // The x, y value for Plotly to use that are on the user map. + x.push(groupGPSInUserGrid.x); + y.push(groupGPSInUserGrid.y); + // Make sure the bar reading data is available. The timeInterval should be fine (but checked) but the barDuration might have changed + // and be fetching. The unit could change from that menu so also need to check. + // Get the bar data to use for the map circle. + const readingsData = groupData[groupID]; + // This protects against there being no readings or that the data is being updated. + if (readingsData && !groupIsFetching) { + // Group name to include in hover on graph. + const label = groupDataById[groupID].name; + // The usual color for this group. + colors.push(getGraphColor(groupID, DataType.Group)); + if (!readingsData) { + throw new Error('Unacceptable condition: readingsData.readings is undefined.'); + } + // Use the most recent time reading for the circle on the map. + // This has the limitations of the bar value where the last one can include ranges without + // data (GitHub issue on this). + // TODO: It might be better to do this similarly to compare. (See GitHub issue) + const readings = orderBy(readingsData, ['startTimestamp'], ['desc']); + const mapReading = readings[0]; + let timeReading: string; + let averagedReading = 0; + if (readings.length === 0) { + // No data. The next lines causes an issue so set specially. + // There may be a better overall fix for no data. + timeReading = 'no data to display'; + size.push(0); + } else { + // only display a range of dates for the hover text if there is more than one day in the range + timeReading = `${moment.utc(mapReading.startTimestamp).format('ll')}`; + if (barDuration.asDays() != 1) { + // subtracting one extra day caused by day ending at midnight of the next day. + // Going from DB unit timestamp that is UTC so force UTC with moment, as usual. + timeReading += ` - ${moment.utc(mapReading.endTimestamp).subtract(1, 'days').format('ll')}`; } - // Use the most recent time reading for the circle on the map. - // This has the limitations of the bar value where the last one can include ranges without - // data (GitHub issue on this). - // TODO: It might be better to do this similarly to compare. (See GitHub issue) - const readings = orderBy(readingsData, ['startTimestamp'], ['desc']); - const mapReading = readings[0]; - let timeReading: string; - let averagedReading = 0; - if (readings.length === 0) { - // No data. The next lines causes an issue so set specially. - // There may be a better overall fix for no data. - timeReading = 'no data to display'; - size.push(0); - } else { - // only display a range of dates for the hover text if there is more than one day in the range - timeReading = `${moment.utc(mapReading.startTimestamp).format('ll')}`; - if (barDuration.asDays() != 1) { - // subtracting one extra day caused by day ending at midnight of the next day. - // Going from DB unit timestamp that is UTC so force UTC with moment, as usual. - timeReading += ` - ${moment.utc(mapReading.endTimestamp).subtract(1, 'days').format('ll')}`; - } - // The value for the circle is the average daily usage. - averagedReading = mapReading.reading / barDuration.asDays(); - if (areaNormalization) { - averagedReading /= groupArea; - } - // The size is the reading value. It will be scaled later. - size.push(averagedReading); + // The value for the circle is the average daily usage. + averagedReading = mapReading.reading / barDuration.asDays(); + if (areaNormalization) { + averagedReading /= groupArea; } - // The hover text. - hoverText.push(` ${timeReading}
${label}: ${averagedReading.toPrecision(6)} ${unitLabel}`); + // The size is the reading value. It will be scaled later. + size.push(averagedReading); } + // The hover text. + hoverText.push(` ${timeReading}
${label}: ${averagedReading.toPrecision(6)} ${unitLabel}`); } } } } - // TODO Using the following seems to have no impact on the code. It has been noticed that this function is called - // many times for each change. Someone should look at why that is happening and why some have no items in the arrays. - // if (size.length > 0) { - // TODO The max circle diameter should come from admin/DB. - const maxFeatureFraction = map.circleSize; - // Find the smaller of width and height. This is used since it means the circle size will be - // scaled to that dimension and smaller relative to the other coordinate. - const minDimension = Math.min(imageDimensionNormalized.width, imageDimensionNormalized.height); - // The circle size is set to area below. Thus, we need to convert from wanting a max - // diameter of minDimension * maxFeatureFraction to an area. - const maxCircleSize = Math.PI * Math.pow(minDimension * maxFeatureFraction / 2, 2); - // Find the largest circle which is usage. - const largestCircleSize = Math.max(...size); - // Scale largest circle to the max size and others will be scaled to be smaller. - // Not that < 1 => a larger circle. - const scaling = largestCircleSize / maxCircleSize; - - // Per https://plotly.com/javascript/reference/scatter/: - // The opacity of 0.5 makes it possible to see the map even when there is a circle but the hover - // opacity is 1 so it is easy to see. - // Set the sizemode to area not diameter. - // Set the sizemin so a circle cannot get so small that it might disappear. Unsure the best size. - // Set the sizeref to scale each point to the desired area. - // Note all sizes are in px so have to estimate the actual size. This could be an issue but maps are currently - // a fixed size so not too much of an issue. - // Also note that the circle can go off the edge of the map. At some point it would be nice to have a border - // around the map to avoid this. - const traceOne = { - x, - y, - type: 'scatter', - mode: 'markers', - marker: { - color: colors, - opacity: 0.5, - size, - sizemin: 6, - sizeref: scaling, - sizemode: 'area' - }, - text: hoverText, - hoverinfo: 'text', - opacity: 1, - showlegend: false - }; - data.push(traceOne); } + // TODO Using the following seems to have no impact on the code. It has been noticed that this function is called + // many times for each change. Someone should look at why that is happening and why some have no items in the arrays. + // if (size.length > 0) { + // TODO The max circle diameter should come from admin/DB. + const maxFeatureFraction = map.circleSize; + // Find the smaller of width and height. This is used since it means the circle size will be + // scaled to that dimension and smaller relative to the other coordinate. + const minDimension = Math.min(imageDimensionNormalized.width, imageDimensionNormalized.height); + // The circle size is set to area below. Thus, we need to convert from wanting a max + // diameter of minDimension * maxFeatureFraction to an area. + const maxCircleSize = Math.PI * Math.pow(minDimension * maxFeatureFraction / 2, 2); + // Find the largest circle which is usage. + const largestCircleSize = Math.max(...size); + // Scale largest circle to the max size and others will be scaled to be smaller. + // Not that < 1 => a larger circle. + const scaling = largestCircleSize / maxCircleSize; + + // Per https://plotly.com/javascript/reference/scatter/: + // The opacity of 0.5 makes it possible to see the map even when there is a circle but the hover + // opacity is 1 so it is easy to see. + // Set the sizemode to area not diameter. + // Set the sizemin so a circle cannot get so small that it might disappear. Unsure the best size. + // Set the sizeref to scale each point to the desired area. + // Note all sizes are in px so have to estimate the actual size. This could be an issue but maps are currently + // a fixed size so not too much of an issue. + // Also note that the circle can go off the edge of the map. At some point it would be nice to have a border + // around the map to avoid this. + const traceOne = { + x, + y, + type: 'scatter', + mode: 'markers', + marker: { + color: colors, + opacity: 0.5, + size, + sizemin: 6, + sizeref: scaling, + sizemode: 'area' + }, + text: hoverText, + hoverinfo: 'text', + opacity: 1, + showlegend: false + }; + data.push(traceOne); } // set map background image @@ -357,7 +343,7 @@ export default function MapChartComponent() { }, images: [{ layer: 'below', - source: (image) ? image.src : '', + source: map?.mapSource, xref: 'x', yref: 'y', x: 0, @@ -377,4 +363,4 @@ export default function MapChartComponent() { layout={layout} /> ); -} +} \ No newline at end of file diff --git a/src/client/app/components/MapChartSelectComponent.tsx b/src/client/app/components/MapChartSelectComponent.tsx index bea22af7f..9c1aa0b47 100644 --- a/src/client/app/components/MapChartSelectComponent.tsx +++ b/src/client/app/components/MapChartSelectComponent.tsx @@ -3,11 +3,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import { sortBy, values } from 'lodash'; -import { useDispatch, useSelector } from 'react-redux'; -import { State } from '../types/redux/state'; -import { SelectOption } from '../types/items'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; +import { selectMapById, selectMapSelectOptions } from '../redux/api/mapsApi'; +import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; +import { selectSelectedMap, updateSelectedMaps } from '../redux/slices/graphSlice'; import SingleSelectComponent from './SingleSelectComponent'; import TooltipMarkerComponent from './TooltipMarkerComponent'; @@ -24,20 +23,16 @@ export default function MapChartSelectComponent() { margin: 0 }; const messages = defineMessages({ - selectMap: {id: 'select.map'} + selectMap: { id: 'select.map' } }); // TODO When this is converted to RTK then should use useAppDispatch(). //Utilizes useDispatch and useSelector hooks - const dispatch = useDispatch(); - const sortedMaps = sortBy(values(useSelector((state: State) => state.maps.byMapID)).map(map => ( - { value: map.id, label: map.name, isDisabled: !(map.origin && map.opposite) } as SelectOption - )), 'label'); + const dispatch = useAppDispatch(); + + const sortedMaps = useAppSelector(selectMapSelectOptions); + const selectedMapData = useAppSelector(state => selectMapById(state, selectSelectedMap(state))); - const selectedMap = { - label: useSelector((state: State) => state.maps.byMapID[state.maps.selectedMap] ? state.maps.byMapID[state.maps.selectedMap].name : ''), - value: useSelector((state: State) => state.maps.selectedMap) - }; //useIntl instead of injectIntl and WrappedComponentProps const intl = useIntl(); @@ -46,16 +41,14 @@ export default function MapChartSelectComponent() {

: - +

dispatch({type: 'UPDATE_SELECTED_MAPS', mapID: selected.value})} - //When we specify stuff in actions files, we also specify other variables, in this case mapID. - //This is where we specify values instead of triggering the action by itself. + onValueChange={selected => dispatch(updateSelectedMaps(selected.value))} />
diff --git a/src/client/app/components/PlotNavComponent.tsx b/src/client/app/components/PlotNavComponent.tsx index 21e28cd60..0729c53b5 100644 --- a/src/client/app/components/PlotNavComponent.tsx +++ b/src/client/app/components/PlotNavComponent.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { TimeInterval } from '../../../common/TimeInterval'; -import { clearGraphHistory } from '../redux/actions/extraActions'; +import { clearGraphHistory } from '../redux/slices/graphSlice'; import { useAppDispatch, useAppSelector } from '../redux/reduxHooks'; import { selectAnythingFetching } from '../redux/selectors/apiSelectors'; import { @@ -40,7 +40,7 @@ export const ExpandComponent = () => { const dispatch = useAppDispatch(); return ( { dispatch(changeSliderRange(TimeInterval.unbounded())); }} + onClick={() => { dispatch(changeSliderRange(TimeInterval.unbounded().toString())); }} /> ); }; @@ -70,7 +70,7 @@ export const RefreshGraphComponent = () => { { !somethingFetching && dispatch(updateTimeInterval(sliderInterval)); }} + onClick={() => { !somethingFetching && dispatch(updateTimeInterval(sliderInterval.toString())); }} /> ); }; diff --git a/src/client/app/components/PlotOED.tsx b/src/client/app/components/PlotOED.tsx index b460cf79a..0adbe7fdf 100644 --- a/src/client/app/components/PlotOED.tsx +++ b/src/client/app/components/PlotOED.tsx @@ -44,14 +44,15 @@ export const PlotOED = (props: OEDPlotProps) => { const startTS = moment.utc(e['xaxis.range[0]']); const endTS = moment.utc(e['xaxis.range[1]']); const workingTimeInterval = new TimeInterval(startTS, endTS); - dispatch(changeSliderRange(workingTimeInterval)); + dispatch(changeSliderRange(workingTimeInterval.toString())); } else if (e['xaxis.range']) { // this case is when the slider knobs are dragged. const range = figure.current.layout?.xaxis?.range; const startTS = range && range[0]; const endTS = range && range[1]; - dispatch(changeSliderRange(new TimeInterval(startTS, endTS))); + const interval = new TimeInterval(startTS, endTS).toString(); + dispatch(changeSliderRange(interval)); } }, 500, { leading: false, trailing: true }); diff --git a/src/client/app/components/RouteComponent.tsx b/src/client/app/components/RouteComponent.tsx index 0a871d5b8..16d93065b 100644 --- a/src/client/app/components/RouteComponent.tsx +++ b/src/client/app/components/RouteComponent.tsx @@ -4,11 +4,8 @@ import * as React from 'react'; import { IntlProvider } from 'react-intl'; import { RouterProvider, createBrowserRouter } from 'react-router-dom'; -import ReadingsCSVUploadComponent from '../components/csv/ReadingsCSVUploadComponent'; -import MetersCSVUploadComponent from '../components/csv/MetersCSVUploadComponent'; -import MapCalibrationContainer from '../containers/maps/MapCalibrationContainer'; -import MapsDetailContainer from '../containers/maps/MapsDetailContainer'; import { useAppSelector } from '../redux/reduxHooks'; +import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; import LocaleTranslationData from '../translations/data'; import { UserRole } from '../types/items'; import AppLayout from './AppLayout'; @@ -18,14 +15,17 @@ import AdminComponent from './admin/AdminComponent'; import UsersDetailComponent from './admin/users/UsersDetailComponent'; import ConversionsDetailComponent from './conversion/ConversionsDetailComponent'; import GroupsDetailComponent from './groups/GroupsDetailComponent'; +import { MapCalibrationComponent } from './maps/MapCalibrationComponent'; +import MapsDetailComponent from './maps/MapsDetailComponent'; import MetersDetailComponent from './meters/MetersDetailComponent'; import AdminOutlet from './router/AdminOutlet'; +import ErrorComponent from './router/ErrorComponent'; import { GraphLink } from './router/GraphLinkComponent'; import NotFound from './router/NotFoundOutlet'; import RoleOutlet from './router/RoleOutlet'; import UnitsDetailComponent from './unit/UnitsDetailComponent'; -import ErrorComponent from './router/ErrorComponent'; -import { selectSelectedLanguage } from '../redux/slices/appStateSlice'; +import MetersCSVUploadComponent from './csv/MetersCSVUploadComponent'; +import ReadingsCSVUploadComponent from './csv/ReadingsCSVUploadComponent'; /** * @returns the router component Responsible for client side routing. @@ -55,10 +55,10 @@ const router = createBrowserRouter([ element: , children: [ { path: 'admin', element: }, - { path: 'calibration', element: }, + { path: 'calibration', element: }, + { path: 'maps', element: }, { path: 'conversions', element: }, { path: 'csvMeters', element: }, - { path: 'maps', element: }, { path: 'units', element: }, { path: 'users', element: } ] diff --git a/src/client/app/components/ThreeDComponent.tsx b/src/client/app/components/ThreeDComponent.tsx index 658df7415..37305d073 100644 --- a/src/client/app/components/ThreeDComponent.tsx +++ b/src/client/app/components/ThreeDComponent.tsx @@ -11,7 +11,7 @@ import { selectUnitDataById } from '../redux/api/unitsApi'; import { useAppSelector } from '../redux/reduxHooks'; import { selectThreeDQueryArgs } from '../redux/selectors/chartQuerySelectors'; import { selectThreeDComponentInfo } from '../redux/selectors/threeDSelectors'; -import { selectGraphState } from '../redux/slices/graphSlice'; +import { selectGraphState, selectQueryTimeInterval } from '../redux/slices/graphSlice'; import { ThreeDReading } from '../types/readings'; import { GraphState, MeterOrGroup } from '../types/redux/graph'; import { GroupDataByID } from '../types/redux/groups'; @@ -38,6 +38,7 @@ export default function ThreeDComponent() { const groupDataById = useAppSelector(selectGroupDataById); const unitDataById = useAppSelector(selectUnitDataById); const graphState = useAppSelector(selectGraphState); + const queryTimeInterval = useAppSelector(selectQueryTimeInterval); const locale = useAppSelector(selectSelectedLanguage); const { meterOrGroupID, meterOrGroupName, isAreaCompatible } = useAppSelector(selectThreeDComponentInfo); @@ -53,7 +54,7 @@ export default function ThreeDComponent() { layout = setHelpLayout(translate('select.meter.group')); } else if (graphState.areaNormalization && !isAreaCompatible) { layout = setHelpLayout(`${meterOrGroupName}${translate('threeD.area.incompatible')}`); - } else if (!isValidThreeDInterval(roundTimeIntervalForFetch(graphState.queryTimeInterval))) { + } else if (!isValidThreeDInterval(roundTimeIntervalForFetch(queryTimeInterval))) { // Not a valid time interval. ThreeD can only support up to 1 year of readings layout = setHelpLayout(translate('threeD.date.range.too.long')); } else if (!threeDData) { diff --git a/src/client/app/components/conversion/ConversionsDetailComponent.tsx b/src/client/app/components/conversion/ConversionsDetailComponent.tsx index 41654ed53..fc4a48528 100644 --- a/src/client/app/components/conversion/ConversionsDetailComponent.tsx +++ b/src/client/app/components/conversion/ConversionsDetailComponent.tsx @@ -7,11 +7,12 @@ import { FormattedMessage } from 'react-intl'; import SpinnerComponent from '../SpinnerComponent'; import TooltipHelpComponent from '../TooltipHelpComponent'; import { conversionsApi, stableEmptyConversions } from '../../redux/api/conversionsApi'; -import { stableEmptyUnitDataById, unitsAdapter, unitsApi } from '../../redux/api/unitsApi'; +import { stableEmptyUnitDataById, unitsApi } from '../../redux/api/unitsApi'; import { ConversionData } from '../../types/redux/conversions'; import TooltipMarkerComponent from '../TooltipMarkerComponent'; import ConversionViewComponent from './ConversionViewComponent'; import CreateConversionModalComponent from './CreateConversionModalComponent'; +import { unitsAdapter } from '../../redux/entityAdapters'; /** * Defines the conversions page card view diff --git a/src/client/app/components/maps/CreateMapModalComponent.tsx b/src/client/app/components/maps/CreateMapModalComponent.tsx new file mode 100644 index 000000000..7980a82aa --- /dev/null +++ b/src/client/app/components/maps/CreateMapModalComponent.tsx @@ -0,0 +1,80 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import * as React from 'react'; +import { useState } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { Button, Modal, ModalHeader, ModalBody, ModalFooter, Form, FormGroup, Label, Input } from 'reactstrap'; +import { Link } from 'react-router-dom'; + +interface CreateMapModalProps { + show: boolean; + handleClose: () => void; + createNewMap: () => void; +} + +/** + * Defines the create map modal form + * params not given since props should be going away and painful. Remove eslint command when fixed. + * @returns Map create element + */ +/* eslint-disable-next-line */ +function CreateMapModalComponent({ show, handleClose, createNewMap }: CreateMapModalProps) { + // TODO: Get rid of props, migrate to RTK, finish modal + // Once modal is finished, it will be used in MapsDetailComponent + const [nameInput, setNameInput] = useState(''); + const [noteInput, setNoteInput] = useState(''); + + const handleCreate = () => { + // TODO: Implement create functionality + createNewMap(); + handleClose(); + }; + + return ( + + + + + +
+ + + setNameInput(e.target.value)} + /> + + + + setNoteInput(e.target.value)} + /> + +
+
+ createNewMap()}> + + +
+
+ + + + +
+ ); +} + +export default CreateMapModalComponent; diff --git a/src/client/app/components/maps/EditMapModalComponent.tsx b/src/client/app/components/maps/EditMapModalComponent.tsx new file mode 100644 index 000000000..f02cb2656 --- /dev/null +++ b/src/client/app/components/maps/EditMapModalComponent.tsx @@ -0,0 +1,185 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public +* License, v. 2.0. If a copy of the MPL was not distributed with this +* file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { debounce, isEqual } from 'lodash'; +import * as React from 'react'; +import { useState } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { Link } from 'react-router-dom'; +import { Button, Form, FormGroup, Input, Label, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap'; +import { mapsApi, selectMapById } from '../../redux/api/mapsApi'; +import { useAppDispatch, useAppSelector } from '../../redux/reduxHooks'; +import { localEditsSlice } from '../../redux/slices/localEditsSlice'; +import { CalibrationModeTypes, MapMetadata } from '../../types/redux/map'; +import { showErrorNotification } from '../../utils/notifications'; + +interface EditMapModalProps { + map: MapMetadata; +} + +// TODO: Migrate to RTK +const EditMapModalComponent: React.FC = ({ map }) => { + const [showModal, setShowModal] = useState(false); + const dispatch = useAppDispatch(); + const [nameInput, setNameInput] = useState(map.name); + const [noteInput, setNoteInput] = useState(map.note || ''); + const [circleInput, setCircleInput] = useState(map.circleSize); + const [displayable, setDisplayable] = useState(map.displayable); + const [submitEdit] = mapsApi.useEditMapMutation(); + const [deleteMap] = mapsApi.useDeleteMapMutation(); + // Only used to track stable reference changes to reset form. + const apiMapCache = useAppSelector(state => selectMapById(state, map.id)); + const intl = useIntl(); + + + const handleShow = () => setShowModal(true); + const handleClose = () => setShowModal(false); + const updatedMap = (): MapMetadata => ({ + ...map, + name: nameInput, + note: noteInput, + circleSize: circleInput, + displayable: displayable + }); + const debouncedLocalUpdate = React.useMemo(() => debounce( + (map: MapMetadata) => !isEqual(map, updatedMap()) && dispatch(localEditsSlice.actions.setOneEdit(map)), + 1000 + ), []); + React.useEffect(() => { debouncedLocalUpdate(updatedMap()); }, [nameInput, noteInput, circleInput, displayable]); + + // Sync with API Cache changes, if any. + React.useEffect(() => { + setNameInput(map.name); + setNoteInput(map.note || ''); + setCircleInput(map.circleSize); + setDisplayable(map.displayable); + }, [apiMapCache]); + + const handleSave = () => { + submitEdit(updatedMap()); + handleClose(); + }; + + const handleDelete = () => { + const consent = window.confirm(intl.formatMessage({ id: 'map.confirm.remove' }, { name: map.name })); + if (consent) { + deleteMap(map.id); + handleClose(); + } + }; + + const handleCalibrationSetting = (mode: CalibrationModeTypes) => { + // Add/update entry to localEdits Slice + dispatch(localEditsSlice.actions.setOneEdit(updatedMap())); + // Update Calibration Mode + dispatch(localEditsSlice.actions.updateMapCalibrationMode({ mode, id: map.id })); + handleClose(); + }; + + const circIsValid = circleInput > 0.0 && circleInput <= 2.0; + const toggleCircleEdit = () => { + if (!circIsValid) { + showErrorNotification(intl.formatMessage({ id: 'invalid.number' })); + } + }; + + return ( + <> +
+ +
+ + + + + +
+ + + setNameInput(e.target.value)} + /> + + + + setDisplayable(e.target.value === 'true')} + > + + + + + + + setCircleInput(parseFloat(e.target.value))} + invalid={!circIsValid} + step={0.1} + onBlur={toggleCircleEdit} + /> + + + + setNoteInput(e.target.value)} + /> + +
+
+ + + + handleCalibrationSetting(CalibrationModeTypes.initiate)}> + + +
+
+ +

+ +

+ handleCalibrationSetting(CalibrationModeTypes.calibrate)}> + + +
+
+ + + + + +
+ + ); +}; + +export default EditMapModalComponent; diff --git a/src/client/app/containers/maps/MapCalibrationChartDisplayContainer.ts b/src/client/app/components/maps/MapCalibrationChartDisplayComponent.tsx similarity index 57% rename from src/client/app/containers/maps/MapCalibrationChartDisplayContainer.ts rename to src/client/app/components/maps/MapCalibrationChartDisplayComponent.tsx index 6dfcd905a..089bc66cd 100644 --- a/src/client/app/containers/maps/MapCalibrationChartDisplayContainer.ts +++ b/src/client/app/components/maps/MapCalibrationChartDisplayComponent.tsx @@ -2,23 +2,28 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { connect } from 'react-redux'; +import { PlotData, PlotMouseEvent } from 'plotly.js'; +import * as React from 'react'; import Plot from 'react-plotly.js'; -import { State } from '../../types/redux/state'; -import * as plotly from 'plotly.js'; -import { CartesianPoint, Dimensions, normalizeImageDimensions } from '../../utils/calibration'; -import { updateCurrentCartesian } from '../../redux/actions/map'; -import { store } from '../../store'; -import { CalibrationSettings } from '../../types/redux/map'; +import { useAppDispatch, useAppSelector } from '../../redux/reduxHooks'; +import { selectSelectedLanguage } from '../../redux/slices/appStateSlice'; +import { localEditsSlice } from '../../redux/slices/localEditsSlice'; import Locales from '../../types/locales'; +import { CalibrationSettings } from '../../types/redux/map'; +import { CartesianPoint, Dimensions, normalizeImageDimensions } from '../../utils/calibration'; -function mapStateToProps(state: State) { +/** + * @returns TODO DO ME + */ +export default function MapCalibrationChartDisplayContainer() { + const dispatch = useAppDispatch(); const x: number[] = []; const y: number[] = []; const texts: string[] = []; + const currentLanguange = useAppSelector(selectSelectedLanguage); + const map = useAppSelector(state => localEditsSlice.selectors.selectLocalEdit(state, localEditsSlice.selectors.selectCalibrationMapId(state))); - const mapID = state.maps.calibratingMap; - const map = state.maps.editedMaps[mapID]; + const settings = useAppSelector(state => state.localEdits.calibrationSettings); const points = map.calibrationSet; if (points) { for (const point of points) { @@ -28,10 +33,9 @@ function mapStateToProps(state: State) { } } const imageDimensions: Dimensions = normalizeImageDimensions({ - width: map.image.width, - height: map.image.height + width: map.imgWidth, + height: map.imgHeight }); - const settings = state.maps.calibrationSettings; const backgroundTrace = createBackgroundTrace(imageDimensions, settings); const dataPointTrace = { x, @@ -49,7 +53,7 @@ function mapStateToProps(state: State) { }; const data = [backgroundTrace, dataPointTrace]; - const imageSource = map.image.src; + const imageSource = map.mapSource; // for a detailed description of layout attributes: https://plotly.com/javascript/reference/#layout const layout: any = { @@ -80,23 +84,37 @@ function mapStateToProps(state: State) { }] }; - /*** - * Usage: - * console.log(points, event)}> - * Plotly no longer has IPlotlyChartProps so we will use any for now. - */ - const props: any = { - data, - layout, - onClick: (event: plotly.PlotMouseEvent) => handlePointClick(event), - config: { - locales: Locales // makes locales available for use - } - }; - props.config.locale = state.appState.selectedLanguage; - return props; + return { + // trace 0 keeps a transparent trace of closely positioned points used for calibration(backgroundTrace), + // trace 1 keeps the data points used for calibration are automatically added to the same trace(dataPointTrace), + // event.points will include all points near a mouse click, including those in the backgroundTrace and the dataPointTrace, + // so the algorithm only looks at trace 0 since points from trace 1 are already put into the data set used for calibration. + event.event.preventDefault(); + const eligiblePoints = []; + for (const point of event.points) { + const traceNumber = point.curveNumber; + if (traceNumber === 0) { + eligiblePoints.push(point); + } + } + // TODO VERIFY + const xValue = eligiblePoints[0].x as number; + const yValue = eligiblePoints[0].y as number; + const clickedPoint: CartesianPoint = { + x: Number(xValue.toFixed(6)), + y: Number(yValue.toFixed(6)) + }; + dispatch(localEditsSlice.actions.updateCurrentCartesian(clickedPoint)); + }} + />; } /** @@ -138,36 +156,4 @@ function createBackgroundTrace(imageDimensions: Dimensions, settings: Calibratio showscale: false }; return trace; -} - -function handlePointClick(event: plotly.PlotMouseEvent) { - event.event.preventDefault(); - const currentPoint: CartesianPoint = getClickedCoordinates(event); - store.dispatch(updateCurrentCartesian(currentPoint)); -} - -function getClickedCoordinates(event: plotly.PlotMouseEvent) { - event.event.preventDefault(); - /* - * trace 0 keeps a transparent trace of closely positioned points used for calibration(backgroundTrace), - * trace 1 keeps the data points used for calibration are automatically added to the same trace(dataPointTrace), - * event.points will include all points near a mouse click, including those in the backgroundTrace and the dataPointTrace, - * so the algorithm only looks at trace 0 since points from trace 1 are already put into the data set used for calibration. - */ - const eligiblePoints = []; - for (const point of event.points) { - const traceNumber = point.curveNumber; - if (traceNumber === 0) { - eligiblePoints.push(point); - } - } - const xValue = eligiblePoints[0].x as number; - const yValue = eligiblePoints[0].y as number; - const clickedPoint: CartesianPoint = { - x: Number(xValue.toFixed(6)), - y: Number(yValue.toFixed(6)) - }; - return clickedPoint; -} - -export default connect(mapStateToProps)(Plot); +} \ No newline at end of file diff --git a/src/client/app/components/maps/MapCalibrationComponent.tsx b/src/client/app/components/maps/MapCalibrationComponent.tsx index 1d60d51cf..b6d2d42f2 100644 --- a/src/client/app/components/maps/MapCalibrationComponent.tsx +++ b/src/client/app/components/maps/MapCalibrationComponent.tsx @@ -3,53 +3,41 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import MapCalibrationChartDisplayContainer from '../../containers/maps/MapCalibrationChartDisplayContainer'; -import MapCalibrationInfoDisplayContainer from '../../containers/maps/MapCalibrationInfoDisplayContainer'; -import MapCalibrationInitiateContainer from '../../containers/maps/MapCalibrationInitiateContainer'; -import MapsDetailContainer from '../../containers/maps/MapsDetailContainer'; +import { Navigate } from 'react-router-dom'; +import { useAppSelector } from '../../redux/reduxHooks'; +import { localEditsSlice } from '../../redux/slices/localEditsSlice'; import { CalibrationModeTypes } from '../../types/redux/map'; +import MapCalibrationChartDisplayComponent from './MapCalibrationChartDisplayComponent'; +import MapCalibrationInfoDisplayComponent from './MapCalibrationInfoDisplayComponent'; +import MapCalibrationInitiateComponent from './MapCalibrationInitiateComponent'; -interface MapCalibrationProps { - mode: CalibrationModeTypes; - isLoading: boolean; - mapID: number; -} - -export default class MapCalibrationComponent extends React.Component { - constructor(props: any) { - super(props); - } - - public render() { - if (this.props.mode === CalibrationModeTypes.initiate) { - return ( -
- {/* */} - -
- ); - } else if (this.props.mode === CalibrationModeTypes.calibrate) { - return ( -
- {/* */} -
- {/* TODO These types of plotly containers expect a lot of passed - values and it gives a TS error. Given we plan to replace this - with the react hooks version and it does not seem to cause any - issues, this TS error is being suppressed for now. - eslint-disable-next-line @typescript-eslint/ban-ts-comment - @ts-ignore */} - - -
-
- ); - } else { // preview mode containers - return ( -
- +/** + * @returns Calibration Component corresponding to current step invloved + */ +export const MapCalibrationComponent = () => { + const mapToCalibrate = useAppSelector(localEditsSlice.selectors.selectCalibrationMapId); + const calibrationMode = useAppSelector(state => { + const data = localEditsSlice.selectors.selectLocalEdit(state, mapToCalibrate); + return data?.calibrationMode ?? CalibrationModeTypes.unavailable; + }); + console.log(calibrationMode); + if (calibrationMode === CalibrationModeTypes.initiate) { + return ( +
+ {/* */} + +
+ ); + } else if (calibrationMode === CalibrationModeTypes.calibrate) { + return ( +
+
+ +
- ); - } +
+ ); + } else { + return ; } -} +}; diff --git a/src/client/app/components/maps/MapCalibrationInfoDisplayComponent.tsx b/src/client/app/components/maps/MapCalibrationInfoDisplayComponent.tsx index 7e27aeede..bff7993d6 100644 --- a/src/client/app/components/maps/MapCalibrationInfoDisplayComponent.tsx +++ b/src/client/app/components/maps/MapCalibrationInfoDisplayComponent.tsx @@ -3,116 +3,109 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import * as React from 'react'; -import {GPSPoint, isValidGPSInput} from '../../utils/calibration'; -import {ChangeEvent, FormEvent} from 'react'; -import {FormattedMessage} from 'react-intl'; +import { ChangeEvent, FormEvent } from 'react'; +import { FormattedMessage } from 'react-intl'; +import { logsApi } from '../../redux/api/logApi'; +import { mapsApi } from '../../redux/api/mapsApi'; +import { useTranslate } from '../../redux/componentHooks'; +import { useAppDispatch, useAppSelector } from '../../redux/reduxHooks'; +import { localEditsSlice } from '../../redux/slices/localEditsSlice'; +import { GPSPoint, isValidGPSInput } from '../../utils/calibration'; -interface InfoDisplayProps { - showGrid: boolean; - currentCartesianDisplay: string; - resultDisplay: string; - changeGridDisplay(): any; - updateGPSCoordinates(gpsCoordinate: GPSPoint): any; - submitCalibratingMap(): any; - dropCurrentCalibration(): any; - log(level: string, message: string): any; -} +/** + * @returns TODO DO ME + */ +export default function MapCalibrationInfoDisplayComponent() { + const dispatch = useAppDispatch(); + const [createNewMap] = mapsApi.useCreateMapMutation(); + const [editMap] = mapsApi.useEditMapMutation(); + const translate = useTranslate(); + const [logToServer] = logsApi.useLogToServerMutation(); + const [value, setValue] = React.useState(''); + const showGrid = useAppSelector(state => state.localEdits.calibrationSettings.showGrid); + const mapData = useAppSelector(state => localEditsSlice.selectors.selectLocalEdit(state, state.localEdits.calibratingMap)); + const resultDisplay = (mapData.calibrationResult) + ? `x: ${mapData.calibrationResult.maxError.x}%, y: ${mapData.calibrationResult.maxError.y}%` + : translate('need.more.points'); + const cartesianDisplay = (mapData.currentPoint) + ? `x: ${mapData.currentPoint.cartesian.x}, y: ${mapData.currentPoint.cartesian.y}` + : translate('undefined'); -interface InfoDisplayState { - value: string; -} + const handleGridDisplay = () => { dispatch(localEditsSlice.actions.toggleMapShowGrid()); }; -export default class MapCalibrationInfoDisplayComponent extends React.Component { - constructor(props: InfoDisplayProps) { - super(props); - this.state = { - value: '' - }; - this.handleGridDisplay = this.handleGridDisplay.bind(this); - this.handleGPSInput = this.handleGPSInput.bind(this); - this.resetInputField = this.resetInputField.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - this.handleChanges = this.handleChanges.bind(this); - this.dropCurrentCalibration = this.dropCurrentCalibration.bind(this); - } - public render() { - const calibrationDisplay = `${this.props.resultDisplay}`; - return ( -
-
- -
-
-
-