diff --git a/src/actions/recomputeReduxState.js b/src/actions/recomputeReduxState.ts similarity index 94% rename from src/actions/recomputeReduxState.js rename to src/actions/recomputeReduxState.ts index 7ab386ea9..4bda5cb0b 100644 --- a/src/actions/recomputeReduxState.js +++ b/src/actions/recomputeReduxState.ts @@ -15,7 +15,7 @@ import { entropyCreateState, genomeMap as createGenomeMap } from "../util/entrop import { calcNodeColor } from "../util/colorHelpers"; import { calcColorScale, createVisibleLegendValues } from "../util/colorScale"; import { computeMatrixFromRawData, checkIfNormalizableFromRawData } from "../util/processFrequencies"; -import { applyInViewNodesToTree } from "../actions/tree"; +import { applyInViewNodesToTree } from "./tree"; import { validateScatterVariables } from "../util/scatterplotHelpers"; import { isColorByGenotype, decodeColorByGenotype, encodeColorByGenotype, decodeGenotypeFilters, encodeGenotypeFilters, getCdsFromGenotype } from "../util/getGenotype"; import { getTraitFromNode, getDivFromNode, collectGenotypeStates } from "../util/treeMiscHelpers"; @@ -24,6 +24,10 @@ import { hasMultipleGridPanels } from "./panelDisplay"; import { strainSymbolUrlString } from "../middleware/changeURL"; import { combineMeasurementsControlsAndQuery, loadMeasurements } from "./measurements"; +import { DatasetJson } from "../types/datasetJson"; +import { IncompleteMetadataState, convertIncompleteMetadataStateToMetadataState } from "../reducers/metadata"; +import { RootState } from "../store"; + export const doesColorByHaveConfidence = (controlsState, colorBy) => controlsState.coloringsPresentOnTreeWithConfidence.has(colorBy); @@ -153,8 +157,10 @@ const modifyStateViaURLQuery = (state, query) => { console.error("Invalid 'animate' URL query (invalid date range)") delete query.animate } else { - window.NEXTSTRAIN.animationStartPoint = _dminNum; - window.NEXTSTRAIN.animationEndPoint = _dmaxNum; + if ('NEXTSTRAIN' in window) { + (window.NEXTSTRAIN as any).animationStartPoint = calendarToNumeric(params[0]); + (window.NEXTSTRAIN as any).animationEndPoint = calendarToNumeric(params[1]); + } state.dateMin = _dmin; state.dateMax = _dmax; state.dateMinNumeric = _dminNum; @@ -268,13 +274,14 @@ const modifyStateViaMetadata = (state, metadata, genomeMap) => { const expectedTypes = ["string", "string", "string", "string", "boolean", "string", 'string', 'string', "boolean" , "boolean"]; for (let i = 0; i < keysToCheckFor.length; i += 1) { - if (Object.hasOwnProperty.call(metadata.displayDefaults, keysToCheckFor[i])) { - if (typeof metadata.displayDefaults[keysToCheckFor[i]] === expectedTypes[i]) { + const key = keysToCheckFor[i] as string; // cast as TS doesn't know i is a valid index of keysToCheckFor + if (Object.hasOwnProperty.call(metadata.displayDefaults, key)) { + if (typeof metadata.displayDefaults[key] === expectedTypes[i]) { if (keysToCheckFor[i] === "sidebar") { - if (metadata.displayDefaults[keysToCheckFor[i]] === "open") { + if (metadata.displayDefaults[key] === "open") { state.defaults.sidebarOpen = true; state.sidebarOpen = true; - } else if (metadata.displayDefaults[keysToCheckFor[i]]=== "closed") { + } else if (metadata.displayDefaults[key]=== "closed") { state.defaults.sidebarOpen = false; state.sidebarOpen = false; } else { @@ -282,8 +289,8 @@ const modifyStateViaMetadata = (state, metadata, genomeMap) => { } } else { /* most of the time if key=geoResolution, set both state.geoResolution and state.defaults.geoResolution */ - state[keysToCheckFor[i]] = metadata.displayDefaults[keysToCheckFor[i]]; - state.defaults[keysToCheckFor[i]] = metadata.displayDefaults[keysToCheckFor[i]]; + state[key] = metadata.displayDefaults[key]; + state.defaults[key] = metadata.displayDefaults[key]; } } else { console.error("Skipping 'display_default' for ", keysToCheckFor[i], "as it is not of type ", expectedTypes[i]); @@ -403,7 +410,7 @@ const modifyControlsStateViaTree = (state, tree, treeToo, colorings) => { state.coloringsPresentOnTree = new Set(); state.coloringsPresentOnTreeWithConfidence = new Set(); // subset of above - let coloringsToCheck = []; + let coloringsToCheck: string[] = []; if (colorings) { coloringsToCheck = Object.keys(colorings); } @@ -467,7 +474,7 @@ const modifyControlsStateViaTree = (state, tree, treeToo, colorings) => { return state; }; -const checkAndCorrectErrorsInState = (state, metadata, genomeMap, query, tree, viewingNarrative) => { +const checkAndCorrectErrorsInState = (state, metadata: IncompleteMetadataState, genomeMap, query, tree, viewingNarrative) => { /* want to check that the (currently set) colorBy (state.colorBy) is valid, * and fall-back to an available colorBy if not */ @@ -618,7 +625,8 @@ const checkAndCorrectErrorsInState = (state, metadata, genomeMap, query, tree, v continue } /* delete filter names (e.g. country, region) which aren't observed on the tree */ - if (!Object.keys(tree.totalStateCounts).includes(traitName) && traitName!==strainSymbol && traitName!==genotypeSymbol) { + // TS TODO - remove the following cast (and check code _was_ working as I thought it was...) + if (!Object.keys(tree.totalStateCounts).includes(traitName as string) && traitName!==strainSymbol && traitName!==genotypeSymbol) { delete state.filters[traitName]; delete query[_queryKey(traitName)]; continue @@ -732,11 +740,10 @@ const convertColoringsListToDict = (coloringsList) => { }; /** - * * A lot of this is simply changing augur's snake_case to auspice's camelCase */ -const createMetadataStateFromJSON = (json) => { - const metadata = {}; +const createMetadataStateFromJSON = (json: DatasetJson): IncompleteMetadataState => { + const metadata: IncompleteMetadataState = {loaded: false}; if (json.meta.colorings) { metadata.colorings = convertColoringsListToDict(json.meta.colorings); } @@ -794,10 +801,10 @@ const createMetadataStateFromJSON = (json) => { } - if (Object.prototype.hasOwnProperty.call(metadata, "loaded")) { + if (Object.prototype.hasOwnProperty.call(json.meta, "loaded")) { console.error("Metadata JSON must not contain the key \"loaded\". Ignoring."); } - metadata.loaded = true; + // metadata.loaded = true; // TODO XXX return metadata; }; @@ -819,7 +826,7 @@ function updateMetadataStateViaSecondTree(metadata, json, genomeMap) { metadata.identicalGenomeMapAcrossBothTrees = isEqualWith( genomeMap, createGenomeMap(json.meta.genome_annotations), - (objValue, othValue, indexOrKey) => { + (_objValue, _othValue, indexOrKey) => { if (indexOrKey==='color') return true; // don't compare CDS colors! // don't compare metadata section as there may be Infinities here // (and if everything else is equal then the metadata will be the same too) @@ -852,6 +859,19 @@ export const getNarrativePageFromQuery = (query, narrative) => { return n; }; +/* TS TODO */ +interface CreateStateArgs { + json: DatasetJson | false; + secondTreeDataset: any; + measurementsData: any; + oldState: RootState | false; + narrativeBlocks: any; /* if in a narrative this argument is set */ + mainTreeName: string | false; + secondTreeName: string | false; + query?: any; + dispatch: any; /* redux should define this for us... */ +} + export const createStateFromQueryOrJSONs = ({ json = false, /* raw json data - completely nuke existing redux state */ measurementsData = false, /* raw measurements json data or error, only used when main json is provided */ @@ -862,8 +882,9 @@ export const createStateFromQueryOrJSONs = ({ secondTreeName = false, query, dispatch -}) => { - let tree, treeToo, entropy, controls, metadata, narrative, frequencies, measurements; +}: CreateStateArgs) => { + let tree, treeToo, entropy, controls, narrative, frequencies, measurements; + let metadata: IncompleteMetadataState; /* first task is to create metadata, entropy, controls & tree partial state */ if (json) { /* create metadata state */ @@ -895,11 +916,14 @@ export const createStateFromQueryOrJSONs = ({ entropy = {...oldState.entropy}; tree = {...oldState.tree}; treeToo = {...oldState.treeToo}; - metadata = {...oldState.metadata}; + metadata = {...oldState.metadata, loaded: false}; frequencies = {...oldState.frequencies}; measurements = {...oldState.measurements}; controls = restoreQueryableStateToDefaults(controls); controls = modifyStateViaMetadata(controls, metadata, entropy.genomeMap); + } else { + // can this _ever_ occur? If it does then `metadata` is undefined... + throw new Error("TKTK") } /* For the creation of state, we want to parse out URL query parameters @@ -949,7 +973,7 @@ export const createStateFromQueryOrJSONs = ({ /* calculate colours if loading from JSONs or if the query demands change */ - if (json || controls.colorBy !== oldState.controls.colorBy) { + if (json || (oldState && controls.colorBy !== oldState.controls.colorBy)) { const colorScale = calcColorScale(controls.colorBy, controls, tree, treeToo, metadata); const nodeColors = calcNodeColor(tree, colorScale); controls.colorScale = colorScale; @@ -1043,7 +1067,9 @@ export const createStateFromQueryOrJSONs = ({ /* if narratives then switch the query back to ?n= for display */ if (narrativeBlocks) query = {n: narrativeSlideIdx}; - return {tree, treeToo, metadata, entropy, controls, narrative, frequencies, measurements, query}; + const meta = convertIncompleteMetadataStateToMetadataState(metadata); + + return {tree, treeToo, metadata: meta, entropy, controls, narrative, frequencies, measurements, query}; }; export const createTreeTooState = ({ diff --git a/src/reducers/metadata.js b/src/reducers/metadata.js deleted file mode 100644 index 818159496..000000000 --- a/src/reducers/metadata.js +++ /dev/null @@ -1,75 +0,0 @@ -import { colorOptions } from "../util/globals"; -import * as types from "../actions/types"; - -/* The metadata reducer holds data that is - * (a) mostly derived from the dataset JSON - * (b) rarely changes - */ - -const Metadata = (state = { - loaded: false, /* see comment in the sequences reducer for explanation */ - metadata: null, - rootSequence: undefined, - identicalGenomeMapAcrossBothTrees: false, - rootSequenceSecondTree: undefined, - colorOptions // this can't be removed as the colorScale currently runs before it should -}, action) => { - switch (action.type) { - case types.DATA_INVALID: - return Object.assign({}, state, { - loaded: false - }); - case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: - case types.TREE_TOO_DATA: - case types.CLEAN_START: - return action.metadata; - case types.ADD_EXTRA_METADATA: { - const colorings = Object.assign({}, state.colorings, action.newColorings); - let geoResolutions = state.geoResolutions; - if (action.newGeoResolution) { - if (!geoResolutions) geoResolutions = [action.newGeoResolution]; /* case where no geoRes in JSON */ - else geoResolutions = [...geoResolutions, action.newGeoResolution]; - } - return Object.assign({}, state, {colorings, geoResolutions}); - } - case types.SET_AVAILABLE: { - if (state.buildUrl) { - return state; // do not use data from getAvailable to overwrite a buildUrl set from a dataset JSON - } - const buildUrl = getBuildUrlFromGetAvailableJson(action.data.datasets); - if (buildUrl) { - return Object.assign({}, state, {buildUrl}); - } - return state; - } - case types.SET_ROOT_SEQUENCE: - return {...state, rootSequence: action.data}; - case types.REMOVE_TREE_TOO: - return Object.assign({}, state, { - identicalGenomeMapAcrossBothTrees: false, - rootSequenceSecondTree: undefined, - }); - default: - return state; - } -}; - -function getBuildUrlFromGetAvailableJson(availableData) { - if (!availableData) return undefined; - /* check if the current dataset is present in the getAvailable data - We currently parse the URL (pathname) for the current dataset but this - really should be stored somewhere in redux */ - const displayedDatasetString = window.location.pathname - .replace(/^\//, '') - .replace(/\/$/, '') - .split(":")[0]; - for (let i=0; i['geo_resolutions']; + buildUrl: Required['build_url'] | false; + displayDefaults: Record; // TODO XXX + panels: Required['panels']; + mainTreeNumTips: number; + title: Required['title']; + version: DatasetJson['version']; + filters: Required['filters']; + dataProvenance: Required['data_provenance']; + maintainers: Required['maintainers']; + description: Required['description']; + updated: Required['updated']; +} + +export type IncompleteMetadataState = Partial> & {loaded: false}; + +export function convertIncompleteMetadataStateToMetadataState(meta: IncompleteMetadataState): MetadataState { + // We need to duplicate they keys here since we can't access the interface at runtime? Seems a little silly + // Also, to do this properly we're essentially implementing runtime type checking or schema validation, + // and we don't want to be doing that. What's the best path here? + const expectedProperties: [string, string, any][] = [ + // THIS IS INCOMPLETE - TODO XXX + ["title", "string", null], + ["version", "string", null], + ["filters", "string", []], + ["updated", "string", new Error("JSON.meta missing property 'updated' which is essential")], // TKTK - it's not essential, just for testing + ] + for (const [key, typeShouldBe, _default] of expectedProperties) { + if (typeof meta[key] === typeShouldBe) continue + if (_default instanceof Error) { + throw _default; + } + meta[key] = _default; + } + // Can we replace the following cast with a Type predicate? That seems incompatible with the loaded boolean + // acting as a type discriminant + return {...meta, loaded: true} as MetadataState; +} + +// function isMetadataStateTypePredicate(meta: IncompleteMetadataState | MetadataState): meta is MetadataState { +// if (meta.loaded !== true) return false; +// // the type predicate should assert properties exist etc, see convertPartialMetadataStateToMetadataState +// // TODO XXX +// return true; +// } + +function getDefaultMetadataState(): IncompleteMetadataState { + return { + loaded: false, + }; +} + +/* The metadata reducer holds data that is + * (a) mostly derived from the dataset JSON + * (b) rarely changes + */ +const Metadata = (state:IncompleteMetadataState | MetadataState = getDefaultMetadataState(), action): IncompleteMetadataState | MetadataState => { + + // TODO XXX - does this maybe have to be unloaded vs loaded, discriminating on 'loaded: boolean' ??? + + switch (action.type) { + case types.DATA_INVALID: + return Object.assign({}, state, { + loaded: false + }); + case types.URL_QUERY_CHANGE_WITH_COMPUTED_STATE: + case types.TREE_TOO_DATA: + case types.CLEAN_START: + console.log("incoming!", action.metadata) + return action.metadata; + case types.ADD_EXTRA_METADATA: { + const colorings = Object.assign({}, state.colorings, action.newColorings); + let geoResolutions = state.geoResolutions; + if (action.newGeoResolution) { + if (!geoResolutions) geoResolutions = [action.newGeoResolution]; /* case where no geoRes in JSON */ + else geoResolutions = [...geoResolutions, action.newGeoResolution]; + } + return Object.assign({}, state, {colorings, geoResolutions}); + } + case types.SET_AVAILABLE: { + if (state.buildUrl) { + return state; // do not use data from getAvailable to overwrite a buildUrl set from a dataset JSON + } + const buildUrl = getBuildUrlFromGetAvailableJson(action.data.datasets); + if (buildUrl) { + return Object.assign({}, state, {buildUrl}); + } + return state; + } + case types.SET_ROOT_SEQUENCE: + return {...state, rootSequence: action.data}; + case types.REMOVE_TREE_TOO: + return Object.assign({}, state, { + identicalGenomeMapAcrossBothTrees: false, + rootSequenceSecondTree: undefined, + }); + default: + return state; + } +}; + +// TODO - can we replace the returned 'false' with 'undefined'? +function getBuildUrlFromGetAvailableJson(availableData): (false | undefined | string) { + if (!availableData) return undefined; + /* check if the current dataset is present in the getAvailable data + We currently parse the URL (pathname) for the current dataset but this + really should be stored somewhere in redux */ + const displayedDatasetString = window.location.pathname + .replace(/^\//, '') + .replace(/\/$/, '') + .split(":")[0]; + for (let i=0; i -// enum Strand {'+', '-'} // other GFF-valid options are '.' and '?' -type Strand = string; +export type JsonAnnotations = Record +type Strand = "+" | "-"; // note that other GFF-valid options are '.' and '?' but not used in Auspice datasets type JsonSegmentRange = {start: number, end: number}; // Start is 1-based, End is 1-based closed (GFF) interface JsonAnnotation { /* Other properties are commonly set in the JSON structure, but the following are @@ -141,7 +140,7 @@ export const genomeMap = (annotations: JsonAnnotations): GenomeAnnotation => { return [chromosome]; } -export const entropyCreateState = (genomeAnnotations: JsonAnnotations) => { +export const entropyCreateState = (genomeAnnotations: JsonAnnotations|undefined) => { if (genomeAnnotations) { try { return {