auspice
Version:
Web app for visualizing pathogen evolution
598 lines (562 loc) • 20.9 kB
text/typescript
import { numericToCalendar, calendarToNumeric, currentNumDate, currentCalDate } from "../util/dateHelpers";
import { defaultGeoResolution,
defaultColorBy,
defaultDateRange,
defaultDistanceMeasure,
defaultLayout,
defaultFocus,
controlsHiddenWidth,
strainSymbol,
twoColumnBreakpoint } from "../util/globals";
import * as types from "../actions/types";
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 {
colorBy: string
continuous: boolean
domain?: unknown[]
genotype: Genotype | null
legendBounds?: LegendBounds
legendLabels?: LegendLabels
legendValues: LegendValues
scale: (value: any) => string
scaleType: ScaleType | null
version: number
visibleLegendValues: LegendValues
}
export interface Genotype {
gene: string
positions: number[]
aa: boolean
}
export type Layout = "rect" | "radial" | "unrooted" | "clock" | "scatter"
export type Focus = "selected" | null
export type LegendBounds = {
[key: string | number]: [number, number]
}
/** A map of legendValues to a value for display in the legend. */
export type LegendLabels = Map<unknown, unknown>
/** An array of values to display in the legend. */
// TODO: I think this should be number[] | string[] but that requires adding type guards
export type LegendValues = any[]
export type PerformanceFlags = Map<string, boolean>
export interface SelectedNode {
existingFilterState: "active" | "inactive" | null
idx: number
isBranch: boolean
name: string
treeId: string
}
interface AvailableAPIData {
datasets:
{
/** The URL path (sans preceding slash) to load the dataset */
request: string
/**
* Does the dataset support snapshots (@YYYY-MM-DD)?
*/
snapshots?: boolean
/** v2 (unified) dataset JSON.
* Present on the Auspice server, not present on nextstrain.org
* Unused in Auspice client.
*/
v2?: boolean
/** a list of request paths which are candidates to be displayed as a second tree */
secondTreeOptions: string[]
/**
* Defines the intended build URL (rendered in the byline) for the dataset.
* This will be used if the actual dataset JSON doesn't define it itself.
* Unused in Auspice server, used for community sources in nextstrain.org.
*/
buildUrl?: null|string
}[]
narratives:
{
/** The URL path (sans preceding slash) to load the narrative */
request: string
}[]
}
export type ScaleType = "ordinal" | "categorical" | "continuous" | "temporal" | "boolean"
export interface ScatterVariables {
showBranches?: boolean
showRegression?: boolean
x?: string
xContinuous?: boolean
xDomain?: number[]
xTemporal?: boolean
y?: string
yContinuous?: boolean
yDomain?: number[]
yTemporal?: boolean
}
export interface TemporalConfidence {
/**
* Does the dataset include confidence values?
*/
exists: boolean
/**
* Whether to display the toggle in the sidebar
*/
display: boolean
/**
* Whether the confidence intervals are displayed (i.e. the toggle is on/off)
*/
on: boolean
}
interface Defaults {
distanceMeasure: Distance
layout: Layout
focus: Focus
geoResolution: string
filters: Record<string, unknown>
filtersInFooter: string[]
colorBy: string
selectedBranchLabel: string
tipLabelKey: string | symbol
showTransmissionLines: boolean
sidebarOpen?: boolean
}
export interface BasicControlsState {
defaults: Defaults
absoluteDateMax: string
absoluteDateMaxNumeric: number
absoluteDateMin: string
absoluteDateMinNumeric: number
analysisSlider: boolean
animationPlayPauseButton: "Play" | "Pause"
available?: AvailableAPIData
branchLengthsToDisplay: string
canRenderBranchLabels: boolean
canTogglePanelLayout: boolean
colorBy: string
colorByConfidence: boolean
coloringsPresentOnTree: Set<string>
/** subset of coloringsPresentOnTree */
coloringsPresentOnTreeWithConfidence: Set<string>
colorScale?: ColorScale
dateMax: string
dateMaxNumeric: number
dateMin: string
dateMinNumeric: number
distanceMeasure: Distance
explodeAttr?: string
filters: Record<string | symbol, Array<{ value: string, active: boolean }>>
filtersInFooter: string[]
focus: Focus
geoResolution: string
layout: Layout
mapAnimationCumulative: boolean
mapAnimationDurationInMilliseconds: number
mapAnimationShouldLoop: boolean
mapAnimationStartDate: unknown
modal: 'download' | 'linkOut' | 'datasetSelector' | null
normalizeFrequencies: boolean
panelLayout: string
panelsAvailable: string[]
panelsToDisplay: string[]
performanceFlags: PerformanceFlags
quickdraw: boolean
scatterVariables: ScatterVariables
selectedBranchLabel: string
selectedNode: SelectedNode | null
showAllBranchLabels: boolean
showOnlyPanels: boolean
showTangle: boolean
showStreamTrees: boolean
streamTreeBranchLabel: string | null
availableStreamLabelKeys: string[]
showTransmissionLines: boolean
showTreeToo: boolean
sidebarOpen: boolean
temporalConfidence: TemporalConfidence
tipLabelKey: string | symbol
zoomMax?: number
zoomMin?: number
}
export interface MeasurementFilters {
[key: string]: Map<string, {active: boolean}>
}
export interface MeasurementsControlState {
measurementsGroupBy: string | undefined
measurementsDisplay: MeasurementsDisplay | undefined
measurementsShowOverallMean: boolean | undefined
measurementsShowThreshold: boolean | undefined
measurementsFilters: MeasurementFilters
measurementsColorGrouping: string | undefined
}
export interface ControlsState extends BasicControlsState, MeasurementsControlState {}
/* defaultState is a fn so that we can re-create it
at any time, e.g. if we want to revert things (e.g. on dataset change)
*/
export const getDefaultControlsState = (): ControlsState => {
const defaults: Defaults = {
distanceMeasure: defaultDistanceMeasure,
layout: defaultLayout,
focus: defaultFocus,
geoResolution: defaultGeoResolution,
filters: {},
filtersInFooter: [],
colorBy: defaultColorBy,
selectedBranchLabel: "none",
tipLabelKey: strainSymbol,
showTransmissionLines: true
};
// a default sidebarOpen status is only set via JSON, URL query
// _or_ if certain URL keywords are triggered
const initialSidebarState = getInitialSidebarState();
if (initialSidebarState.setDefault) {
defaults.sidebarOpen = initialSidebarState.sidebarOpen;
}
const dateMin = numericToCalendar(currentNumDate() - defaultDateRange);
const dateMax = currentCalDate();
const dateMinNumeric = calendarToNumeric(dateMin);
const dateMaxNumeric = calendarToNumeric(dateMax);
return {
defaults,
available: undefined,
canTogglePanelLayout: true,
temporalConfidence: { exists: false, display: false, on: false },
layout: defaults.layout,
scatterVariables: {},
distanceMeasure: defaults.distanceMeasure,
focus: defaults.focus,
dateMin,
dateMinNumeric,
dateMax,
dateMaxNumeric,
absoluteDateMin: dateMin,
absoluteDateMinNumeric: dateMinNumeric,
absoluteDateMax: dateMax,
absoluteDateMaxNumeric: dateMaxNumeric,
colorBy: defaults.colorBy,
colorByConfidence: false,
colorScale: undefined,
coloringsPresentOnTree: new Set(),
coloringsPresentOnTreeWithConfidence: new Set(),
explodeAttr: undefined,
selectedBranchLabel: "none",
showAllBranchLabels: false,
selectedNode: null,
canRenderBranchLabels: true,
analysisSlider: false,
geoResolution: defaults.geoResolution,
filters: JSON.parse(JSON.stringify(defaults.filters)),
filtersInFooter: JSON.parse(JSON.stringify(defaults.filtersInFooter)),
modal: null,
quickdraw: false, // if true, components may skip expensive computes.
mapAnimationDurationInMilliseconds: 30000, // in milliseconds
mapAnimationStartDate: null, // Null so it can pull the absoluteDateMin as the default
mapAnimationCumulative: false,
mapAnimationShouldLoop: false,
animationPlayPauseButton: "Play",
panelsAvailable: [],
panelsToDisplay: [],
panelLayout: calcBrowserDimensionsInitialState().width > twoColumnBreakpoint ? "grid" : "full",
tipLabelKey: defaults.tipLabelKey,
showTreeToo: false,
showTangle: false,
showStreamTrees: false,
streamTreeBranchLabel: null,
availableStreamLabelKeys: [],
zoomMin: undefined,
zoomMax: undefined,
branchLengthsToDisplay: "divAndDate",
sidebarOpen: initialSidebarState.sidebarOpen,
showOnlyPanels: false,
showTransmissionLines: true,
normalizeFrequencies: true,
measurementsGroupBy: undefined,
measurementsDisplay: undefined,
measurementsShowOverallMean: undefined,
measurementsShowThreshold: undefined,
measurementsColorGrouping: undefined,
measurementsFilters: {},
performanceFlags: new Map(),
};
};
/**
* Keeping measurements control state separate from getDefaultControlsState
* in order to be able to differentiate when the page is loaded with and without
* URL params for the measurements panel.
*
* The initial control state is constructed then the URL params update the state.
* However, the measurements JSON is loaded after this, so it needs a way to
* differentiate the clean slate vs the added URL params.
*/
export const defaultMeasurementsControlState: MeasurementsControlState = {
measurementsGroupBy: undefined,
measurementsDisplay: "mean",
measurementsShowOverallMean: true,
measurementsShowThreshold: true,
measurementsFilters: {},
measurementsColorGrouping: undefined,
};
/* while this may change, div currently doesn't have CIs, so they shouldn't be displayed. */
export const shouldDisplayTemporalConfidence = (exists, distMeasure, layout): boolean => exists && distMeasure === "num_date" && layout === "rect";
const Controls = (state: ControlsState = getDefaultControlsState(), action): ControlsState => {
switch (action.type) {
case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: /* fallthrough */
case types.CLEAN_START:
return action.controls;
case types.SET_AVAILABLE:
return Object.assign({}, state, { available: action.data });
case types.CHANGE_EXPLODE_ATTR:
return Object.assign({}, state, {
explodeAttr: action.explodeAttr,
colorScale: Object.assign({}, state.colorScale, { visibleLegendValues: action.visibleLegendValues })
});
case types.CHANGE_BRANCH_LABEL:
return Object.assign({}, state, { selectedBranchLabel: action.value });
case types.TOGGLE_SHOW_ALL_BRANCH_LABELS:
return Object.assign({}, state, { showAllBranchLabels: action.value });
case types.CHANGE_LAYOUT:
return Object.assign({}, state, {
layout: action.layout,
canRenderBranchLabels: action.canRenderBranchLabels,
scatterVariables: action.scatterVariables,
/* temporal confidence can only be displayed for rectangular trees */
temporalConfidence: Object.assign({}, state.temporalConfidence, {
display: shouldDisplayTemporalConfidence(
state.temporalConfidence.exists,
state.distanceMeasure,
action.data
),
on: false
})
});
case types.CHANGE_DISTANCE_MEASURE: {
const updatesToState: Partial<ControlsState> = {
distanceMeasure: action.data,
branchLengthsToDisplay: state.branchLengthsToDisplay
};
if (
shouldDisplayTemporalConfidence(state.temporalConfidence.exists, action.data, state.layout)
) {
updatesToState.temporalConfidence = Object.assign({}, state.temporalConfidence, {
display: true
});
} else {
updatesToState.temporalConfidence = Object.assign({}, state.temporalConfidence, {
display: false,
on: false
});
}
return Object.assign({}, state, updatesToState);
}
case types.SET_FOCUS: {
return {...state, focus: action.focus}
}
case types.CHANGE_DATES_VISIBILITY_THICKNESS: {
const newDates: Partial<ControlsState> = { quickdraw: action.quickdraw };
if (action.dateMin) {
newDates.dateMin = action.dateMin;
newDates.dateMinNumeric = action.dateMinNumeric;
}
if (action.dateMax) {
newDates.dateMax = action.dateMax;
newDates.dateMaxNumeric = action.dateMaxNumeric;
}
const colorScale = {...state.colorScale, visibleLegendValues: action.visibleLegendValues};
return {...state, ...newDates, colorScale};
}
case types.CHANGE_ABSOLUTE_DATE_MIN:
return Object.assign({}, state, {
absoluteDateMin: action.data,
absoluteDateMinNumeric: calendarToNumeric(action.data)
});
case types.CHANGE_ABSOLUTE_DATE_MAX:
return Object.assign({}, state, {
absoluteDateMax: action.data,
absoluteDateMaxNumeric: calendarToNumeric(action.data)
});
case types.CHANGE_ANIMATION_TIME:
return Object.assign({}, state, {
mapAnimationDurationInMilliseconds: action.data
});
case types.CHANGE_ANIMATION_CUMULATIVE:
return Object.assign({}, state, {
mapAnimationCumulative: action.data
});
case types.CHANGE_ANIMATION_LOOP:
return Object.assign({}, state, {
mapAnimationShouldLoop: action.data
});
case types.MAP_ANIMATION_PLAY_PAUSE_BUTTON:
return Object.assign({}, state, {
quickdraw: action.data !== "Play",
animationPlayPauseButton: action.data
});
case types.CHANGE_ANIMATION_START:
return Object.assign({}, state, {
mapAnimationStartDate: action.data
});
case types.CHANGE_PANEL_LAYOUT:
return Object.assign({}, state, {
panelLayout: action.data
});
case types.CHANGE_TIP_LABEL_KEY:
return {...state, tipLabelKey: action.key};
case types.TREE_TOO_DATA:
return action.controls;
case types.TOGGLE_PANEL_DISPLAY:
return Object.assign({}, state, {
panelsToDisplay: action.panelsToDisplay,
panelLayout: action.panelLayout,
canTogglePanelLayout: action.canTogglePanelLayout
});
case types.NEW_COLORS: {
const newState = Object.assign({}, state, {
colorBy: action.colorBy,
colorScale: action.colorScale,
colorByConfidence: doesColorByHaveConfidence(state, action.colorBy)
});
if (action.scatterVariables) {
newState.scatterVariables = action.scatterVariables;
}
return newState;
}
case types.CHANGE_GEO_RESOLUTION:
return Object.assign({}, state, {
geoResolution: action.data
});
case types.SELECT_NODE: {
/**
* We don't store a (reference to) the node itself as that breaks redux's immutability checking,
* instead we store the information needed to access it from the nodes array(s)
*/
const existingFilterInfo = (state.filters?.[strainSymbol]||[]).find((info) => info.value===action.name);
const existingFilterState = existingFilterInfo === undefined ? null :
existingFilterInfo.active ? 'active' : 'inactive';
const selectedNode: SelectedNode = {name: action.name, idx: action.idx, existingFilterState, isBranch: action.isBranch, treeId: action.treeId};
return {...state, selectedNode};
}
case types.DESELECT_NODE: {
return {...state, selectedNode: null}
}
case types.APPLY_FILTER: {
// values arrive as array
const filters = Object.assign({}, state.filters, {});
if (action.values.length) { // set the filters to the new values
filters[action.trait] = action.values;
} else { // remove if no active+inactive filters
delete filters[action.trait]
}
/**
* If a tip modal is open then the strain will have been added as an active filter.
* If we inactivate/remove that specific strain filter then we want to close the modal too!
* (The inverse isn't true: filtering to a specific strain doesn't open the modal)
*/
let selectedNode = state.selectedNode
if (selectedNode &&
!selectedNode.isBranch &&
action.trait===strainSymbol &&
!action.values.find((f) => f.value===selectedNode.name && f.active)
) {
selectedNode = null;
}
return Object.assign({}, state, {
filters,
selectedNode,
});
}
case types.TOGGLE_TEMPORAL_CONF:
return Object.assign({}, state, {
temporalConfidence: Object.assign({}, state.temporalConfidence, {
on: !state.temporalConfidence.on
})
});
case types.SET_MODAL:
return Object.assign({}, state, {
modal: action.modal || null
});
case types.REMOVE_TREE_TOO:
return Object.assign({}, state, {
showTreeToo: false,
showTangle: false,
canTogglePanelLayout: hasMultipleGridPanels(state.panelsAvailable),
panelsToDisplay: state.panelsAvailable.slice()
});
case types.TOGGLE_TANGLE:
if (state.showTreeToo) {
return Object.assign({}, state, { showTangle: !state.showTangle });
}
return state;
case types.TOGGLE_STREAM_TREE:
return {...state, showStreamTrees: action.showStreamTrees};
case types.CHANGE_STREAM_TREE_BRANCH_LABEL:
return {...state, streamTreeBranchLabel: action.streamTreeBranchLabel, showStreamTrees: true};
case types.TOGGLE_SIDEBAR:
return Object.assign({}, state, { sidebarOpen: action.value });
case types.TOGGLE_LEGEND:
return Object.assign({}, state, { legendOpen: action.value });
case types.ADD_EXTRA_METADATA: {
for (const colorBy of Object.keys(action.newColorings)) {
state.coloringsPresentOnTree.add(colorBy);
}
let newState = Object.assign({}, state, { coloringsPresentOnTree: state.coloringsPresentOnTree, filters: state.filters });
if (action.newGeoResolution && !state.panelsAvailable.includes("map")) {
newState = {
...newState,
geoResolution: action.newGeoResolution.key,
canTogglePanelLayout: hasMultipleGridPanels([...state.panelsToDisplay, "map"]),
panelsAvailable: [...state.panelsAvailable, "map"],
panelsToDisplay: [...state.panelsToDisplay, "map"]
};
}
return newState;
}
case types.REMOVE_METADATA: {
const coloringsPresentOnTree = new Set(state.coloringsPresentOnTree);
action.nodeAttrsToRemove.forEach((colorBy: string): void => {
coloringsPresentOnTree.delete(colorBy);
})
return {...state, coloringsPresentOnTree};
}
case types.UPDATE_VISIBILITY_AND_BRANCH_THICKNESS: {
const colorScale = Object.assign({}, state.colorScale, { visibleLegendValues: action.visibleLegendValues });
return Object.assign({}, state, { colorScale: colorScale });
}
case types.TOGGLE_TRANSMISSION_LINES:
return Object.assign({}, state, { showTransmissionLines: action.data });
case types.LOAD_FREQUENCIES:
return {...state, normalizeFrequencies: action.normalizeFrequencies};
case types.FREQUENCY_MATRIX: {
if (Object.hasOwnProperty.call(action, "normalizeFrequencies")) {
return Object.assign({}, state, { normalizeFrequencies: action.normalizeFrequencies });
}
return state;
}
case types.CHANGE_MEASUREMENTS_COLLECTION: // fallthrough
case types.CHANGE_MEASUREMENTS_COLOR_GROUPING: // fallthrough
case types.CHANGE_MEASUREMENTS_DISPLAY: // fallthrough
case types.CHANGE_MEASUREMENTS_GROUP_BY: // fallthrough
case types.TOGGLE_MEASUREMENTS_OVERALL_MEAN: // fallthrough
case types.TOGGLE_MEASUREMENTS_THRESHOLD: // fallthrough
case types.APPLY_MEASUREMENTS_FILTER:
return {...state, ...action.controls};
/**
* Currently the CHANGE_ZOOM action (entropy panel zoom changed) does not
* update the zoomMin/zoomMax, and as such they only represent the initially
* requested zoom range. The following commented out code will keep the
* state in sync, but corresponding changes will be required to the entropy
* code.
*/
// case types.CHANGE_ZOOM: // this is the entropy panel zoom
// return {...state, zoomMin: action.zoomc[0], zoomMax: action.zoomc[1]};
default:
return state;
}
};
export default Controls;
function getInitialSidebarState(): {
sidebarOpen: boolean
setDefault: boolean
} {
return {
sidebarOpen: window.innerWidth > controlsHiddenWidth,
setDefault: false
};
}