auspice
Version:
Web app for visualizing pathogen evolution
417 lines (366 loc) • 16.4 kB
text/typescript
import type { Metadata, LegendContinuous, LegendNonContinuous } from "../reducers/metadata.types";
import { PANEL_VALUES, DISTANCE_MEASURE_VALUES, LAYOUT_VALUES, SIDEBAR_VALUES, PANELS_WITH_LEGEND, LegendPlacements } from "../reducers/metadata.types";
import { entropyCreateState } from "./entropyCreateStateFromJsons";
import * as utils from "./typeUtils";
import { ScaleType, SCALE_TYPE_VALUES, getDefaultControlsState } from "../reducers/controls";
import type { RootState } from "../store";
import { genomeMapToGenomeAnnotations } from "./entropyCreateStateFromJsons";
import { urlQueryLabel } from "./treeVisibilityHelpers";
import { version } from "../version";
/**
* Parse the `json.meta` property returning redux state for redux.metadata and redux.entropy.
*
* We don't check for the presence of unhandled keys, we just ignore them.
*/
export function parseJsonMetaBlock(json: unknown): { metadata: Metadata, entropy: unknown } {
if (!validJson(json)) {
throw new Error("Invalid JSON. Missing top-level .meta property")
}
const metadata: Partial<Metadata> = {};
metadata.edited = false;
metadata.title = utils.isString(json.meta.title) ? json.meta.title : '';
metadata.updated = utils.isString(json.meta.updated) ? json.meta.updated : '';
metadata.colorings = convertColoringsListToDict(json.meta.colorings);
if (utils.isString(json.meta.description)) {
metadata.description = json.meta.description;
}
if (utils.isString(json.meta.warning)) {
metadata.warning = json.meta.warning;
}
if (utils.isString(json.version)) {
metadata.version = json.version;
}
if (utils.isObject(json.meta.extensions) && utils.isString(json.meta.extensions.original_version)) {
metadata.originalVersion = json.meta.extensions.original_version;
}
if (Array.isArray(json.meta.maintainers)) {
metadata.maintainers = json.meta.maintainers
.map((m: unknown) => utils.validatedStringObject(m, ['name'], ['url']))
.filter((m): m is NonNullable<typeof m> => m !== null);
}
if (utils.isString(json.meta.build_url)) {
metadata.buildUrl = json.meta.build_url;
}
if (utils.isString(json.meta.build_avatar)) {
metadata.buildAvatar = json.meta.build_avatar;
}
if (Array.isArray(json?.meta?.data_provenance)) {
metadata.dataProvenance = json.meta.data_provenance.filter((el) => typeof el.name === 'string')
}
if (Array.isArray(json.meta.filters) && json.meta.filters.every((p) => utils.isString(p))) {
metadata.filters = json.meta.filters;
}
if (Array.isArray(json.meta.panels)) {
const panels = json.meta.panels.filter((p) => PANEL_VALUES.includes(p));
if (panels.length) {
metadata.panels = json.meta.panels;
}
}
if (Array.isArray(json.meta.stream_labels) && json.meta.stream_labels.every((p) => utils.isString(p))) {
metadata.streamLabels = json.meta.stream_labels;
}
if (json.root_sequence && utils.isObject(json.root_sequence)) {
/* A dataset may set the root sequence inline (i.e. within the main dataset JSON), which
we capture here. Alternatively it may be a sidecar JSON file (which is added to state later on) */
const rs = Object.entries(json.root_sequence)
.filter((entry): entry is [string, string] => utils.isString(entry[1]));
if (rs.length) {
metadata.rootSequence = Object.fromEntries(rs);
}
}
if (utils.isObject(json.meta.display_defaults)) {
const dd = json.meta.display_defaults;
const validPanels = (Array.isArray(dd.panels) && dd.panels || []).filter((p) => utils.isOneOf(p, PANEL_VALUES));
metadata.displayDefaults = {
...(utils.isBoolean(dd.map_triplicate) && {mapTriplicate: dd.map_triplicate}),
...(utils.isString(dd.geo_resolution) && { geoResolution: dd.geo_resolution }),
...(utils.isString(dd.color_by) && { colorBy: dd.color_by }),
...(utils.isOneOf(dd.distance_measure, DISTANCE_MEASURE_VALUES) && { distanceMeasure: dd.distance_measure }),
...(utils.isOneOf(dd.layout, LAYOUT_VALUES) && { layout: dd.layout }),
...(utils.isString(dd.branch_label) && { selectedBranchLabel: dd.branch_label }),
// 'label' is the branch label that tree starts zoomed to, expressed as <key>:<value>
...(utils.isString(dd.label) && dd.label.split(':').length===2 && { label: dd.label }),
...(utils.isString(dd.tip_label) && { tipLabelKey: dd.tip_label }),
...(utils.isString(dd.stream_label) && { streamLabel: dd.stream_label }),
...(utils.isBoolean(dd.transmission_lines) && {showTransmissionLines: dd.transmission_lines}),
...(utils.isString(dd.language) && { language: dd.language }),
...(utils.isOneOf(dd.sidebar, SIDEBAR_VALUES) && { sidebar: dd.sidebar }),
...(validPanels.length && { panels: validPanels }),
}
}
if (Array.isArray(json.meta.geo_resolutions)) {
metadata.geoResolutions = json.meta.geo_resolutions.filter((gr) => {
if (
!utils.isObject(gr) ||
!utils.isString(gr.key) ||
(Object.hasOwn(gr, 'title') && !utils.isString(gr.title)) ||
!utils.isObject(gr.demes) ||
!Object.values(gr.demes).every((d) => utils.isObject(d) && utils.isNumber(d.latitude) && utils.isNumber(d.longitude))
) {
return false;
}
return true;
});
}
metadata.legendPlacements = computeLegendPlacements(metadata.panels || []);
metadata.sharing = computeMetadataSharing(metadata, json.meta?.sharing)
if (Object.prototype.hasOwnProperty.call(metadata, "loaded")) {
console.error("Metadata JSON must not contain the key \"loaded\". Ignoring.");
}
metadata.loaded = true;
const genome_annotations = utils.isObject(json.meta.genome_annotations) ? json.meta.genome_annotations : {};
const entropy = entropyCreateState(genome_annotations);
assertMetadataComplete(metadata);
return { metadata, entropy };
}
function computeLegendPlacements(panels: Metadata["panels"]): Metadata["legendPlacements"] {
return panels.reduce((acc, panel) => {
if (!PANELS_WITH_LEGEND.includes(panel)) return acc;
let panelPlacement: LegendPlacements = {};
switch (panel) {
case "tree":
panelPlacement = {
tree: {
vertical: "top",
horizontal: "left"
}
}
break;
case "map":
panelPlacement = {
map: {
vertical: "top",
horizontal: "right"
}
}
break;
case "measurements":
panelPlacement = {
measurements: {
vertical: "top",
horizontal: "right"
}
}
break;
default:
throw new Error(`Unknown default legend placement for panel ${panel}`)
}
return {...acc, ...panelPlacement};
}, {})
}
/* control ability to share / download assets */
export function computeMetadataSharing(
metadataState: Partial<Metadata>,
userData: unknown,
): Metadata['sharing'] {
/** begin with defaults */
const sharing: Metadata['sharing'] = {
dataset_json: true,
metadata_tsv: true,
authors: true,
trees: true,
entropy: true,
screenshot: true
}
for (const [key, value] of Object.entries(_parseJsonSharingData(userData, Object.keys(sharing)))) {
sharing[key] = value;
}
/* Hardcode overrides for GISAID datasets */
const gisaid = (metadataState?.dataProvenance || []).filter((el) => el.name.toUpperCase() === 'GISAID').length > 0;
if (gisaid) {
sharing.dataset_json = false;
sharing.metadata_tsv = false;
sharing.gisaid_acknowledgments = true;
}
return sharing;
}
function _parseJsonSharingData(data: unknown, validKeys: string[]): Partial<Metadata['sharing']> {
if (data === undefined) return {};
if (!(typeof data === 'object' && !Array.isArray(data) && data !== null)) {
console.warn(`JSON.metadata.sharing must be an object (dict)`);
return {};
}
return Object.fromEntries(
Object.entries(data)
.map(([key, value]): [string,boolean]|null => {
if (!validKeys.includes(key)) {
console.warn(`JSON.metadata.sharing.${key} is not a valid key`);
return null;
}
if (value !== false && value !== true) {
console.warn(`JSON.metadata.sharing.${key} must be a boolean value, not ${value}`);
return null;
}
return [key, value]
})
.filter((x) => !!x)
)
}
function assertMetadataComplete(m: Partial<Metadata>): asserts m is Metadata {
const keys = ['sharing', 'title', 'updated', 'loaded'];
if (!keys.every((key) => Object.hasOwn(m, key))) {
throw new Error("[INTERNAL ERROR] Metadata is missing a required property");
}
}
function validJson(json: unknown): json is Record<string, unknown> & { meta: Record<string, unknown> } {
if (utils.isObject(json) && Object.hasOwn(json, 'meta')) {
return true;
}
return false
}
export function convertColoringsListToDict(
// JSON defined coloring list (array)
coloringsList: unknown
): undefined | Metadata['colorings'] {
if (!Array.isArray(coloringsList)) return undefined;
// Array.isArray narrows to any[], but prefer unknown[]
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return Object.fromEntries((coloringsList as unknown[])
.map((c) => {
if (!utils.isObject(c)) return undefined;
let { key, title, type, scale, legend } = c;
if (!utils.isString(key)) return undefined;
if (!utils.isString(title)) title = key; // title is required in schema but nicer to handle when missing
if (!utils.isOneOf(type, SCALE_TYPE_VALUES)) {
console.warn(`Scale for ${key} has unknown .type '${type}'. Must be one of ${SCALE_TYPE_VALUES.join(', ')}.`)
return undefined;
}
// ensure array appeases current type def: [string | number, string][]
// but we can use stronger typing & better validation here!
if (
!Array.isArray(scale) ||
!scale.every((s) =>
s.length === 2 &&
((type === 'continuous' && utils.isNumber(s[0])) || utils.isString(s[0])) &&
utils.isString(s[1])
)
) {
scale = undefined;
}
if (!Array.isArray(legend) || !legend.every((el) => _validateLegendArray(type, el))) {
legend = undefined;
}
return [key, { title, type, ...(scale && { scale }), ...(legend && {legend}) }]
})
.filter((x) => !!x));
}
function _validateLegendArray(
scaleType: ScaleType,
el: unknown
): el is LegendContinuous | LegendNonContinuous {
if (!utils.isObject(el)) return false;
if (
scaleType === 'continuous' && (
!utils.isNumber(el.value) ||
!Array.isArray(el.bounds) ||
el.bounds.length !== 2 ||
!utils.isNumber(el.bounds[0]) ||
!utils.isNumber(el.bounds[1])
)
) return true;
if (
scaleType === 'continuous' &&
!utils.isString(el.value)
) return true;
return false;
}
/**
* Somewhat the inverse of parseJsonMetaBlock: reconstructs the `meta` JSON object
* from the Redux metadata state, reversing structural transformations applied during ingestion.
*/
export function metadataStateToJson(reduxState: RootState): object {
const { metadata, entropy } = reduxState;
if (!metadata.loaded) {
console.error("Internal error (metadata.loaded = false)")
return {};
}
const meta: Record<string, unknown> = {};
// Ordering of metadata props here matches the `parseJsonMetaBlock` function so
// it's easier to compare code side-by-side
if (metadata.title) meta.title = metadata.title;
if (metadata.updated) meta.updated = metadata.updated;
if (metadata.colorings) {
const _colorings = Object.entries(metadata.colorings)
.filter(([key]) => key !== 'gt') // 'gt' is dynamically generated in Auspice
.map(([key, value]) => ({ key, ...value }));
if (_colorings.length) meta.colorings = _colorings;
}
if (metadata.description) meta.description = metadata.description;
if (metadata.warning) meta.warning = metadata.warning;
// `metadata.version` is stored in JSON.version, not JSON.meta.version
// Don't export 'originalVersion' (json.meta.extensions.original_version)
if (metadata.maintainers) meta.maintainers = metadata.maintainers;
if (metadata.buildUrl) meta.build_url = metadata.buildUrl;
if (metadata.buildAvatar) meta.build_avatar = metadata.buildAvatar;
if (metadata.dataProvenance) meta.data_provenance = metadata.dataProvenance;
if (metadata.filters) meta.filters = metadata.filters;
if (metadata.panels) meta.panels = metadata.panels;
if (metadata.streamLabels) meta.stream_labels = metadata.streamLabels;
// Note: metadata.rootSequence is assigned to json.root_sequence (or a sidecar), not json.meta
const display_defaults = computedDisplayDefaults(reduxState);
if (Object.keys(display_defaults).length) {
meta.display_defaults = display_defaults;
}
if (metadata.geoResolutions) meta.geo_resolutions = metadata.geoResolutions;
const sharing = _computeJsonSharing(metadata.sharing)
if (Object.keys(sharing).length) meta.sharing = sharing;
if (entropy.genomeMap?.length) {
meta.genome_annotations = genomeMapToGenomeAnnotations(entropy.genomeMap);
}
/** We don't store any extensions data from the original JSON, so we can't re-export that.
* For debugging purposes, detail where this JSON came from:
*/
meta.extensions = {
generated_by: `JSON created in Auspice (${version}) on ${_today()}`,
}
return meta;
}
function _computeJsonSharing(sharing: Metadata['sharing']): Record<string, false> {
return Object.fromEntries(
Object.entries(sharing)
.filter(([key, _value]) => key!=='gisaid_acknowledgments')
.filter(([_key, value]) => value===false)
)
}
/**
* Compute the display_defaults to (as best as we can) produce a JSON whose initial (default)
* state represents the current state of Auspice.
*/
function computedDisplayDefaults(state: RootState): Record<string, unknown> {
if (!state.metadata.loaded) {
console.error("Internal error (metadata.loaded = false)")
return {};
}
// Start by setting two base properties from the original display_defaults rather than
// recreating them from the current UI state
const dd = state.metadata.displayDefaults || {};
const defaults: Record<string, unknown> = {
// [auspice prop] [json key name] [auspice prop]
...(Object.hasOwn(dd, 'mapTriplicate') && { map_triplicate: dd.mapTriplicate }),
...(Object.hasOwn(dd, 'sidebar') && { sidebar: dd.sidebar }),
}
// Now inject specific data from redux state (i.e. UI-driven) if it doesn't match Auspice's defaults.
// (If it matches the defaults Auspice would set, then don't export it)
const defaultControls = getDefaultControlsState();
const { controls } = state;
if (controls.layout !== defaultControls.layout) defaults.layout = controls.layout;
if (controls.colorBy !== defaultControls.colorBy) defaults.color_by = controls.colorBy;
if (controls.distanceMeasure !== defaultControls.distanceMeasure) defaults.distance_measure = controls.distanceMeasure;
if (controls.geoResolution !== defaultControls.geoResolution) defaults.geo_resolution = controls.geoResolution;
if (controls.selectedBranchLabel !== defaultControls.selectedBranchLabel) defaults.branch_label = controls.selectedBranchLabel;
if (controls.tipLabelKey !== defaultControls.tipLabelKey) defaults.tip_label = controls.tipLabelKey;
if (controls.showTransmissionLines !== defaultControls.showTransmissionLines) defaults.transmission_lines = controls.showTransmissionLines;
if (controls.showStreamTrees) defaults.stream_label = controls.streamTreeBranchLabel;
if (controls.panelsToDisplay.length) defaults.panels = controls.panelsToDisplay;
// Find out if the current zoom level has an available (branch) label
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const label = urlQueryLabel(state.tree.nodes![state.tree.idxOfInViewRootNode], state.tree.availableBranchLabels)
if (label) defaults.label = label;
// language is special-cased in the code
if (state.general.language !== 'en') defaults.language = state.general.language;
return defaults;
}
/** Return current YYYY-MM-DD in user's local timezone */
function _today(): string {
return new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 10);
}