vitessce
Version:
Vitessce app and React component library
319 lines (299 loc) • 12.3 kB
JavaScript
/* eslint-disable no-plusplus */
/* eslint-disable camelcase */
import difference from 'lodash/difference';
import cloneDeep from 'lodash/cloneDeep';
import packageJson from '../../package.json';
import { getNextScope } from '../utils';
import {
AUTO_INDEPENDENT_COORDINATION_TYPES,
} from './state/coordination';
import { getViewTypes } from './component-registry';
import {
getComponentCoordinationTypes,
getDefaultCoordinationValues,
getCoordinationTypes,
getFileTypes,
getConvenienceFileTypes,
} from './plugins';
import { SCHEMA_HANDLERS } from './view-config-versions';
/**
* Get a list of all unique scope names for a
* particular coordination type, which exist in
* a particular view config.
* @param {object} config A view config object.
* @param {string} coordinationType A coordination type,
* for example 'spatialZoom' or 'dataset'.
* @returns {string[]} Array of existing coordination scope names.
*/
export function getExistingScopesForCoordinationType(config, coordinationType) {
const spaceScopes = Object.keys(config?.coordinationSpace?.[coordinationType] || {});
const componentScopes = config.layout.map(c => c.coordinationScopes?.[coordinationType]);
return Array.from(new Set([...spaceScopes, ...componentScopes]));
}
/**
* Give each component the same scope name for this coordination type.
* @param {object} config A view config object.
* @param {string} coordinationType A coordination type,
* for example 'spatialZoom' or 'dataset'.
* @param {*} scopeValue The initial value for the coordination scope,
* to set in the coordination space.
* @returns {object} The new view config.
*/
function coordinateComponentsTogether(config, coordinationType, scopeValue) {
const componentCoordinationTypes = getComponentCoordinationTypes();
const scopeName = getNextScope(getExistingScopesForCoordinationType(config, coordinationType));
const newConfig = {
...config,
coordinationSpace: {
...config.coordinationSpace,
[coordinationType]: {
...config?.coordinationSpace?.[coordinationType],
// Add the new scope name and value to the coordination space.
[scopeName]: scopeValue,
},
},
layout: config.layout.map(component => ({
...component,
coordinationScopes: {
...component.coordinationScopes,
// Only set the coordination scope if this component uses this coordination type,
// and the component is missing a coordination scope for this coordination type.
...((
componentCoordinationTypes[component.component].includes(coordinationType)
&& !component.coordinationScopes?.[coordinationType]
) ? {
// Only set the new scope name if the scope name
// for this component and coordination type is currently undefined.
[coordinationType]: scopeName,
} : {}),
},
})),
};
return newConfig;
}
/**
* Give each component a different scope name for this coordination type.
* @param {object} config A view config object.
* @param {string} coordinationType A coordination type,
* for example 'spatialZoom' or 'dataset'.
* @param {*} scopeValue The initial value for the coordination scope,
* to set in the coordination space.
* @returns {object} The new view config.
*/
function coordinateComponentsIndependent(config, coordinationType, scopeValue) {
const componentCoordinationTypes = getComponentCoordinationTypes();
const newConfig = {
...config,
layout: [...config.layout],
};
const newScopes = {};
newConfig.layout.forEach((component, i) => {
// Only set the coordination scope if this component uses this coordination type,
// and the component is missing a coordination scope for this coordination type.
if (componentCoordinationTypes[component.component].includes(coordinationType)
&& !component.coordinationScopes?.[coordinationType]
) {
const scopeName = getNextScope([
...getExistingScopesForCoordinationType(config, coordinationType),
...Object.keys(newScopes),
]);
newScopes[scopeName] = scopeValue;
newConfig.layout[i] = {
...component,
coordinationScopes: {
...component.coordinationScopes,
[coordinationType]: scopeName,
},
};
}
});
newConfig.coordinationSpace = {
...newConfig.coordinationSpace,
[coordinationType]: {
...newConfig.coordinationSpace[coordinationType],
// Add the new scope name and value to the coordination space.
...newScopes,
},
};
return newConfig;
}
function initializeAuto(config) {
let newConfig = config;
const { layout, datasets } = newConfig;
const componentCoordinationTypes = getComponentCoordinationTypes();
const defaultCoordinationValues = getDefaultCoordinationValues();
const coordinationTypes = getCoordinationTypes();
// For each coordination type, check whether it requires initialization.
coordinationTypes.forEach((coordinationType) => {
// A coordination type requires coordination if at least one component is missing
// a (coordination type, coordination scope) tuple.
// Components may only use a subset of all coordination types.
const requiresCoordination = !layout
.every(c => (
(!componentCoordinationTypes[c.component].includes(coordinationType))
|| c.coordinationScopes?.[coordinationType]
));
if (requiresCoordination) {
// Note that the default value may be undefined.
let defaultValue = defaultCoordinationValues[coordinationType];
// Check whether this is the special 'dataset' coordination type.
if (coordinationType === 'dataset' && datasets.length >= 1) {
// Use the first dataset ID as the default
// if there is at least one dataset.
defaultValue = datasets[0].uid;
}
// Use the list of "independent" coordination types
// to determine whether a particular coordination type
// should be initialized to
// a unique scope for every component ("independent")
// vs. the same scope for every component ("together").
if (AUTO_INDEPENDENT_COORDINATION_TYPES.includes(coordinationType)) {
newConfig = coordinateComponentsIndependent(newConfig, coordinationType, defaultValue);
} else {
newConfig = coordinateComponentsTogether(newConfig, coordinationType, defaultValue);
}
}
});
return newConfig;
}
export function checkTypes(config) {
// Add a log message when there are additionalProperties in the coordination space that
// do not appear in the view config JSON schema,
// with a note that this indicates either a mistake or custom coordination type usage.
const coordinationTypesInConfig = Object.keys(config.coordinationSpace || {});
const allCoordinationTypes = getCoordinationTypes();
const unknownCoordinationTypes = difference(coordinationTypesInConfig, allCoordinationTypes);
if (unknownCoordinationTypes.length > 0) {
return [false, `The following coordination types are not recognized: [${unknownCoordinationTypes}].\nIf these are plugin coordination types, ensure that they have been properly registered.`];
}
// Add a log message when there are views in the layout that are neither
// core views nor registered plugin views.
const viewTypesInConfig = config.layout.map(c => c.component);
const allViewTypes = getViewTypes();
const unknownViewTypes = difference(viewTypesInConfig, allViewTypes);
if (unknownViewTypes.length > 0) {
return [false, `The following view types are not recognized: [${unknownViewTypes}].\nIf these are plugin view types, ensure that they have been properly registered.`];
}
// Add a log message when there are file definitions with neither
// core nor registered plugin file types.
const fileTypesInConfig = config.datasets.flatMap(d => d.files.map(f => f.fileType));
const allFileTypes = getFileTypes();
const unknownFileTypes = difference(fileTypesInConfig, allFileTypes);
if (unknownFileTypes.length > 0) {
return [false, `The following file types are not recognized: [${unknownFileTypes}].\nIf these are plugin file types, ensure that they have been properly registered.`];
}
return [true, 'All view types, coordination types, and file types that appear in the view config are recognized.'];
}
/**
* Assign unique ids for view definitions where
* they are missing a value for the uid property
* in layout[].uid.
* @param {object} config The view config
* @returns The updated view config.
*/
function assignViewUids(config) {
const { layout } = config;
const usedIds = layout.map(view => view.uid);
layout.forEach((view, i) => {
// Assign uids for views where they are not present.
if (!view.uid) {
const nextUid = getNextScope(usedIds);
layout[i].uid = nextUid;
usedIds.push(nextUid);
}
});
return {
...config,
layout,
};
}
/**
* Expand convenience file definitions. Each convenience file
* definition expansion function takes in one file definition and
* returns an array of file definitions. Not performed recursively.
* @param {object} config The view config containing collapsed
* convenience file types.
* @returns The view config containing expanded minimal file types.
*/
function expandConvenienceFileDefs(config) {
const convenienceFileTypes = getConvenienceFileTypes();
const { datasets: currDatasets } = config;
const datasets = cloneDeep(currDatasets);
currDatasets.forEach((dataset, i) => {
const { files = [] } = dataset;
let newFiles = [];
files.forEach((fileDef) => {
const { fileType } = fileDef;
const expansionFunc = convenienceFileTypes[fileType];
if (expansionFunc && typeof expansionFunc === 'function') {
// This was a convenience file type, so expand it.
const expandedFileDefs = expansionFunc(fileDef);
newFiles = newFiles.concat(expandedFileDefs);
} else {
// This was not a convenience file type,
// so keep it in the files array as-is.
newFiles.push(fileDef);
}
});
datasets[i].files = newFiles;
});
return {
...config,
datasets,
};
}
/**
* Initialize the view config:
* - Fill in missing coordination objects with default values.
* - Fill in missing component coordination scope mappings.
* based on the `initStrategy` specified in the view config.
* - Fill in missing view uid values.
* - Expand convenience file types.
* Should be "stable": if run on the same view config twice, the return value the second
* time should be identical to the return value the first time.
* @param {object} config The view config prop.
* @returns The initialized view config.
*/
export function initialize(config) {
let newConfig = cloneDeep(config);
if (newConfig.initStrategy === 'auto') {
newConfig = initializeAuto(config);
}
newConfig = expandConvenienceFileDefs(newConfig);
return assignViewUids(newConfig);
}
export function upgradeAndValidate(oldConfig) {
// oldConfig object must have a `version` property.
let nextConfig = oldConfig;
let fromVersion;
let upgradeFunction; let
validateFunction;
do {
fromVersion = nextConfig.version;
if (!Object.keys(SCHEMA_HANDLERS).includes(fromVersion)) {
return [{
title: 'Config validation failed',
preformatted: 'Unknown config version.',
}, false];
}
[validateFunction, upgradeFunction] = SCHEMA_HANDLERS[fromVersion];
// Validate under the legacy schema before upgrading.
const validLegacy = validateFunction(nextConfig);
if (!validLegacy) {
const failureReason = JSON.stringify(validateFunction.errors, null, 2);
return [{
title: 'Config validation failed',
preformatted: failureReason,
}, false];
}
if (upgradeFunction) {
nextConfig = upgradeFunction(nextConfig);
}
} while (upgradeFunction);
// NOTE: Remove when a view config viewer/editor is available in UI.
console.groupCollapsed(`🚄 Vitessce (${packageJson.version}) view configuration`);
console.info(`data:,${JSON.stringify(nextConfig)}`);
console.info(JSON.stringify(nextConfig, null, 2));
console.groupEnd();
return [nextConfig, true];
}