diff --git a/package-lock.json b/package-lock.json index d383145ac..072d8b188 100644 --- a/package-lock.json +++ b/package-lock.json @@ -110,6 +110,7 @@ "@types/d3-transition": "^1.2.0", "@types/d3-zoom": "^1.1.3", "@types/leaflet": "^1.9.3", + "@types/lodash": "^4.17.13", "@types/node": "^18.15.11", "@types/webpack-env": "^1.18.2", "@typescript-eslint/eslint-plugin": "^5.57.0", @@ -3275,6 +3276,13 @@ "@types/geojson": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", @@ -15630,6 +15638,12 @@ "@types/geojson": "*" } }, + "@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "dev": true + }, "@types/minimatch": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-5.1.2.tgz", diff --git a/package.json b/package.json index b8497ca8f..ca1fb35c2 100644 --- a/package.json +++ b/package.json @@ -131,6 +131,7 @@ "@types/d3-transition": "^1.2.0", "@types/d3-zoom": "^1.1.3", "@types/leaflet": "^1.9.3", + "@types/lodash": "^4.17.13", "@types/node": "^18.15.11", "@types/webpack-env": "^1.18.2", "@typescript-eslint/eslint-plugin": "^5.57.0", diff --git a/src/actions/measurements.js b/src/actions/measurements.ts similarity index 71% rename from src/actions/measurements.js rename to src/actions/measurements.ts index 7e43a8800..6e4bc5a91 100644 --- a/src/actions/measurements.js +++ b/src/actions/measurements.ts @@ -1,6 +1,7 @@ -import { cloneDeep, pick } from "lodash"; +import { cloneDeep } 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,51 @@ import { TOGGLE_MEASUREMENTS_OVERALL_MEAN, TOGGLE_MEASUREMENTS_THRESHOLD, } from "./types"; +import { + Collection, + asCollection, + asMeasurement, + isMeasurementsDisplay, + measurementsDisplayValues, + Measurement, + 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]: Map +} + + +/* mf_ correspond to active measurements filters */ +const filterQueryPrefix = "mf_"; +type MeasurementsFilterQuery = `mf_${string}` + +type QueryBoolean = "show" | "hide" +const queryBooleanValues: QueryBoolean[] = ["show", "hide"]; +export const isQueryBoolean = (x: any): x is QueryBoolean => queryBooleanValues.includes(x) +/* 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! + * Expected to be the returned object from querystring.parse() + * https://nodejs.org/docs/latest-v22.x/api/querystring.html#querystringparsestr-sep-eq-options + */ +interface Query extends MeasurementsQuery { + [key: string]: string | string[] +} /** * Find the collection within collections that has a key matching the provided @@ -19,13 +65,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 +84,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', @@ -65,9 +110,8 @@ function getCollectionDefaultControl(controlKey, collection) { break; } case 'measurementsDisplay': { - const expectedValues = ["mean", "raw"]; - if (defaultControl !== undefined && !expectedValues.includes(defaultControl)) { - console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be one of ${expectedValues}`) + if (defaultControl !== undefined && !isMeasurementsDisplay(defaultControl)) { + console.error(`Ignoring invalid ${displayDefaultKey} value ${defaultControl}, must be one of ${measurementsDisplayValues}`) defaultControl = undefined; } break; @@ -106,10 +150,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,12 +169,18 @@ 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))); + const newControls = cloneDeep(defaultMeasurementsControlState); + Object.entries(controls).forEach(([key, value]) => { + if (key in newControls) { + newControls[key] = cloneDeep(value); + } + }) // Checks the current group by is available as a grouping in collection // If it doesn't exist, set to undefined so it will get filled in with collection's default if (!collection.groupings.has(newControls.measurementsGroupBy)) { @@ -142,7 +190,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,23 +217,31 @@ const getCollectionDisplayControls = (controls, collection) => { return newControls; }; -const parseMeasurementsJSON = (json) => { - // Avoid editing the original json values since they are cached for narratives - const collections = cloneDeep(json["collections"]); - if (!collections || collections.length === 0) { +const parseMeasurementsJSON = (json: MeasurementsJson): MeasurementsState => { + const jsonCollections = json["collections"]; + if (!jsonCollections || jsonCollections.length === 0) { throw new Error("Measurements JSON does not have collections"); } - collections.forEach((collection) => { + // Collection properties with the same type as JsonCollection properties. + const propertiesWithSameType = ["key", "x_axis_label", "display_defaults", "thresholds", "title"]; + + const collections = jsonCollections.map((jsonCollection): Collection => { + const collection: Partial = {}; + // Check for properties with the same type that can be directly copied + for (const collectionProp of propertiesWithSameType) { + if (collectionProp in jsonCollection) { + collection[collectionProp] = cloneDeep(jsonCollection[collectionProp]); + } + } /** * Keep backwards compatibility with single value threshold. * Make sure thresholds are an array of values so that we don't have to * check the data type in the D3 drawing process * `collection.thresholds` takes precedence over the deprecated `collection.threshold` */ - if (typeof collection.threshold === "number") { - collection.thresholds = collection.thresholds || [collection.threshold]; - delete collection.threshold; + if (typeof jsonCollection.threshold === "number") { + collection.thresholds = collection.thresholds || [jsonCollection.threshold]; } /* * Create fields Map for easier access of titles and to keep ordering @@ -193,8 +249,8 @@ const parseMeasurementsJSON = (json) => { * Then loop over measurements to add any remaining fields */ collection.fields = new Map( - (collection.fields || []) - .map(({key, title}) => [key, {title: title || key}]) + (jsonCollection.fields || []) + .map(({key, title}): [string, {title: string}] => [key, {title: title || key}]) ); /** @@ -203,63 +259,83 @@ const parseMeasurementsJSON = (json) => { * Then loop over measurements to add values * If there are no JSON defined filters, then add all fields as filters */ - const collectionFiltersArray = collection.filters; + const collectionFiltersArray = jsonCollection.filters; collection.filters = new Map( - (collection.filters || []) - .map((filterField) => [filterField, {values: new Set()}]) + (jsonCollection.filters || []) + .map((filterField): [string, {values: Set}] => [filterField, {values: new Set()}]) ); // 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}) => { - tempObject[key] = {}; + const groupingsValues: GroupingValues = jsonCollection.groupings.reduce((tempObject, {key}) => { + tempObject[key] = new Map(); return tempObject; }, {}); - collection.measurements.forEach((measurement, index) => { - Object.entries(measurement).forEach(([field, fieldValue]) => { - // Add remaining field titles - if (!collection.fields.has(field)) { - collection.fields.set(field, {title: field}); - } + collection.measurements = jsonCollection.measurements.map((jsonMeasurement, index): Measurement => { + const parsedMeasurement: Partial = { + [measurementIdSymbol]: index + } + Object.entries(jsonMeasurement).forEach(([field, fieldValue]) => { + /** + * Convert all measurements metadata (except the `value`) to strings + * for proper matching with filter queries. + * This does mean the the `value` cannot be used as a field filter. + * We can revisit this decision when adding types to measurementsD3 + * because converting `value` to string resulted in a lot of calculation errors + */ + if (field === "value") { + parsedMeasurement[field] = Number(fieldValue); + } else { + const fieldValueString = fieldValue.toString(); + parsedMeasurement[field] = fieldValueString; + + // Add remaining field titles + if (!collection.fields.has(field)) { + collection.fields.set(field, {title: field}); + } - // Only save the unique values if the field is in defined filters - // OR there are no JSON defined filters, so all fields are filters - if ((collection.filters.has(field)) || !collectionFiltersArray) { - const filterObject = collection.filters.get(field) || { values: new Set()}; - filterObject.values.add(fieldValue); - collection.filters.set(field, filterObject); - } + // Only save the unique values if the field is in defined filters + // OR there are no JSON defined filters, so all fields are filters + if ((collection.filters.has(field)) || !collectionFiltersArray) { + const filterObject = collection.filters.get(field) || { values: new Set()}; + filterObject.values.add(fieldValueString); + collection.filters.set(field, filterObject); + } - // Save grouping field values and counts - if (field in groupingsValues) { - const previousValue = groupingsValues[field][fieldValue]; - groupingsValues[field][fieldValue] = previousValue ? previousValue + 1 : 1; + // Save grouping field values and counts + if (field in groupingsValues) { + const previousValue = groupingsValues[field].get(fieldValueString); + groupingsValues[field].set(fieldValueString, previousValue ? previousValue + 1 : 1); + } } }); - // Add stable id for each measurement to help visualization - measurement[measurementIdSymbol] = index; + return asMeasurement(parsedMeasurement); }); // Create groupings Map for easier access of sorted values and to keep groupings ordering // Must be done after looping through measurements to build `groupingsValues` object collection.groupings = new Map( - collection.groupings.map(({key, order}) => { - const valuesByCount = Object.entries(groupingsValues[key]) + jsonCollection.groupings.map(({key, order}): [string, {values: string[]}] => { + const defaultOrder = order ? order.map((x) => x.toString()) : []; + const valuesByCount = [...groupingsValues[key].entries()] // Use the grouping values' counts to sort the values, highest count first .sort(([, valueCountA], [, valueCountB]) => valueCountB - valueCountA) // Filter out values that already exist in provided order from JSON - .filter(([fieldValue]) => order ? !order.includes(fieldValue) : true) + .filter(([fieldValue]) => !defaultOrder.includes(fieldValue)) // Create array of field values .map(([fieldValue]) => fieldValue); + return [ key, // Prioritize the provided values order then list values by count - {values: (order || []).concat(valuesByCount)} + {values: (defaultOrder).concat(valuesByCount)} ]; }) ); + + return asCollection(collection); }); const collectionKeys = collections.map((collection) => collection.key); @@ -277,7 +353,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 +384,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 +406,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: string, + active: boolean +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -338,7 +423,10 @@ export const applyMeasurementFilter = (field, value, active) => (dispatch, getSt }); }; -export const removeSingleFilter = (field, value) => (dispatch, getState) => { +export const removeSingleFilter = ( + field: string, + value: string +): ThunkFunction => (dispatch, getState) => { const { controls, measurements } = getState(); const measurementsFilters = {...controls.measurementsFilters}; measurementsFilters[field] = new Map(measurementsFilters[field]); @@ -357,7 +445,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 +459,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 +476,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 +488,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 +500,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 +514,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 +535,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 +547,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 +607,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 @@ -529,24 +635,23 @@ export const combineMeasurementsControlsAndQuery = (measurements, query) => { let newControlState = undefined; switch(queryKey) { case "m_display": - if (queryValue === "mean" || queryValue === "raw") { + if (isMeasurementsDisplay(queryValue)) { newControlState = queryValue; } break; case "m_groupBy": // Verify value is a valid grouping of collection - if (collectionGroupings.includes(queryValue)) { + if (typeof queryValue === "string" && collectionGroupings.includes(queryValue)) { newControlState = queryValue; } break; case "m_overallMean": - if (queryValue === "show" || queryValue === "hide") { + if (isQueryBoolean(queryValue)) { newControlState = queryValue === "show"; } break; case "m_threshold": - if (collectionToDisplay.thresholds && - (queryValue === "show" || queryValue === "hide")) { + if (collectionToDisplay.thresholds && isQueryBoolean(queryValue)) { newControlState = queryValue === "show"; } break; @@ -572,8 +677,11 @@ export const combineMeasurementsControlsAndQuery = (measurements, query) => { } // Remove and ignore query for invalid field values + let filterValues = updatedQuery[filterKey]; + if (typeof filterValues === "string") { + filterValues = Array(filterValues); + } const collectionFieldValues = collectionToDisplay.filters.get(field).values; - const filterValues = Array.isArray(updatedQuery[filterKey]) ? updatedQuery[filterKey] : [updatedQuery[filterKey]]; const validFilterValues = filterValues.filter((value) => collectionFieldValues.has(value)); if (!validFilterValues.length) { delete updatedQuery[filterKey]; diff --git a/src/actions/tree.ts b/src/actions/tree.ts index 34967f0e4..6b7bbac57 100644 --- a/src/actions/tree.ts +++ b/src/actions/tree.ts @@ -13,7 +13,7 @@ import { warningNotification } from "./notifications"; import { calcFullTipCounts, calcTipCounts } from "../util/treeCountingHelpers"; import { PhyloNode } from "../components/tree/phyloTree/types"; import { Metadata } from "../metadata"; -import { AppDispatch, RootState } from "../store"; +import { ThunkFunction } from "../store"; import { ReduxNode, TreeState } from "../reducers/tree/types"; type RootIndex = number | undefined @@ -21,9 +21,6 @@ type RootIndex = number | undefined /** [root idx tree1, root idx tree2] */ export type Root = [RootIndex, RootIndex] -/** A function to be handled by redux (thunk) */ -type ThunkFunction = (dispatch: AppDispatch, getState: () => RootState) => void - /** * Updates the `inView` property of nodes which depends on the currently selected * root index (i.e. what node the tree is zoomed into). @@ -277,7 +274,7 @@ export const applyFilter = ( * - "set" -> set the values of the filter to be those provided. All disabled filters will be removed. XXX TODO. */ mode: "add" | "inactivate" | "remove" | "set", - + /** the trait name of the filter ("authors", "country" etcetera) */ trait: string | symbol, 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 75% rename from src/components/measurements/index.js rename to src/components/measurements/index.tsx index 9d6fe6dee..43be5b9c2 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,24 +25,49 @@ import { layout, jitterRawMeansByColorBy } from "./measurementsD3"; +import { RootState } from "../../store"; +import { MeasurementFilters } from "../../reducers/controls"; +import { Visibility } from "../../reducers/tree/types"; +import { Measurement, isMeasurement } from "../../reducers/measurements/types"; + +interface MeanAndStandardDeviation { + mean: number + standardDeviation: number | undefined +} +function isMeanAndStandardDeviation(x: any): x is MeanAndStandardDeviation { + return ( + typeof x.mean === "number" && + (typeof x.standardDeviation === "number" || x.standardDeviation === undefined) + ) +} +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; } return ref.current; -}; +} // 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 +77,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 +110,7 @@ const treeStrainPropertySelector = (state) => { } return treeStrainProperty; - }, intitialTreeStrainProperty); + }, initialTreeStrainProperty); }; /** @@ -96,14 +121,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?: string[]} + filteredMeasurements: Measurement[] +} => { // Find active filters to filter measurements - const activeFilters = {}; + const activeFilters: {string?: string[]} = {}; Object.entries(filters).forEach(([field, valuesMap]) => { activeFilters[field] = activeFilters[field] || []; valuesMap.forEach(({active}, fieldValue) => { @@ -119,7 +147,11 @@ const filterMeasurements = (measurements, treeStrainVisibility, filters) => { if (!isVisible(treeStrainVisibility[measurement.strain])) return false; // Then check that the measurement contains values for all active filters for (const [field, values] of Object.entries(activeFilters)) { - if (values.length > 0 && !values.includes(measurement[field])) return false; + const measurementValue = measurement[field]; + if (values.length > 0 && + ((typeof measurementValue === "string") && !values.includes(measurementValue))){ + return false; + } } return true; }) @@ -128,25 +160,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,14 +214,19 @@ 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 | MeanAndStandardDeviation, + mouseX: number, + mouseY: number, + colorByAttr: string = null + ): void => { let newHoverData = null; if (data !== null) { // Set color-by attribute as title if provided const hoverTitle = colorByAttr !== null ? `Color by ${getColorByTitle(colorings, colorBy)} : ${colorByAttr}` : null; // Create a Map of data to save order of fields const newData = new Map(); - if (dataType === "measurement") { + if (isMeasurement(data)) { // Handle single measurement data // Filter out internal auspice fields (i.e. measurementsJitter and measurementsId) const displayFields = Object.keys(data).filter((field) => fields.has(field)); @@ -199,13 +236,13 @@ const MeasurementsPlot = ({height, width, showLegend, setPanelTitle}) => { orderedFields.forEach((field) => { newData.set(fields.get(field).title, data[field]); }); - } else if (dataType === "mean") { + } else if (isMeanAndStandardDeviation(data)) { // Handle mean and standard deviation data newData.set("mean", data.mean.toFixed(2)); newData.set("standard deviation", data.standardDeviation ? data.standardDeviation.toFixed(2) : "N/A"); } else { // Catch unknown data types - console.error(`"Unknown data type for hover panel: ${dataType}`); + console.error(`"Unknown data type for hover panel: ${JSON.stringify(data)}`); // Display provided data without extra ordering or parsing Object.entries(data).forEach(([key, value]) => newData.set(key, value)); } @@ -255,7 +292,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 +305,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 +319,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 +357,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/components/measurements/measurementsD3.js b/src/components/measurements/measurementsD3.js index fe69e0be4..de4693ed5 100644 --- a/src/components/measurements/measurementsD3.js +++ b/src/components/measurements/measurementsD3.js @@ -453,7 +453,7 @@ export const addHoverPanelToMeasurementsAndMeans = (ref, handleHover, treeStrain } // sets hover data state to trigger the hover panel display - handleHover(d, dataType, clientX, clientY, colorByAttr); + handleHover(d, clientX, clientY, colorByAttr); }) .on("mouseout.hoverPanel", () => handleHover(null)); }; diff --git a/src/reducers/controls.ts b/src/reducers/controls.ts index c04271d0c..516fb8065 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 { 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.js b/src/reducers/measurements.js deleted file mode 100644 index f7805e951..000000000 --- a/src/reducers/measurements.js +++ /dev/null @@ -1,31 +0,0 @@ -import { - CHANGE_MEASUREMENTS_COLLECTION, - CLEAN_START, - URL_QUERY_CHANGE_WITH_COMPUTED_STATE -} from "../actions/types"; - -export const getDefaultMeasurementsState = () => ({ - error: undefined, - loaded: false, - defaultCollectionKey: "", - collections: [], - collectionToDisplay: {} -}); - -const measurements = (state = getDefaultMeasurementsState(), action) => { - switch (action.type) { - case CLEAN_START: // fallthrough - case URL_QUERY_CHANGE_WITH_COMPUTED_STATE: - return { ...action.measurements }; - case CHANGE_MEASUREMENTS_COLLECTION: - return { - ...state, - loaded: true, - collectionToDisplay: action.collectionToDisplay - }; - default: - return state; - } -}; - -export default measurements; diff --git a/src/reducers/measurements/index.ts b/src/reducers/measurements/index.ts new file mode 100644 index 000000000..6236c484d --- /dev/null +++ b/src/reducers/measurements/index.ts @@ -0,0 +1,38 @@ +import { AnyAction } from "@reduxjs/toolkit"; +import { + CHANGE_MEASUREMENTS_COLLECTION, + CLEAN_START, + URL_QUERY_CHANGE_WITH_COMPUTED_STATE +} from "../../actions/types"; +import { MeasurementsState } from "./types"; + +export const getDefaultMeasurementsState = (): MeasurementsState => ({ + error: undefined, + loaded: false, + defaultCollectionKey: undefined, + collections: undefined, + collectionToDisplay: undefined +}); + +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 }; + case CHANGE_MEASUREMENTS_COLLECTION: + if (state.loaded) { + return { + ...state, + collectionToDisplay: action.collectionToDisplay + }; + } + return state; + default: + return state; + } +}; + +export default measurements; diff --git a/src/reducers/measurements/types.ts b/src/reducers/measurements/types.ts new file mode 100644 index 000000000..e8c57789a --- /dev/null +++ b/src/reducers/measurements/types.ts @@ -0,0 +1,128 @@ +import { measurementIdSymbol } from "../../util/globals"; + +// -- Shared Measurements types -- // +export type MeasurementsDisplay = "raw" | "mean" +export const measurementsDisplayValues: MeasurementsDisplay[] = ["raw", "mean"]; +export const isMeasurementsDisplay = (x: any): x is MeasurementsDisplay => measurementsDisplayValues.includes(x); + +// -- Measurements JSON types -- // +/** + * Measurements are allowed to have arbitrary metadata. + * Matching types allowed in Augur's measurements schema + * + */ +type JsonMeasurementMetadata = string | number | boolean + +interface JsonMeasurement { + readonly strain: string + readonly value: number + readonly [key: string]: JsonMeasurementMetadata +} + +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?: JsonMeasurementMetadata[] +} + +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 { + [measurementIdSymbol]: number + strain: string + value: number + [key: string]: string | number +} + +export function asMeasurement(x: Partial): Measurement { + if (x[measurementIdSymbol] !== undefined && x.strain && x.value !== undefined) { + return { + ...x, + [measurementIdSymbol]: x[measurementIdSymbol], + strain: x.strain, + value: x.value, + } + } + throw new Error("Measurement is partial."); +} + +export function isMeasurement(x: any): x is Measurement { + try { + asMeasurement(x); + return true; + } catch { + return false; + } +} + +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 function asCollection(x: Partial): Collection { + if ( + x.fields && + x.filters && + x.groupings && + x.key && + x.measurements && + x.x_axis_label + ){ + return { + ...x, + fields: x.fields, + filters: x.filters, + groupings: x.groupings, + key: x.key, + measurements: x.measurements, + x_axis_label: x.x_axis_label, + } + } + throw new Error("Collection is partial."); +} + +export interface MeasurementsState { + loaded: boolean + error: string | undefined + collections: Collection[] | undefined + collectionToDisplay: Collection | undefined + defaultCollectionKey: string | undefined +} diff --git a/src/store.ts b/src/store.ts index d68e74d09..69c87306b 100644 --- a/src/store.ts +++ b/src/store.ts @@ -44,7 +44,7 @@ const store = configureStore({ if (process.env.NODE_ENV !== 'production' && module.hot) { // console.log("hot reducer reload"); module.hot.accept('./reducers', () => { - const nextRootReducer = require('./reducers/index'); + const nextRootReducer = require('./reducers/index'); store.replaceReducer(nextRootReducer); }); } @@ -54,4 +54,7 @@ if (process.env.NODE_ENV !== 'production' && module.hot) { export type RootState = ReturnType export type AppDispatch = typeof store.dispatch +/** A function to be handled by redux (thunk) */ +export type ThunkFunction = (dispatch: AppDispatch, getState: () => RootState) => void + export default store;