auspice
Version:
Web app for visualizing pathogen evolution
226 lines (197 loc) • 9.06 kB
text/typescript
import { AppDispatch, RootState } from "../../store";
import { UPDATE_METADATA } from "../types";
import { hasMultipleGridPanels } from "../panelDisplay";
import { SPECIAL_CASED_NODE_ATTRS } from "../../reducers/tree/types";
import type{ ControlsState } from "../../reducers/controls";
import type { TreeState, NodeAttr} from "../../reducers/tree/types";
import type { UpdateMetadataAction, NewMetadata, AttrDetails } from "./updateMetadata.types";
import { changeColorBy } from "../colors";
export const SUCCESS = "SUCCESS";
/**
* A redux thunk action to update node attributes and related data. The newMetadata
* has not been cross-referenced with Redux state and so this thunk does that work,
* dispatching notifications as needed. The resulting dispatched action contains
* validated data which the reducers can simply merge into state.
*/
export const updateMetadata = (
newMetadata: NewMetadata,
/** Replace redux state where possible, rather than merge */
replace = false
) => {
return (dispatch: AppDispatch, getState: () => RootState): string => {
const existingState = getState();
// Compute new redux state data for relevant reducers ("fat actions" pattern)
const tree = _reduxTree(existingState.tree, newMetadata.attributes || {}, replace);
if (tree===undefined) {
return "No matching nodes in tree!";
}
const treeToo = _reduxTree(existingState.treeToo, newMetadata.attributes || {}, replace) || existingState.treeToo;
const metadata = _reduxMetadata(existingState.metadata, newMetadata, tree, replace);
const controls = _reduxControls(existingState.controls, newMetadata);
dispatch({ type: UPDATE_METADATA, tree, treeToo, metadata, controls })
// If the dataset didn't have any colorings, but now does, then switch to the first one
// (very common in auspice.us)
const colorsNowAvailable = getState().controls.coloringsPresentOnTree;
if (!existingState.controls.coloringsPresentOnTree.size && colorsNowAvailable.size) {
dispatch(changeColorBy([...colorsNowAvailable][0]));
}
return SUCCESS;
}
}
/**
* Compute data to be easily merged into the tree reducer(s)
* Returns undefined if there's no state updates to make (either because the incoming data
* doesn't update the tree state or the (second tree's) tree state is empty)
*
* NOTE: there is an out-of-sync bug lurking here: if you are filtering to (e.g.) country=X
* and the **NewMetadata** updates these values, the filters won't update. Specifically,
* the value count in the filter badge _will_ update (via updated `totalStateCounts` state)
* but the actual tree visibility won't.
*/
function _reduxTree(
tree: TreeState,
attributes: NewMetadata['attributes'],
/** each supplied attribute will become the tree's attr values - no existing data will be preserved */
replace: boolean,
): UpdateMetadataAction['tree'] | undefined {
const attrsWithUpdates: string[] = Object.entries(attributes)
.flatMap(([k, v]) => Object.keys(v.strains).length ? k : [])
.filter((k) => !SPECIAL_CASED_NODE_ATTRS.has(k));
if (!attrsWithUpdates.length || tree.nodes === null) return undefined;
const attrsNonContinuous: Set<string> = new Set(attrsWithUpdates
.filter((attrName) => attributes[attrName].scaleType !== 'continuous'));
/* Compute updated nodeAttrs for all nodes for all node attr keys which have updates.
* While we do this, count all observed terminal values (similar to `countTraitsAcrossTree`)
* for non-continuous traits.
*/
const counts: Record<string, Map<string, number>> = {};
const nodeAttrs = Object.fromEntries(
tree.nodes.map((node) => {
const name = node.name;
const nodeAttrs = Object.fromEntries(
attrsWithUpdates.map((attrName) => {
// attr data may be (i) new data, (ii) existing data, (iii) undefined
// re: type assertion -- above we restrict attrsWithUpdates to always represent "normal" NodeAttr types
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const attrData = (
replace ?
(attributes[attrName].strains[name] || undefined) :
(attributes[attrName].strains[name] || node.node_attrs[attrName])
) as (NodeAttr | undefined);
const value = attrData?.value;
if (!node.hasChildren && value && attrsNonContinuous.has(attrName)) {
if (!counts[attrName]) counts[attrName] = new Map();
counts[attrName].set(String(value), (counts[attrName].get(String(value)) || 0) + 1);
}
return [attrName, attrData];
})
);
return [name, nodeAttrs]
})
);
return {
nodeAttrs,
nodeAttrKeys: new Set([...tree.nodeAttrKeys, ...attrsWithUpdates]),
totalStateCounts: {...tree.totalStateCounts, ...counts},
}
}
/**
* Return an object representing updates to the existing redux controls **state** which
* the reducer can simply merge in.
*/
function _reduxControls(
state: ControlsState,
newMetadata: NewMetadata
): UpdateMetadataAction['controls'] {
const updates: UpdateMetadataAction['controls'] = {};
/* colorings first (auspice assumes all attrs are colorings) */
if (newMetadata.attributes) {
const coloringsPresentOnTree = (new Set(state.coloringsPresentOnTree))
.union(new Set(Object.keys(newMetadata.attributes)));
updates.coloringsPresentOnTree = coloringsPresentOnTree;
}
/* geographic resolutions */
if (newMetadata.geographic?.length && !state.panelsAvailable.includes("map")) {
updates.panelsAvailable = [...state.panelsAvailable, "map"],
updates.panelsToDisplay = [...updates.panelsAvailable];
updates.canTogglePanelLayout = hasMultipleGridPanels(updates.panelsAvailable);
updates.geoResolution = newMetadata.geographic[0].key;
}
return updates;
}
/**
* Return an object representing updates to the existing redux **state** which the reducer
* can simply merge in.
* NOTE: metadata redux state is untyped
*/
function _reduxMetadata(
state: Record<string, any>,
newMetadata: NewMetadata,
tree: UpdateMetadataAction['tree'],
/** replace color scales entirely rather than attempt to merge in new colors */
replace: boolean
): UpdateMetadataAction['metadata'] {
const colorings = Object.fromEntries([
// First update existing colorings
...Object.entries(state.colorings)
.map(([key, value]) => {
// The redux state values are untyped, so for now assume they are the expected shape
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const oldColoring = value as UpdateMetadataAction['metadata']['colorings'][string];
const coloring = Object.hasOwn(newMetadata.attributes, key) ?
_updateColoring(oldColoring, newMetadata.attributes[key], tree.totalStateCounts[key], replace) :
oldColoring;
return [key, coloring]
}),
// Then add entirely new colorings
...Object.keys(newMetadata.attributes)
.filter((key) => !Object.hasOwn(state.colorings, key))
.map((key) => [key, _updateColoring(undefined, newMetadata.attributes[key], tree.totalStateCounts[key], undefined)])
]);
/* currently the only usage of `updateMetadata` guarantees that each geographic
trait key (name) is also a coloring, but as usage is expanded we should check this */
const geoResolutions = newMetadata.geographic?.length &&
[...(state.geoResolutions || []), ...newMetadata.geographic];
return {
...(Object.keys(colorings).length && { colorings }),
...(geoResolutions && { geoResolutions }),
}
}
/**
* Return coloring object (for a specific attr), with new coloring info **attrDetails**
* either merged in or replacing wholesale the original coloring **state**
*/
function _updateColoring(
state: UpdateMetadataAction['metadata']['colorings'][string],
attrDetails: AttrDetails,
stateCounts: undefined | Map<string, number>,
replace: boolean
): UpdateMetadataAction['metadata']['colorings'][string] {
replace = replace || state === undefined;
if (!replace && (state.type !== attrDetails.scaleType || state.type !== 'categorical')) {
console.warn(`Merging scale colors is only possible for categorical scales`)
replace = true;
}
if (replace) {
attrDetails.colors
return {
title: attrDetails.name,
type: attrDetails.scaleType,
...(attrDetails.colors?.length && { scale: attrDetails.colors }),
}
}
const updatedScale: [string, string][] = Object.entries({
// existing scale pairs, less any values which no longer exist on the tree
...Object.fromEntries(
(state.scale || []) // restrict to strings as we only consider categorical scales
.filter(([value,]) => typeof value === 'string' && stateCounts?.get(value) > 0)
),
// plus new value-color pairs
...Object.fromEntries(attrDetails.colors || []),
})
return {
...state,
title: attrDetails.name,
...(updatedScale.length && {scale: updatedScale})
}
}