From e6d67bc9834cc55ba169a5f37650d2c36ae7bda0 Mon Sep 17 00:00:00 2001 From: Jover Lee Date: Fri, 15 Nov 2024 17:07:52 -0800 Subject: [PATCH] Add types to measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add types for most measurements specific files. I'm delaying converting the measurementsD3.js file because the import of `event` from d3-selection causes an error. This is likely due to the d3 types not matching the version of d3-selection.¹ I decided to place the measurements JSON types in the same file as the measurements state types so that it's easy to compare the data structures before and after parsing. There are many type errors that will be fixed in subsequent commits. ¹ --- .../{measurements.js => measurements.ts} | 141 +++++++++++++----- src/components/controls/filter.js | 4 +- ...entsOptions.js => measurementsOptions.tsx} | 27 ++-- src/components/info/filtersSummary.js | 2 +- .../{hoverPanel.js => hoverPanel.tsx} | 18 ++- .../measurements/{index.js => index.tsx} | 102 ++++++++----- src/reducers/controls.ts | 10 +- src/reducers/measurements/index.ts | 31 ++-- src/reducers/measurements/types.ts | 79 ++++++++++ 9 files changed, 305 insertions(+), 109 deletions(-) rename src/actions/{measurements.js => measurements.ts} (86%) rename src/components/controls/{measurementsOptions.js => measurementsOptions.tsx} (79%) rename src/components/measurements/{hoverPanel.js => hoverPanel.tsx} (89%) rename src/components/measurements/{index.js => index.tsx} (78%) create mode 100644 src/reducers/measurements/types.ts diff --git a/src/actions/measurements.js b/src/actions/measurements.ts similarity index 86% rename from src/actions/measurements.js rename to src/actions/measurements.ts index 7e43a8800..4852b6bb5 100644 --- a/src/actions/measurements.js +++ b/src/actions/measurements.ts @@ -1,6 +1,7 @@ import { cloneDeep, pick } from "lodash"; +import { AppDispatch, ThunkFunction } from "../store"; import { measurementIdSymbol } from "../util/globals"; -import { defaultMeasurementsControlState } from "../reducers/controls"; +import { ControlsState, defaultMeasurementsControlState, MeasurementsControlState } from "../reducers/controls"; import { getDefaultMeasurementsState } from "../reducers/measurements"; import { warningNotification } from "./notifications"; import { @@ -11,6 +12,42 @@ import { TOGGLE_MEASUREMENTS_OVERALL_MEAN, TOGGLE_MEASUREMENTS_THRESHOLD, } from "./types"; +import { + Collection, + MeasurementMetadata, + MeasurementsDisplay, + MeasurementsJson, + MeasurementsState +} from "../reducers/measurements/types"; + +/** + * Temp object for groupings to keep track of values and their counts so that + * we can create a stable default order for grouping field values + */ +interface GroupingValues { + [key: string]: { + [key: string]: number + } +} + + +/* mf_ correspond to active measurements filters */ +const filterQueryPrefix = "mf_"; +type MeasurementsFilterQuery = `mf_${string}` +type QueryBoolean = "show" | "hide" +/* Measurements query parameters that are constructed and/or parsed here. */ +interface MeasurementsQuery { + m_collection?: string + m_display?: MeasurementsDisplay + m_groupBy?: string + m_overallMean?: QueryBoolean + m_threshold?: QueryBoolean + [key: MeasurementsFilterQuery]: string[] +} +/* Central Query type placeholder! */ +interface Query extends MeasurementsQuery { + [key: string]: unknown +} /** * Find the collection within collections that has a key matching the provided @@ -19,13 +56,12 @@ import { * If collectionKey is not provided, returns the default collection. * If no matches are found, returns the default collection. * If multiple matches are found, only returns the first matching collection. - * - * @param {Array} collections - * @param {string} collectionKey - * @param {string} defaultKey - * @returns {Object} */ -const getCollectionToDisplay = (collections, collectionKey, defaultKey) => { +const getCollectionToDisplay = ( + collections: Collection[], + collectionKey: string, + defaultKey: string +): Collection => { const defaultCollection = collections.filter((collection) => collection.key === defaultKey)[0]; if (!collectionKey) return defaultCollection; const potentialCollections = collections.filter((collection) => collection.key === collectionKey); @@ -39,11 +75,11 @@ const getCollectionToDisplay = (collections, collectionKey, defaultKey) => { /** * Map the controlKey to the default value in collectionDefaults * Checks if the collection default is a valid value for the control - * @param {string} controlKey - * @param {Object} collection - * @returns {*} */ -function getCollectionDefaultControl(controlKey, collection) { +function getCollectionDefaultControl( + controlKey: string, + collection: Collection +): string | boolean | undefined { const collectionControlToDisplayDefaults = { measurementsGroupBy: 'group_by', measurementsDisplay: 'measurements_display', @@ -106,10 +142,8 @@ function getCollectionDefaultControl(controlKey, collection) { /** * Returns the default control state for the provided collection * Returns teh default control state for the app if the collection is not loaded yet - * @param {Object} collection - * @returns {MeasurementsControlState} */ -function getCollectionDefaultControls(collection) { +function getCollectionDefaultControls(collection: Collection): MeasurementsControlState { const defaultControls = {...defaultMeasurementsControlState}; if (Object.keys(collection).length) { for (const [key, value] of Object.entries(defaultControls)) { @@ -127,10 +161,11 @@ function getCollectionDefaultControls(collection) { * If no display defaults are provided, uses the current controls redux state. * If the current `measurementsGrouping` does not exist in the collection, then * defaults to the first grouping option. - * @param {Object} collection - * @returns {MeasurementsControlState} */ -const getCollectionDisplayControls = (controls, collection) => { +const getCollectionDisplayControls = ( + controls: ControlsState, + collection: Collection +): MeasurementsControlState => { // Copy current control options for measurements const newControls = cloneDeep(pick(controls, Object.keys(defaultMeasurementsControlState))); // Checks the current group by is available as a grouping in collection @@ -142,7 +177,7 @@ const getCollectionDisplayControls = (controls, collection) => { // Verify that current filters are valid for the new collection newControls.measurementsFilters = Object.fromEntries( Object.entries(newControls.measurementsFilters) - .map(([field, valuesMap]) => { + .map(([field, valuesMap]): [string, Map] => { // Clone nested Map to avoid changing redux state in place // Delete filter for values that do not exist within the field of the new collection const newValuesMap = new Map([...valuesMap].filter(([value]) => { @@ -169,7 +204,7 @@ const getCollectionDisplayControls = (controls, collection) => { return newControls; }; -const parseMeasurementsJSON = (json) => { +const parseMeasurementsJSON = (json: MeasurementsJson): MeasurementsState => { // Avoid editing the original json values since they are cached for narratives const collections = cloneDeep(json["collections"]); if (!collections || collections.length === 0) { @@ -211,7 +246,7 @@ const parseMeasurementsJSON = (json) => { // Create a temp object for groupings to keep track of values and their // counts so that we can create a stable default order for grouping field values - const groupingsValues = collection.groupings.reduce((tempObject, {key}) => { + const groupingsValues: GroupingValues = collection.groupings.reduce((tempObject, {key}) => { tempObject[key] = {}; return tempObject; }, {}); @@ -277,7 +312,10 @@ const parseMeasurementsJSON = (json) => { } }; -export const loadMeasurements = (measurementsData, dispatch) => { +export const loadMeasurements = ( + measurementsData: MeasurementsJson | Error, + dispatch: AppDispatch +): MeasurementsState => { let measurementState = getDefaultMeasurementsState(); /* Just return default state there are no measurements data to load */ if (!measurementsData) { @@ -305,7 +343,9 @@ export const loadMeasurements = (measurementsData, dispatch) => { return measurementState; }; -export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, getState) => { +export const changeMeasurementsCollection = ( + newCollectionKey: string +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const collectionToDisplay = getCollectionToDisplay(measurements.collections, newCollectionKey, measurements.defaultCollectionKey); const newControls = getCollectionDisplayControls(controls, collectionToDisplay); @@ -325,7 +365,11 @@ export const changeMeasurementsCollection = (newCollectionKey) => (dispatch, get * Tried to use lodash.cloneDeep(), but it did not work for the nested Map * - Jover, 19 January 2022 */ -export const applyMeasurementFilter = (field, value, active) => (dispatch, getState) => { +export const applyMeasurementFilter = ( + field: string, + value: MeasurementMetadata, + active: boolean +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -338,7 +382,10 @@ export const applyMeasurementFilter = (field, value, active) => (dispatch, getSt }); }; -export const removeSingleFilter = (field, value) => (dispatch, getState) => { +export const removeSingleFilter = ( + field: string, + value: MeasurementMetadata +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -357,7 +404,9 @@ export const removeSingleFilter = (field, value) => (dispatch, getState) => { }); }; -export const removeAllFieldFilters = (field) => (dispatch, getState) => { +export const removeAllFieldFilters = ( + field: string +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; delete measurementsFilters[field]; @@ -369,7 +418,10 @@ export const removeAllFieldFilters = (field) => (dispatch, getState) => { }); }; -export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => { +export const toggleAllFieldFilters = ( + field: string, + active: boolean +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -383,7 +435,7 @@ export const toggleAllFieldFilters = (field, active) => (dispatch, getState) => }); }; -export const toggleOverallMean = () => (dispatch, getState) => { +export const toggleOverallMean = (): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const controlKey = "measurementsShowOverallMean"; const newControls = { [controlKey]: !controls[controlKey] }; @@ -395,7 +447,7 @@ export const toggleOverallMean = () => (dispatch, getState) => { }); } -export const toggleThreshold = () => (dispatch, getState) => { +export const toggleThreshold = (): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const controlKey = "measurementsShowThreshold"; const newControls = { [controlKey]: !controls[controlKey] }; @@ -407,7 +459,9 @@ export const toggleThreshold = () => (dispatch, getState) => { }); }; -export const changeMeasurementsDisplay = (newDisplay) => (dispatch, getState) => { +export const changeMeasurementsDisplay = ( + newDisplay: MeasurementsDisplay +): ThunkFunction => (dispatch, getState) => { const { measurements } = getState(); const controlKey = "measurementsDisplay"; const newControls = { [controlKey]: newDisplay }; @@ -419,7 +473,9 @@ export const changeMeasurementsDisplay = (newDisplay) => (dispatch, getState) => }); } -export const changeMeasurementsGroupBy = (newGroupBy) => (dispatch, getState) => { +export const changeMeasurementsGroupBy = ( + newGroupBy: string +): ThunkFunction => (dispatch, getState) => { const { measurements } = getState(); const controlKey = "measurementsGroupBy"; const newControls = { [controlKey]: newGroupBy }; @@ -438,9 +494,10 @@ const controlToQueryParamMap = { measurementsShowThreshold: "m_threshold", }; -/* mf_ correspond to active measurements filters */ -const filterQueryPrefix = "mf_"; -export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) { +export function removeInvalidMeasurementsFilterQuery( + query: Query, + newQueryParams: {[key: MeasurementsFilterQuery]: string} +): Query { const newQuery = cloneDeep(query); // Remove measurements filter query params that are not included in the newQueryParams Object.keys(query) @@ -449,7 +506,11 @@ export function removeInvalidMeasurementsFilterQuery(query, newQueryParams) { return newQuery } -function createMeasurementsQueryFromControls(measurementControls, collection, defaultCollectionKey) { +function createMeasurementsQueryFromControls( + measurementControls: Partial, + collection: Collection, + defaultCollectionKey: string +): MeasurementsQuery { const newQuery = { m_collection: collection.key === defaultCollectionKey ? "" : collection.key }; @@ -505,11 +566,15 @@ function createMeasurementsQueryFromControls(measurementControls, collection, de * * In cases where the query param is invalid, the query param is removed from the * returned query object. - * @param {Object} measurements - * @param {Object} query - * @returns {Object} */ -export const combineMeasurementsControlsAndQuery = (measurements, query) => { +export const combineMeasurementsControlsAndQuery = ( + measurements: MeasurementsState, + query: Query +): { + collectionToDisplay: Collection, + collectionControls: MeasurementsControlState, + updatedQuery: Query +} => { const updatedQuery = cloneDeep(query); const collectionKeys = measurements.collections.map((collection) => collection.key); // Remove m_collection query if it's invalid or the default collection key diff --git a/src/components/controls/filter.js b/src/components/controls/filter.js index de94cac8f..f27e949b3 100644 --- a/src/components/controls/filter.js +++ b/src/components/controls/filter.js @@ -28,8 +28,8 @@ const DEBOUNCE_TIME = 200; nodes: state.tree.nodes, nodesSecondTree: state.treeToo?.nodes, totalStateCountsSecondTree: state.treeToo?.totalStateCounts, - measurementsFieldsMap: state.measurements.collectionToDisplay.fields, - measurementsFiltersMap: state.measurements.collectionToDisplay.filters, + measurementsFieldsMap: state.measurements.collectionToDisplay?.fields, + measurementsFiltersMap: state.measurements.collectionToDisplay?.filters, measurementsFilters: state.controls.measurementsFilters }; }) diff --git a/src/components/controls/measurementsOptions.js b/src/components/controls/measurementsOptions.tsx similarity index 79% rename from src/components/controls/measurementsOptions.js rename to src/components/controls/measurementsOptions.tsx index 78ace0c37..725f06fc9 100644 --- a/src/components/controls/measurementsOptions.js +++ b/src/components/controls/measurementsOptions.tsx @@ -13,15 +13,22 @@ import { controlsWidth } from "../../util/globals"; import { SidebarSubtitle, SidebarButton } from "./styles"; import Toggle from "./toggle"; import CustomSelect from "./customSelect"; +import { Collection } from "../../reducers/measurements/types"; +import { RootState } from "../../store"; + +interface SelectOption { + value: string + label: string +} /** * React Redux selector function that takes the key and title for the * available collections to create the object expected for the Select library. * The label defaults to the key if a collection does not have a set title. - * @param {Array} collections - * @returns {Array} */ -const collectionOptionsSelector = (collections) => { +const collectionOptionsSelector = ( + collections: Collection[] +): SelectOption[] => { return collections.map((collection) => { return { value: collection.key, @@ -32,15 +39,15 @@ const collectionOptionsSelector = (collections) => { const MeasurementsOptions = () => { const dispatch = useAppDispatch(); - const collection = useSelector((state) => state.measurements.collectionToDisplay); - const collectionOptions = useSelector((state) => collectionOptionsSelector(state.measurements.collections), isEqual); - const groupBy = useSelector((state) => state.controls.measurementsGroupBy); - const display = useSelector((state) => state.controls.measurementsDisplay); - const showOverallMean = useSelector((state) => state.controls.measurementsShowOverallMean); - const showThreshold = useSelector((state) => state.controls.measurementsShowThreshold); + const collection = useSelector((state: RootState) => state.measurements.collectionToDisplay); + const collectionOptions = useSelector((state: RootState) => collectionOptionsSelector(state.measurements.collections), isEqual); + const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); + const display = useSelector((state: RootState) => state.controls.measurementsDisplay); + const showOverallMean = useSelector((state: RootState) => state.controls.measurementsShowOverallMean); + const showThreshold = useSelector((state: RootState) => state.controls.measurementsShowThreshold); // Create grouping options for the Select library - let groupingOptions = []; + let groupingOptions: SelectOption[] = []; if (collection.groupings) { groupingOptions = [...collection.groupings.keys()].map((grouping) => { return { diff --git a/src/components/info/filtersSummary.js b/src/components/info/filtersSummary.js index ecdcd6335..8854082d9 100644 --- a/src/components/info/filtersSummary.js +++ b/src/components/info/filtersSummary.js @@ -42,7 +42,7 @@ const closeBracketSmall = { +export interface HoverData { + hoverTitle: string + mouseX: number + mouseY: number + containerId: string + data: Map +} + +const HoverPanel = ({ + hoverData +}: { + hoverData: HoverData +}) => { if (hoverData === null) return null; const { hoverTitle, mouseX, mouseY, containerId, data } = hoverData; - const panelStyle = { + const panelStyle: CSSProperties = { position: "absolute", minWidth: 200, padding: "5px", diff --git a/src/components/measurements/index.js b/src/components/measurements/index.tsx similarity index 78% rename from src/components/measurements/index.js rename to src/components/measurements/index.tsx index 9d6fe6dee..aec8c0ea2 100644 --- a/src/components/measurements/index.js +++ b/src/components/measurements/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useRef, useEffect, useMemo, useState } from "react"; +import React, { CSSProperties, MutableRefObject, useCallback, useRef, useEffect, useMemo, useState } from "react"; import { useSelector } from "react-redux"; import { isEqual, orderBy } from "lodash"; import { NODE_VISIBLE } from "../../util/globals"; @@ -8,7 +8,7 @@ import ErrorBoundary from "../../util/errorBoundary"; import Flex from "../framework/flex"; import Card from "../framework/card"; import Legend from "../tree/legend/legend"; -import HoverPanel from "./hoverPanel"; +import HoverPanel, { HoverData } from "./hoverPanel"; import { createXScale, createYScale, @@ -25,16 +25,31 @@ import { layout, jitterRawMeansByColorBy } from "./measurementsD3"; +import { RootState } from "../../store"; +import { MeasurementFilters } from "../../reducers/controls"; +import { Visibility } from "../../reducers/tree/types"; +import { Measurement, MeasurementMetadata } from "../../reducers/measurements/types"; + +interface TreeStrainVisibility { + [strain: string]: Visibility +} +interface TreeStrainProperties { + treeStrainVisibility: TreeStrainVisibility + treeStrainColors: { + [strain: string]: { + attribute: string + color: string + } + } +} /** * A custom React Hook that returns a memoized value that will only change * if a deep comparison using lodash.isEqual determines the value is not * equivalent to the previous value. - * @param {*} value - * @returns {*} */ -const useDeepCompareMemo = (value) => { - const ref = useRef(); +function useDeepCompareMemo(value: T): T { + const ref: MutableRefObject = useRef(); if (!isEqual(value, ref.current)) { ref.current = value; } @@ -42,7 +57,7 @@ const useDeepCompareMemo = (value) => { }; // Checks visibility against global NODE_VISIBLE -const isVisible = (visibility) => visibility === NODE_VISIBLE; +const isVisible = (visibility: Visibility): boolean => visibility === NODE_VISIBLE; /** * A custom React Redux Selector that reduces the tree redux state to an object @@ -52,13 +67,13 @@ const isVisible = (visibility) => visibility === NODE_VISIBLE; * * tree.visibility and tree.nodeColors need to be arrays that have the same * order as tree.nodes - * @param {Object} state - * @returns {Object} */ -const treeStrainPropertySelector = (state) => { +const treeStrainPropertySelector = ( + state: RootState +): TreeStrainProperties => { const { tree, controls } = state; const { colorScale } = controls; - const intitialTreeStrainProperty = { + const initialTreeStrainProperty: TreeStrainProperties = { treeStrainVisibility: {}, treeStrainColors: {} }; @@ -85,7 +100,7 @@ const treeStrainPropertySelector = (state) => { } return treeStrainProperty; - }, intitialTreeStrainProperty); + }, initialTreeStrainProperty); }; /** @@ -96,14 +111,17 @@ const treeStrainPropertySelector = (state) => { * treeStrainVisibility object for strain. * * Returns the active filters object and the filtered measurements - * @param {Array} measurements - * @param {Object} treeStrainVisibility - * @param {Object} filters - * @returns {Object} */ -const filterMeasurements = (measurements, treeStrainVisibility, filters) => { +const filterMeasurements = ( + measurements: Measurement[], + treeStrainVisibility: TreeStrainVisibility, + filters: MeasurementFilters +): { + activeFilters: {string?: MeasurementMetadata[]} + filteredMeasurements: Measurement[] +} => { // Find active filters to filter measurements - const activeFilters = {}; + const activeFilters: {string?: MeasurementMetadata[]} = {}; Object.entries(filters).forEach(([field, valuesMap]) => { activeFilters[field] = activeFilters[field] || []; valuesMap.forEach(({active}, fieldValue) => { @@ -128,25 +146,25 @@ const filterMeasurements = (measurements, treeStrainVisibility, filters) => { const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { // Use `lodash.isEqual` to deep compare object states to prevent unnecessary re-renderings of the component - const { treeStrainVisibility, treeStrainColors } = useSelector((state) => treeStrainPropertySelector(state), isEqual); - const legendValues = useSelector((state) => state.controls.colorScale.legendValues, isEqual); - const colorings = useSelector((state) => state.metadata.colorings); - const colorBy = useSelector((state) => state.controls.colorBy); - const groupBy = useSelector((state) => state.controls.measurementsGroupBy); - const filters = useSelector((state) => state.controls.measurementsFilters); - const display = useSelector((state) => state.controls.measurementsDisplay); - const showOverallMean = useSelector((state) => state.controls.measurementsShowOverallMean); - const showThreshold = useSelector((state) => state.controls.measurementsShowThreshold); - const collection = useSelector((state) => state.measurements.collectionToDisplay, isEqual); + const { treeStrainVisibility, treeStrainColors } = useSelector((state: RootState) => treeStrainPropertySelector(state), isEqual); + const legendValues = useSelector((state: RootState) => state.controls.colorScale.legendValues, isEqual); + const colorings = useSelector((state: RootState) => state.metadata.colorings); + const colorBy = useSelector((state: RootState) => state.controls.colorBy); + const groupBy = useSelector((state: RootState) => state.controls.measurementsGroupBy); + const filters = useSelector((state: RootState) => state.controls.measurementsFilters); + const display = useSelector((state: RootState) => state.controls.measurementsDisplay); + const showOverallMean = useSelector((state: RootState) => state.controls.measurementsShowOverallMean); + const showThreshold = useSelector((state: RootState) => state.controls.measurementsShowThreshold); + const collection = useSelector((state: RootState) => state.measurements.collectionToDisplay, isEqual); const { title, x_axis_label, thresholds, fields, measurements, groupings } = collection; // Ref to access the D3 SVG - const svgContainerRef = useRef(null); - const d3Ref = useRef(null); - const d3XAxisRef = useRef(null); + const svgContainerRef: MutableRefObject = useRef(null); + const d3Ref: MutableRefObject = useRef(null); + const d3XAxisRef: MutableRefObject = useRef(null); // State for storing data for the HoverPanel - const [hoverData, setHoverData] = useState(null); + const [hoverData, setHoverData] = useState(null); // Filter and group measurements const {activeFilters, filteredMeasurements} = filterMeasurements(measurements, treeStrainVisibility, filters); @@ -182,7 +200,13 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { groupedMeasurements }); // Cache handleHover function to avoid extra useEffect calls - const handleHover = useCallback((data, dataType, mouseX, mouseY, colorByAttr=null) => { + const handleHover = useCallback(( + data: Measurement | { mean: number, standardDeviation: number }, + dataType: "measurement" | "mean", + mouseX: number, + mouseY: number, + colorByAttr: string = null + ): void => { let newHoverData = null; if (data !== null) { // Set color-by attribute as title if provided @@ -255,7 +279,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { toggleDisplay(d3Ref.current, "threshold", showThreshold); }, [svgData, showThreshold]); - const getSVGContainerStyle = () => { + const getSVGContainerStyle = (): CSSProperties => { return { overflowY: "auto", position: "relative", @@ -268,7 +292,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { * Sticky x-axis with a set height to make sure the x-axis is always * at the bottom of the measurements panel */ - const getStickyXAxisSVGStyle = () => { + const getStickyXAxisSVGStyle = (): CSSProperties => { return { width: "100%", height: layout.xAxisHeight, @@ -282,7 +306,7 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { * allow x-axis to fit in the bottom of the panel when scrolling all the way * to the bottom of the measurements SVG */ - const getMainSVGStyle = () => { + const getMainSVGStyle = (): CSSProperties => { return { width: "100%", position: "relative", @@ -320,9 +344,9 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { }; const Measurements = ({height, width, showLegend}) => { - const measurementsLoaded = useSelector((state) => state.measurements.loaded); - const measurementsError = useSelector((state) => state.measurements.error); - const showOnlyPanels = useSelector((state) => state.controls.showOnlyPanels); + const measurementsLoaded = useSelector((state: RootState) => state.measurements.loaded); + const measurementsError = useSelector((state: RootState) => state.measurements.error); + const showOnlyPanels = useSelector((state: RootState) => state.controls.showOnlyPanels); const [title, setTitle] = useState("Measurements"); diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index c04271d0c..81c3c6c9f 100644 --- a/src/reducers/controls.ts +++ b/src/reducers/controls.ts @@ -13,6 +13,7 @@ import { calcBrowserDimensionsInitialState } from "./browserDimensions"; import { doesColorByHaveConfidence } from "../actions/recomputeReduxState"; import { hasMultipleGridPanels } from "../actions/panelDisplay"; import { Distance } from "../components/tree/phyloTree/types"; +import { MeasurementMetadata, MeasurementsDisplay } from "./measurements/types"; export interface ColorScale { @@ -151,14 +152,15 @@ export interface BasicControlsState { zoomMin?: number } +export interface MeasurementFilters { + [key: string]: Map +} export interface MeasurementsControlState { measurementsGroupBy: string | undefined, - measurementsDisplay: string | undefined, + measurementsDisplay: MeasurementsDisplay | undefined, measurementsShowOverallMean: boolean | undefined, measurementsShowThreshold: boolean | undefined, - measurementsFilters: { - [key: string]: Map - } + measurementsFilters: MeasurementFilters } export interface ControlsState extends BasicControlsState, MeasurementsControlState {} diff --git a/src/reducers/measurements/index.ts b/src/reducers/measurements/index.ts index f7805e951..e0027d86b 100644 --- a/src/reducers/measurements/index.ts +++ b/src/reducers/measurements/index.ts @@ -1,28 +1,35 @@ +import { AnyAction } from "@reduxjs/toolkit"; import { CHANGE_MEASUREMENTS_COLLECTION, CLEAN_START, URL_QUERY_CHANGE_WITH_COMPUTED_STATE -} from "../actions/types"; +} from "../../actions/types"; +import { MeasurementsState } from "./types"; -export const getDefaultMeasurementsState = () => ({ +export const getDefaultMeasurementsState = (): MeasurementsState => ({ error: undefined, loaded: false, - defaultCollectionKey: "", - collections: [], - collectionToDisplay: {} + defaultCollectionKey: undefined, + collections: undefined, + collectionToDisplay: undefined }); -const measurements = (state = getDefaultMeasurementsState(), action) => { +const measurements = ( + state: MeasurementsState = getDefaultMeasurementsState(), + action: AnyAction, +): MeasurementsState => { switch (action.type) { case CLEAN_START: // fallthrough case URL_QUERY_CHANGE_WITH_COMPUTED_STATE: - return { ...action.measurements }; + return action.measurements; case CHANGE_MEASUREMENTS_COLLECTION: - return { - ...state, - loaded: true, - collectionToDisplay: action.collectionToDisplay - }; + if (state.loaded) { + return { + ...state, + collectionToDisplay: action.collectionToDisplay + }; + } + return state; default: return state; } diff --git a/src/reducers/measurements/types.ts b/src/reducers/measurements/types.ts new file mode 100644 index 000000000..d4c9edce7 --- /dev/null +++ b/src/reducers/measurements/types.ts @@ -0,0 +1,79 @@ +import { measurementIdSymbol } from "../../util/globals"; + +// -- Measurements JSON types -- // + +/** + * Measurements are allowed to have arbitrary metadata. + * Matching types allowed in Augur's measurements schema + * + */ +export type MeasurementMetadata = string | number | boolean +export type MeasurementsDisplay = 'raw' | 'mean' + +interface JsonMeasurement { + readonly strain: string + readonly value: number + readonly [key: string]: MeasurementMetadata +} + +interface JsonCollectionDisplayDefaults { + readonly group_by?: string + readonly measurements_display?: MeasurementsDisplay + readonly show_overall_mean?: boolean + readonly show_threshold?: boolean +} + +interface JsonCollectionField { + readonly key: string + readonly title?: string +} + +interface JsonCollectionGrouping { + readonly key: string + readonly order?: MeasurementMetadata[] +} + +export interface JsonCollection { + readonly display_defaults?: JsonCollectionDisplayDefaults + readonly fields?: JsonCollectionField[] + readonly filters?: string[] + readonly groupings: JsonCollectionGrouping[] + readonly key: string + readonly measurements: JsonMeasurement[] + readonly threshold?: number + readonly thresholds?: number[] + readonly title?: string + readonly x_axis_label: string +} + +export interface MeasurementsJson { + readonly collections: JsonCollection[] + readonly default_collection?: string +} + +// -- Measurements state types -- // + +export interface Measurement extends JsonMeasurement { + [measurementIdSymbol]: number +} + +export interface Collection { + // TODO: Convert this to MeasurementsControlState during parseMeasurementsJSON + display_defaults?: JsonCollectionDisplayDefaults + fields: Map + filters: Map}> + groupings: Map + key: string + measurements: Measurement[] + thresholds?: number[] + title?: string + x_axis_label: string +} + +export interface MeasurementsState { + loaded: boolean + error: string | undefined + collections: Collection[] | undefined + collectionToDisplay: Collection | undefined + defaultCollectionKey: string | undefined +}