higlass
Version:
HiGlass Hi-C / genomic / large data viewer
1,701 lines (1,432 loc) • 166 kB
JSX
// @ts-nocheck
import clsx from 'clsx';
import { ElementQueries, ResizeSensor } from 'css-element-queries';
import { scaleLinear } from 'd3-scale';
import { pointer, select } from 'd3-selection';
import * as PIXI from 'pixi.js';
import PropTypes from 'prop-types';
import createPubSub, { globalPubSub } from 'pub-sub-es';
import React from 'react';
import ReactGridLayout from 'react-grid-layout';
import slugid from 'slugid';
import parse from 'url-parse';
import vkbeautify from 'vkbeautify';
import ChromosomeInfo from './ChromosomeInfo';
import ExportLinkDialog from './ExportLinkDialog';
import GenomePositionSearchBox from './GenomePositionSearchBox';
import TiledPlot from './TiledPlot';
import ViewConfigEditor from './ViewConfigEditor';
import ViewHeader from './ViewHeader';
import createApi from './api';
import { all as icons } from './icons';
import createSymbolIcon from './symbol';
import { Provider as ModalProvider } from './hocs/with-modal';
// Higher-order components
import { Provider as PubSubProvider } from './hocs/with-pub-sub';
import { Provider as ThemeProvider } from './hocs/with-theme';
import HiGlassComponentContext from './HiGlassComponentContext';
// Services
import {
chromInfo,
createDomEvent,
requestsInFlight,
setTileProxyAuthHeader,
tileProxy,
} from './services';
// Utils
import {
debounce,
dictFromTuples,
dictItems,
dictKeys,
dictValues,
download,
fillInMinWidths,
forwardEvent,
getElementDim,
getTrackByUid,
getTrackObjById,
getTrackPositionByUid,
hasParent,
// loadChromInfos,
numericifyVersion,
objVals,
scalesCenterAndK,
scalesToGenomeLoci,
toVoid,
visitPositionedTracks,
} from './utils';
import positionedTracksToAllTracks from './utils/positioned-tracks-to-all-tracks';
// Configs
import {
DEFAULT_CONTAINER_PADDING_X,
DEFAULT_CONTAINER_PADDING_Y,
DEFAULT_SERVER,
DEFAULT_VIEW_MARGIN,
DEFAULT_VIEW_PADDING,
GLOBALS,
LOCATION_LISTENER_PREFIX,
LONG_DRAG_TIMEOUT,
MOUSE_TOOL_MOVE,
MOUSE_TOOL_SELECT,
MOUSE_TOOL_TRACK_SELECT,
SHORT_DRAG_TIMEOUT,
SIZE_MODE_BOUNDED,
SIZE_MODE_BOUNDED_OVERFLOW,
SIZE_MODE_DEFAULT,
SIZE_MODE_OVERFLOW,
SIZE_MODE_SCROLL,
THEME_DARK,
THEME_LIGHT,
TRACKS_INFO_BY_TYPE,
} from './configs';
// Styles
import styles from '../styles/HiGlass.module.scss';
import stylesMTHeader from '../styles/ViewHeader.module.scss';
import '../styles/HiGlass.scss';
const NUM_GRID_COLUMNS = 12;
const DEFAULT_NEW_VIEW_HEIGHT = 12;
const VIEW_HEADER_HEIGHT = 20;
class HiGlassComponent extends React.Component {
constructor(props) {
super(props);
// Check React version
if (numericifyVersion(React.version) < 15.6) {
console.warn(
'HiGlass requires React v15.6 or higher. Current version: ',
React.version,
);
}
this.topDivRef = React.createRef();
this.pubSub = createPubSub();
this.domEvent = createDomEvent(this.pubSub);
this.pubSubs = [];
this.minHorizontalHeight = 20;
this.minVerticalWidth = 20;
this.resizeSensor = null;
this.uid = slugid.nice();
/** @type {Record<string, import('./TiledPlot').TiledPlot> */
this.tiledPlots = {};
this.genomePositionSearchBoxes = {};
// keep track of the xScales of each Track Renderer
/** @type {Record<string, import('./TrackRenderer').Scale>} */
this.xScales = {};
/** @type {Record<string, import('./TrackRenderer').Scale>} */
this.yScales = {};
this.projectionXDomains = {};
this.projectionYDomains = {};
this.topDiv = null;
this.zoomToDataExtentOnInit = new Set();
// a reference of view / track combinations
// to be used with combined to viewAndTrackUid
this.viewTrackUidsToCombinedUid = {};
this.combinedUidToViewTrack = {};
// event listeners for when the scales of a view change
// bypasses the React event framework because this needs
// to be fast
// indexed by view uid and then listener uid
this.scalesChangedListeners = {};
this.draggingChangedListeners = {};
this.valueScalesChangedListeners = {};
// locks that keep the location and zoom synchronized
// between views
this.zoomLocks = {};
this.locationLocks = {};
// axis-specific location lock
this.locationLocksAxisWise = { x: {}, y: {} };
// locks that keep the value scales synchronized between
// *tracks* (which can be in different views)
this.valueScaleLocks = {};
this.prevAuthToken = props.options.authToken;
this.setCenters = {};
this.plusImg = {};
this.configImg = {};
// allow a different PIXI to be passed in case the
// caller wants to use a different version
GLOBALS.PIXI = props.options?.PIXI || PIXI;
this.viewMarginTop =
+props.options.viewMarginTop >= 0
? +props.options.viewMarginTop
: DEFAULT_VIEW_MARGIN;
this.viewMarginBottom =
+props.options.viewMarginBottom >= 0
? +props.options.viewMarginBottom
: DEFAULT_VIEW_MARGIN;
this.viewMarginLeft =
+props.options.viewMarginLeft >= 0
? +props.options.viewMarginLeft
: DEFAULT_VIEW_MARGIN;
this.viewMarginRight =
+props.options.viewMarginRight >= 0
? +props.options.viewMarginRight
: DEFAULT_VIEW_MARGIN;
this.viewPaddingTop =
+props.options.viewPaddingTop >= 0
? +props.options.viewPaddingTop
: DEFAULT_VIEW_PADDING;
this.viewPaddingBottom =
+props.options.viewPaddingBottom >= 0
? +props.options.viewPaddingBottom
: DEFAULT_VIEW_PADDING;
this.viewPaddingLeft =
+props.options.viewPaddingLeft >= 0
? +props.options.viewPaddingLeft
: DEFAULT_VIEW_PADDING;
this.viewPaddingRight =
+props.options.viewPaddingRight >= 0
? +props.options.viewPaddingRight
: DEFAULT_VIEW_PADDING;
this.genomePositionSearchBox = null;
this.viewHeaders = {};
this.boundRefreshView = () => {
this.refreshView(LONG_DRAG_TIMEOUT);
};
this.unsetOnLocationChange = [];
this.setTheme(props.options.theme, props.options.isDarkTheme);
this.viewconfLoaded = false;
const { viewConfig } = this.props;
const views = this.processViewConfig(
JSON.parse(JSON.stringify(this.props.viewConfig)),
);
if (props.options.authToken) {
setTileProxyAuthHeader(props.options.authToken);
}
this.pixiRoot = new GLOBALS.PIXI.Container();
this.pixiRoot.interactive = true;
this.pixiStage = new GLOBALS.PIXI.Container();
this.pixiStage.interactive = true;
this.pixiRoot.addChild(this.pixiStage);
this.pixiMask = new GLOBALS.PIXI.Graphics();
this.pixiRoot.addChild(this.pixiMask);
this.pixiStage.mask = this.pixiMask;
this.element = null;
this.scrollTop = 0;
let mouseTool = MOUSE_TOOL_MOVE;
if (this.props.options) {
switch (this.props.options.mouseTool) {
case MOUSE_TOOL_SELECT:
mouseTool = MOUSE_TOOL_SELECT;
break;
case MOUSE_TOOL_TRACK_SELECT:
mouseTool = MOUSE_TOOL_TRACK_SELECT;
break;
default:
break;
}
}
if (this.props.options.pluginTracks) {
window.higlassTracksByType = Object.assign(
window.higlassTracksByType || {},
this.props.options.pluginTracks,
);
}
const pluginTracks = {};
try {
if (window.higlassTracksByType) {
Object.entries(window.higlassTracksByType).forEach(
([trackType, trackDef]) => {
pluginTracks[trackType] = trackDef;
},
);
}
} catch (e) {
console.warn('Broken config of a plugin track');
}
if (this.props.options.pluginDataFetchers) {
window.higlassDataFetchersByType = Object.assign(
window.higlassDataFetchersByType || {},
this.props.options.pluginDataFetchers,
);
}
const pluginDataFetchers = window.higlassDataFetchersByType;
const rowHeight = this.props.options.pixelPreciseMarginPadding ? 1 : 30;
this.mounted = false;
this.pluginTracks = pluginTracks;
this.pluginDataFetchers = pluginDataFetchers;
this.state = {
currentBreakpoint: 'lg',
width: 0,
height: 0,
rowHeight,
svgElement: null,
canvasElement: null,
customDialog: null,
views,
viewConfig,
addTrackPositionMenuPosition: null,
typedEditable: undefined,
mouseOverOverlayUid: null,
mouseTool,
overTrackChooser: null,
isDarkTheme: false,
rangeSelection1dSize: [0, Number.POSITIVE_INFINITY],
rangeSelectionToInt: false,
modal: null,
sizeMode: this.props.options?.sizeMode,
};
// monitor whether this element is attached to the DOM so that
// we can determine whether to add the resizesensor
this.attachedToDOM = false;
// Set up API
const {
public: api,
destroy: apiDestroy,
publish: apiPublish,
stack: apiStack,
} = createApi(this, this.pubSub);
this.api = api;
this.apiDestroy = apiDestroy;
this.apiPublish = apiPublish;
this.apiStack = apiStack;
this.viewChangeListener = [];
this.triggerViewChangeDb = debounce(this.triggerViewChange.bind(this), 250);
this.pubSubs = [];
this.rangeSelection = [null, null];
this.prevMouseHoverTrack = null;
this.zooming = false;
// Bound functions
this.appClickHandlerBound = this.appClickHandler.bind(this);
this.canvasClickHandlerBound = this.canvasClickHandler.bind(this);
this.keyDownHandlerBound = this.keyDownHandler.bind(this);
this.keyUpHandlerBound = this.keyUpHandler.bind(this);
this.resizeHandlerBound = this.resizeHandler.bind(this);
this.resizeHandlerBound = this.resizeHandler.bind(this);
this.dispatchEventBound = this.dispatchEvent.bind(this);
this.animateOnMouseMoveHandlerBound =
this.animateOnMouseMoveHandler.bind(this);
this.zoomStartHandlerBound = this.zoomStartHandler.bind(this);
this.zoomEndHandlerBound = this.zoomEndHandler.bind(this);
this.zoomHandlerBound = this.zoomHandler.bind(this);
this.trackDroppedHandlerBound = this.trackDroppedHandler.bind(this);
this.trackDimensionsModifiedHandlerBound =
this.trackDimensionsModifiedHandler.bind(this);
this.animateBound = this.animate.bind(this);
this.animateOnGlobalEventBound = this.animateOnGlobalEvent.bind(this);
this.requestReceivedHandlerBound = this.requestReceivedHandler.bind(this);
this.wheelHandlerBound = this.wheelHandler.bind(this);
this.mouseMoveHandlerBound = this.mouseMoveHandler.bind(this);
this.onMouseLeaveHandlerBound = this.onMouseLeaveHandler.bind(this);
this.onBlurHandlerBound = this.onBlurHandler.bind(this);
this.openModalBound = this.openModal.bind(this);
this.closeModalBound = this.closeModal.bind(this);
this.handleEditViewConfigBound = this.handleEditViewConfig.bind(this);
this.onScrollHandlerBound = this.onScrollHandler.bind(this);
this.viewUidToNameBound = this.viewUidToName.bind(this);
// for typed shortcuts (e.g. e-d-i-t) to toggle editable
this.typedText = '';
this.typedTextTimeout = null;
this.modal = {
open: this.openModalBound,
close: this.closeModalBound,
};
this.setBroadcastMousePositionGlobally(
this.props.options.broadcastMousePositionGlobally ||
this.props.options.globalMousePosition,
);
this.setShowGlobalMousePosition(
this.props.options.showGlobalMousePosition ||
this.props.options.globalMousePosition,
);
}
UNSAFE_componentWillMount() {
this.domEvent.register('keydown', document);
this.domEvent.register('keyup', document);
this.domEvent.register('scroll', document);
this.domEvent.register('resize', window);
this.domEvent.register('orientationchange', window);
this.domEvent.register('wheel', window);
this.domEvent.register('mousedown', window, true);
this.domEvent.register('mouseup', window, true);
this.domEvent.register('click', window, true);
this.domEvent.register('mousemove', window);
this.domEvent.register('touchmove', window);
this.domEvent.register('touchstart', window);
this.domEvent.register('touchend', window);
this.domEvent.register('touchcancel', window);
this.domEvent.register('blur', window);
this.pubSubs.push(
this.pubSub.subscribe('app.click', this.appClickHandlerBound),
this.pubSub.subscribe('blur', this.onBlurHandlerBound),
this.pubSub.subscribe('keydown', this.keyDownHandlerBound),
this.pubSub.subscribe('keyup', this.keyUpHandlerBound),
this.pubSub.subscribe('resize', this.resizeHandlerBound),
this.pubSub.subscribe('wheel', this.wheelHandlerBound),
this.pubSub.subscribe('orientationchange', this.resizeHandlerBound),
this.pubSub.subscribe('app.event', this.dispatchEventBound),
this.pubSub.subscribe(
'app.animateOnMouseMove',
this.animateOnMouseMoveHandlerBound,
),
this.pubSub.subscribe('trackDropped', this.trackDroppedHandlerBound),
this.pubSub.subscribe(
'trackDimensionsModified',
this.trackDimensionsModifiedHandlerBound,
),
this.pubSub.subscribe('app.zoomStart', this.zoomStartHandlerBound),
this.pubSub.subscribe('app.zoomEnd', this.zoomEndHandlerBound),
this.pubSub.subscribe('app.zoom', this.zoomHandlerBound),
this.pubSub.subscribe(
'requestReceived',
this.requestReceivedHandlerBound,
),
);
if (this.props.getApi) {
this.props.getApi(this.api);
}
}
get sizeMode() {
return typeof this.state.sizeMode === 'undefined'
? this.props.options.bounded
? 'bounded'
: SIZE_MODE_DEFAULT
: this.state.sizeMode;
}
setBroadcastMousePositionGlobally(isBroadcastMousePositionGlobally = false) {
this.isBroadcastMousePositionGlobally = isBroadcastMousePositionGlobally;
}
setShowGlobalMousePosition(isShowGlobalMousePosition = false) {
this.isShowGlobalMousePosition = isShowGlobalMousePosition;
if (this.isShowGlobalMousePosition && !this.globalMousePositionListener) {
this.globalMousePositionListener = globalPubSub.subscribe(
'higlass.mouseMove',
this.animateOnGlobalEventBound,
);
this.pubSubs.push(this.globalMousePositionListener);
}
if (this.isShowGlobalMousePosition && !this.globalMousePositionListener) {
const index = this.pubSubs.findIndex(
(listener) => listener === this.globalMousePositionListener,
);
globalPubSub.unsubscribe(this.globalMousePositionListener);
if (index >= 0) this.pubSubs.splice(index, 1);
this.globalMousePositionListener = undefined;
}
}
zoomStartHandler() {
this.hideHoverMenu();
this.zooming = true;
}
zoomEndHandler() {
this.zooming = false;
}
zoomHandler(evt) {
if (!evt.sourceEvent) return;
this.mouseMoveHandler(evt.sourceEvent);
}
waitForDOMAttachment(callback) {
if (!this.mounted) return;
const thisElement = this.topDivRef.current;
if (thisElement && document.body.contains(thisElement)) {
callback();
} else {
requestAnimationFrame(() => this.waitForDOMAttachment(callback));
}
}
componentDidMount() {
// the addEventListener is necessary because TrackRenderer determines where to paint
// all the elements based on their bounding boxes. If the window isn't
// in focus, everything is drawn at the top and overlaps. When it gains
// focus we need to redraw everything in its proper place
this.mounted = true;
this.element = this.topDivRef.current;
window.addEventListener('focus', this.boundRefreshView);
Object.values(this.state.views).forEach((view) => {
this.adjustLayoutToTrackSizes(view);
if (!view.layout) {
view.layout = this.generateViewLayout(view);
} else {
view.layout.i = view.uid;
}
});
const rendererOptions = {
width: this.state.width,
height: this.state.height,
view: this.canvasElement,
antialias: true,
transparent: true,
resolution: 2,
autoResize: true,
};
switch (PIXI.VERSION[0]) {
case '4':
console.warn(
'Deprecation warning: please update Pixi.js to version 5!',
);
if (this.props.options.renderer === 'canvas') {
this.pixiRenderer = new GLOBALS.PIXI.CanvasRenderer(rendererOptions);
} else {
this.pixiRenderer = new GLOBALS.PIXI.WebGLRenderer(rendererOptions);
}
break;
// case '5':
// case '6':
// case '7': // Gosling uses PIXI.js v7
default:
if (this.props.options.renderer === 'canvas') {
this.pixiRenderer = new GLOBALS.PIXI.CanvasRenderer(rendererOptions);
} else {
this.pixiRenderer = new GLOBALS.PIXI.Renderer(rendererOptions);
}
break;
}
// PIXI.RESOLUTION=2;
this.fitPixiToParentContainer();
// keep track of the width and height of this element, because it
// needs to be reflected in the size of our drawing surface
this.setState({
svgElement: this.svgElement,
canvasElement: this.canvasElement,
});
this.waitForDOMAttachment(() => {
ElementQueries.listen();
this.resizeSensor = new ResizeSensor(
this.element.parentNode,
this.updateAfterResize.bind(this),
);
// this.forceUpdate();
this.updateAfterResize();
});
this.handleDragStart();
this.handleDragStop();
this.animate();
// this.handleExportViewsAsLink();
const baseSvg = select(this.element).append('svg').style('display', 'none');
// Add SVG Icons
icons.forEach((icon) =>
createSymbolIcon(baseSvg, icon.id, icon.paths, icon.viewBox),
);
}
getTrackObject(viewUid, trackUid) {
return this.tiledPlots[viewUid].trackRenderer.getTrackObject(trackUid);
}
getTrackRenderer(viewUid) {
return this.tiledPlots[viewUid].trackRenderer;
}
UNSAFE_componentWillReceiveProps(newProps) {
if (this.mounted) {
this.setState({ viewConfig: newProps.viewConfig });
}
const viewsByUid = this.processViewConfig(
JSON.parse(JSON.stringify(newProps.viewConfig)),
);
if (newProps.options.authToken !== this.prevAuthToken) {
// we go a new auth token so we should reload everything
setTileProxyAuthHeader(newProps.options.authToken);
this.reload();
this.prevAuthToken = newProps.options.authToken;
}
// make sure that the current view is tall enough to display
// all the tracks (if unbounded, which is checked in adjustLayout...)
for (const view of dictValues(viewsByUid)) {
this.adjustLayoutToTrackSizes(view);
}
this.setState({
views: viewsByUid,
});
}
UNSAFE_componentWillUpdate() {
// let width = this.element.clientWidth;
// let height = this.element.clientHeight;
this.pixiRenderer.render(this.pixiRoot);
}
reload() {
for (const viewId of this.iterateOverViews()) {
const trackRenderer = this.getTrackRenderer(viewId);
if (!trackRenderer) continue;
const trackDefinitions = JSON.parse(trackRenderer.prevTrackDefinitions);
// this will remove all the tracks and then recreate them
// re-requesting all tiles with the new auth key
trackRenderer.syncTrackObjects([]);
trackRenderer.syncTrackObjects(trackDefinitions);
}
}
componentDidUpdate() {
this.setTheme(this.props.options.theme, this.props.options.isDarkTheme);
this.animate();
this.triggerViewChangeDb();
}
componentWillUnmount() {
// Destroy PIXI renderer, stages, and assets
this.mounted = false;
this.pixiStage.destroy(false);
this.pixiStage = null;
this.pixiRenderer.destroy(true);
this.pixiRenderer = null;
window.removeEventListener('focus', this.boundRefreshView);
// if this element was never attached to the DOM
// then the resize sensor will never have been initiated
if (this.resizeSensor) this.resizeSensor.detach();
this.domEvent.unregister('keydown', document);
this.domEvent.unregister('keyup', document);
this.domEvent.unregister('scroll', document);
this.domEvent.unregister('wheel', window);
this.domEvent.unregister('mousedown', window);
this.domEvent.unregister('mouseup', window);
this.domEvent.unregister('click', window);
this.domEvent.unregister('mousemove', window);
this.domEvent.unregister('touchmove', window);
this.domEvent.unregister('touchstart', window);
this.domEvent.unregister('touchend', window);
this.domEvent.unregister('touchcancel', window);
this.pubSubs.forEach((subscription) =>
this.pubSub.unsubscribe(subscription),
);
this.pubSubs = [];
this.apiDestroy();
}
/* ---------------------------- Custom Methods ---------------------------- */
setTheme(
newTheme = this.props.options.theme,
isDarkTheme = this.props.options.isDarkTheme,
) {
if (typeof isDarkTheme !== 'undefined') {
console.warn(
'The option `isDarkTheme` is deprecated. Please use `theme` instead.',
);
this.theme = isDarkTheme ? 'dark' : 'light';
} else {
switch (newTheme) {
case 'dark':
this.theme = THEME_DARK;
break;
case 'light':
case undefined:
this.theme = THEME_LIGHT;
break;
default:
console.warn(`Unknown theme "${newTheme}". Using light theme.`);
this.theme = THEME_LIGHT;
break;
}
}
}
dispatchEvent(e) {
if (!this.canvasElement) return;
forwardEvent(e, this.canvasElement);
}
trackDroppedHandler() {
this.setState({
draggingHappening: null,
});
}
requestReceivedHandler() {
if (!this.viewconfLoaded && requestsInFlight === 0) {
this.viewconfLoaded = true;
if (this.props.options.onViewConfLoaded) {
this.props.options.onViewConfLoaded();
}
}
}
animateOnMouseMoveHandler(active) {
if (active && !this.animateOnMouseMove) {
this.pubSubs.push(
this.pubSub.subscribe('app.mouseMove', this.animateBound),
);
}
this.animateOnMouseMove = active;
}
fitPixiToParentContainer() {
const element = this.topDivRef.current;
if (!element || !element.parentNode) {
// console.warn('No parentNode:', element);
return;
}
const width = element.parentNode.clientWidth;
const height = element.parentNode.clientHeight;
this.pixiMask.beginFill(0xffffff).drawRect(0, 0, width, height).endFill();
this.pixiRenderer.resize(width, height);
this.pixiRenderer.view.style.width = `${width}px`;
this.pixiRenderer.view.style.height = `${height}px`;
this.pixiRenderer.render(this.pixiRoot);
}
/**
* Add default track options. These can come from two places:
*
* 1. The track definitions (configs/tracks-info.js)
* 2. The default options passed into the component
*
* Of these, #2 takes precendence over #1.
*
* @param {array} track The track to add default options to
*/
addDefaultTrackOptions(track) {
const trackInfo = this.getTrackInfo(track.type);
if (!trackInfo) return;
if (typeof track.options === 'undefined') {
track.options = {};
}
const trackOptions = track.options ? track.options : {};
if (this.props.options.defaultTrackOptions) {
if (this.props.options.defaultTrackOptions.trackSpecific?.[track.type]) {
// track specific options take precedence over all options
const options =
this.props.options.defaultTrackOptions.trackSpecific[track.type];
for (const optionName in options) {
track.options[optionName] =
typeof track.options[optionName] !== 'undefined'
? track.options[optionName]
: JSON.parse(JSON.stringify(options[optionName]));
}
}
if (this.props.options.defaultTrackOptions.all) {
const options = this.props.options.defaultTrackOptions.all;
for (const optionName in options) {
track.options[optionName] =
typeof track.options[optionName] !== 'undefined'
? track.options[optionName]
: JSON.parse(JSON.stringify(options[optionName]));
}
}
}
if (trackInfo.defaultOptions) {
const defaultThemeOptions = trackInfo.defaultOptionsByTheme?.[this.theme]
? trackInfo.defaultOptionsByTheme[this.theme]
: {};
const defaultOptions = {
...trackInfo.defaultOptions,
...defaultThemeOptions,
};
if (!track.options) {
track.options = JSON.parse(JSON.stringify(defaultOptions));
} else {
for (const optionName in defaultOptions) {
track.options[optionName] =
typeof track.options[optionName] !== 'undefined'
? track.options[optionName]
: JSON.parse(JSON.stringify(defaultOptions[optionName]));
}
}
} else {
track.options = trackOptions;
}
}
toggleTypedEditable() {
this.setState({
typedEditable: !this.isEditable(),
});
}
/** Handle typed commands (e.g. e-d-i-t) */
typedTextHandler(event) {
if (!this.props.options.cheatCodesEnabled) {
return;
}
this.typedText = this.typedText.concat(event.key);
if (this.typedText.endsWith('hgedit')) {
this.toggleTypedEditable();
this.typedText = '';
}
// 1.5 seconds to type the next letter
const TYPED_TEXT_TIMEOUT = 750;
if (this.typedTextTimeout) {
clearTimeout(this.typedTextTimeout);
}
// set a timeout for new typed text
this.typedTextTimeout = setTimeout(() => {
this.typedText = '';
}, TYPED_TEXT_TIMEOUT);
}
keyDownHandler(event) {
// handle typed commands (e.g. e-d-i-t)
this.typedTextHandler(event);
if (this.props.options.rangeSelectionOnAlt && event.key === 'Alt') {
this.setState({
mouseTool: MOUSE_TOOL_SELECT,
});
}
}
keyUpHandler(event) {
if (this.props.options.rangeSelectionOnAlt && event.key === 'Alt') {
this.setState({
mouseTool: MOUSE_TOOL_MOVE,
});
}
}
openModal(modal) {
this.setState({
// The following is only needed for testing purposes
modal: React.cloneElement(modal, {
ref: (c) => {
this.modalRef = c;
},
}),
});
}
closeModal() {
this.modalRef = null;
this.setState({ modal: null });
}
handleEditViewConfig() {
const { viewConfig: viewConfigTmp } = this.state;
this.setState({ viewConfigTmp });
this.openModal(
<ViewConfigEditor
onCancel={() => {
const { viewConfigTmp: viewConfig } = this.state;
const views = this.processViewConfig(viewConfig);
for (const view of dictValues(views)) {
this.adjustLayoutToTrackSizes(view);
}
this.setState({
views,
viewConfig,
viewConfigTmp: null,
});
}}
onChange={(viewConfigJson) => {
const viewConfig = JSON.parse(viewConfigJson);
const views = this.processViewConfig(viewConfig);
for (const view of dictValues(views)) {
this.adjustLayoutToTrackSizes(view);
}
this.setState({ views, viewConfig });
}}
onSave={(viewConfigJson) => {
const viewConfig = JSON.parse(viewConfigJson);
const views = this.processViewConfig(viewConfig);
for (const view of dictValues(views)) {
this.adjustLayoutToTrackSizes(view);
}
this.setState({
views,
viewConfig,
viewConfigTmp: null,
});
}}
viewConfig={this.getViewsAsString()}
/>,
);
}
animate() {
if (this.isRequestingAnimationFrame) return;
this.isRequestingAnimationFrame = true;
requestAnimationFrame(() => {
// component was probably unmounted
if (!this.pixiRenderer) return;
this.pixiRenderer.render(this.pixiRoot);
this.isRequestingAnimationFrame = false;
});
}
animateOnGlobalEvent({ sourceUid } = {}) {
if (sourceUid !== this.uid && this.animateOnMouseMove) this.animate();
}
measureSize() {
const [width, height] = getElementDim(this.element);
if (width > 0 && height > 0) {
this.setState({
sizeMeasured: true,
width,
height,
});
}
}
updateAfterResize() {
this.measureSize();
this.updateRowHeight();
this.fitPixiToParentContainer();
this.refreshView(LONG_DRAG_TIMEOUT);
this.resizeHandler();
}
onBreakpointChange(breakpoint) {
this.setState({
currentBreakpoint: breakpoint,
});
}
handleOverlayMouseEnter(uid) {
this.setState({
mouseOverOverlayUid: uid,
});
}
handleOverlayMouseLeave() {
this.setState({
mouseOverOverlayUid: null,
});
}
/**
* We want to lock the zoom of this view to the zoom of another view.
*
* First we pick which other view we want to lock to.
*
* The we calculate the current zoom offset and center offset. The differences
* between the center of the two views will always remain the same, as will the
* different between the zoom levels.
*/
handleLockLocation(uid) {
// create a view chooser and remove the config view menu
this.setState({
chooseViewHandler: (uid2) => this.handleLocationLockChosen(uid, uid2),
mouseOverOverlayUid: uid,
});
}
/**
* Can views be added, removed or rearranged and are the view headers
* visible?
*/
isEditable() {
if (this.state.typedEditable !== undefined) {
// somebody typed "edit" so we need to follow the directive of
// this cheat code over all other preferences
return this.state.typedEditable;
}
if (!this.props.options || !('editable' in this.props.options)) {
return this.state.viewConfig.editable;
}
return this.props.options.editable && this.state.viewConfig.editable;
}
/**
* Can views be added, removed or rearranged and are the view headers
* visible?
*/
isTrackMenuDisabled() {
if (
this.props.options &&
(this.props.options.editable === false ||
this.props.options.tracksEditable === false)
) {
return true;
}
return (
this.state.viewConfig &&
(this.state.viewConfig.tracksEditable === false ||
this.state.viewConfig.editable === false)
);
}
/**
* Can views be added, removed or rearranged and are the view headers
* visible?
*/
isViewHeaderDisabled() {
if (
this.props.options &&
(this.props.options.editable === false ||
this.props.options.viewEditable === false)
) {
return true;
}
return (
this.state.viewConfig &&
(this.state.viewConfig.viewEditable === false ||
this.state.viewConfig.editable === false)
);
}
/**
* Iteratate over all of the views in this component
*/
iterateOverViews() {
const viewIds = [];
for (const viewId of Object.keys(this.state.views)) {
viewIds.push(viewId);
}
return viewIds;
}
iterateOverTracksInView(viewId) {
const allTracks = [];
const { tracks } = this.state.views[viewId];
for (const trackType in tracks) {
for (const track of tracks[trackType]) {
if (track.type === 'combined' && track.contents) {
for (const subTrack of track.contents) {
allTracks.push({ viewId, trackId: subTrack.uid, track: subTrack });
}
} else {
allTracks.push({ viewId, trackId: track.uid, track });
}
}
}
return allTracks;
}
/**
* Iterate over all the tracks in this component.
*/
iterateOverTracks() {
/** @type {Array<{ viewId: string, trackId: string, track: import('./types').UnknownTrackConfig }>}*/
const allTracks = [];
for (const viewId in this.state.views) {
const { tracks } = this.state.views[viewId];
for (const trackType in tracks) {
for (const track of tracks[trackType]) {
if (track.type === 'combined' && track.contents) {
for (const subTrack of track.contents) {
allTracks.push({
viewId,
trackId: subTrack.uid,
track: subTrack,
});
}
} else {
allTracks.push({ viewId, trackId: track.uid, track });
}
}
}
}
return allTracks;
}
setMouseTool(mouseTool) {
this.setState({ mouseTool });
}
setSizeMode(sizeMode) {
this.setState({ sizeMode });
}
/**
* Checks if a track's value scale is locked with another track
*/
isValueScaleLocked(viewUid, trackUid) {
const uid = this.combineViewAndTrackUid(viewUid, trackUid);
// the view must have been deleted
if (!this.state.views[viewUid]) {
return false;
}
if (this.valueScaleLocks[uid]) {
return true;
}
return false;
}
/**
* Computed the minimal and maximal values of all tracks that are in the same
* lockGroup as a given track
* @param {string} viewUid The id of the view containing the track
* @param {string} trackUid The id of the track
* @return {array} Tuple [min,max] containing the overall extrema - or null.
*/
getLockGroupExtrema(viewUid, trackUid) {
const uid = this.combineViewAndTrackUid(viewUid, trackUid);
// the view must have been deleted
if (!this.state.views[viewUid]) {
return null;
}
if (!this.valueScaleLocks[uid]) {
return null;
}
const lockGroup = this.valueScaleLocks[uid];
const lockedTracks = Object.values(lockGroup)
.filter((track) => this.tiledPlots[track.view])
.map((track) =>
this.tiledPlots[track.view].trackRenderer.getTrackObject(track.track),
)
// filter out stale locks with non-existant tracks
.filter((track) => track)
// Filter out tracks that don't have values scales (e.g. chromosome tracks).
// The .originalTrack check covers LeftTrackModifier style tracks.
.filter((track) => track.valueScale || track.originalTrack?.valueScale)
// if the track is a LeftTrackModifier we want the originalTrack
.map((track) =>
track.originalTrack === undefined ? track : track.originalTrack,
);
const minValues = lockedTracks
// exclude tracks that don't set min and max values
.filter((track) => track.minRawValue && track.maxRawValue)
.map((track) =>
lockGroup.ignoreOffScreenValues
? track.minVisibleValue(true)
: track.minVisibleValueInTiles(true),
);
const maxValues = lockedTracks
// exclude tracks that don't set min and max values
.filter((track) => track.minRawValue && track.maxRawValue)
.map((track) =>
lockGroup.ignoreOffScreenValues
? track.maxVisibleValue(true)
: track.maxVisibleValueInTiles(true),
);
if (
minValues.length === 0 ||
minValues.filter((x) => x === null || x === Number.POSITIVE_INFINITY)
.length > 0
) {
return null; // Data hasn't loaded completely
}
if (
maxValues.length === 0 ||
maxValues.filter((x) => x === null || x === Number.NEGATIVE_INFINITY)
.length > 0
) {
return null; // Data hasn't loaded completely
}
const allMin = Math.min(...minValues);
const allMax = Math.max(...maxValues);
return [allMin, allMax];
}
/**
* Syncing the values of locked scales
*
* Arguments
* ---------
* viewUid: string
* The id of the view containing the track whose value scale initially changed
* trackUid: string
* The id of the track that whose value scale changed
*
* Returns
* -------
* Nothing
*/
syncValueScales(viewUid, trackUid) {
const uid = this.combineViewAndTrackUid(viewUid, trackUid);
if (!this.state.views[viewUid]) return;
// the view must have been deleted
const sourceTrack = getTrackByUid(
this.state.views[viewUid].tracks,
trackUid,
);
if (this.valueScaleLocks[uid]) {
const lockGroup = this.valueScaleLocks[uid];
const lockedTracks = Object.values(lockGroup)
.filter((track) => this.tiledPlots[track.view])
.map((track) =>
this.tiledPlots[track.view].trackRenderer.getTrackObject(track.track),
)
// filter out locks with non-existant tracks
.filter((track) => track)
// if the track is a LeftTrackModifier we want the originalTrack
.map((track) =>
track.originalTrack === undefined ? track : track.originalTrack,
);
const lockGroupExtrema = this.getLockGroupExtrema(viewUid, trackUid);
if (lockGroupExtrema === null) {
return; // Data hasn't loaded completely
}
const allMin = lockGroupExtrema[0];
const allMax = lockGroupExtrema[1];
const epsilon = 1e-6;
for (const lockedTrack of lockedTracks) {
// set the newly calculated minimum and maximum values
// using d3 style setters
if (lockedTrack.minValue) {
lockedTrack.minValue(allMin);
}
if (lockedTrack.maxValue) {
lockedTrack.maxValue(allMax);
}
if (!lockedTrack.valueScale) {
// this track probably hasn't loaded the tiles to
// create a valueScale
continue;
}
const hasScaleChanged =
Math.abs(
lockedTrack.minValue() - lockedTrack.valueScale.domain()[0],
) > epsilon ||
Math.abs(
lockedTrack.maxValue() - lockedTrack.valueScale.domain()[1],
) > epsilon;
const hasBrushMoved =
sourceTrack.options &&
lockedTrack.options &&
typeof sourceTrack.options.scaleStartPercent !== 'undefined' &&
typeof sourceTrack.options.scaleEndPercent !== 'undefined' &&
(Math.abs(
lockedTrack.options.scaleStartPercent -
sourceTrack.options.scaleStartPercent,
) > epsilon ||
Math.abs(
lockedTrack.options.scaleEndPercent -
sourceTrack.options.scaleEndPercent,
) > epsilon);
// If we do view based scaling we want to minimize the number of rerenders
// Check if it is necessary to rerender
if (
lockedTrack.continuousScaling &&
!hasScaleChanged &&
!hasBrushMoved
) {
continue;
}
lockedTrack.valueScale.domain([allMin, allMax]);
// In TiledPixiTrack, we check if valueScale has changed before
// calling onValueScaleChanged. If we don't update prevValueScale
// here, that function won't get called and the value scales won't
// stay synced
lockedTrack.prevValueScale = lockedTrack.valueScale.copy();
if (hasBrushMoved) {
lockedTrack.options.scaleStartPercent =
sourceTrack.options.scaleStartPercent;
lockedTrack.options.scaleEndPercent =
sourceTrack.options.scaleEndPercent;
}
// the second parameter forces a rerender even though
// the options haven't changed
lockedTrack.rerender(lockedTrack.options, true);
}
}
}
handleNewTilesLoaded(viewUid, trackUid) {
// this.syncValueScales(viewUid, trackUid);
this.animate();
}
notifyDragChangedListeners(dragging) {
// iterate over viewId
dictValues(this.draggingChangedListeners).forEach((l) => {
// iterate over listenerId
dictValues(l).forEach((listener) => listener(dragging));
});
}
/**
* Add a listener that will be called every time the view is updated.
*
* @param viewUid: The uid of the view being observed
* @param listenerUid: The uid of the listener
* @param eventHandler: The handler to be called when the scales change
* Event handler is called with parameters (xScale, yScale)
*/
addDraggingChangedListener(viewUid, listenerUid, eventHandler) {
if (!(viewUid in this.draggingChangedListeners)) {
this.draggingChangedListeners[viewUid] = {};
}
this.draggingChangedListeners[viewUid][listenerUid] = eventHandler;
eventHandler(true);
eventHandler(false);
}
/**
* Remove a scale change event listener
*
* @param viewUid: The view that it's listening on.
* @param listenerUid: The uid of the listener itself.
*/
removeDraggingChangedListener(viewUid, listenerUid) {
if (viewUid in this.draggingChangedListeners) {
const listeners = this.draggingChangedListeners[viewUid];
if (listenerUid in listeners) {
// make sure the listener doesn't think we're still
// dragging
listeners[listenerUid](false);
delete listeners[listenerUid];
}
}
}
/**
* Add an event listener that will be called every time the scale
* of the view with uid viewUid is changed.
*
* @param viewUid: The uid of the view being observed
* @param listenerUid: The uid of the listener
* @param eventHandler: The handler to be called when the scales change
* Event handler is called with parameters (xScale, yScale)
*/
addScalesChangedListener(viewUid, listenerUid, eventHandler) {
if (!this.scalesChangedListeners[viewUid]) {
this.scalesChangedListeners[viewUid] = {};
}
this.scalesChangedListeners[viewUid][listenerUid] = eventHandler;
if (!this.xScales[viewUid] || !this.yScales[viewUid]) {
return;
}
// call the handler for the first time
eventHandler(this.xScales[viewUid], this.yScales[viewUid]);
}
/**
* Remove a scale change event listener
*
* @param viewUid: The view that it's listening on.
* @param listenerUid: The uid of the listener itself.
*/
removeScalesChangedListener(viewUid, listenerUid) {
if (this.scalesChangedListeners[viewUid]) {
const listeners = this.scalesChangedListeners[viewUid];
if (listeners[listenerUid]) {
delete listeners[listenerUid];
}
}
}
createSVG() {
const svg = document.createElement('svg');
svg.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
svg.setAttribute('version', '1.1');
for (const tiledPlot of dictValues(this.tiledPlots)) {
if (!tiledPlot) continue; // probably opened and closed
for (const trackDefObject of dictValues(
tiledPlot.trackRenderer.trackDefObjects,
)) {
if (trackDefObject.trackObject.exportSVG) {
const trackSVG = trackDefObject.trackObject.exportSVG();
if (trackSVG) svg.appendChild(trackSVG[0]);
}
}
}
// FF is fussier than Chrome, and requires dimensions on the SVG,
// if it is to be used as an image src.
svg.setAttribute('width', this.canvasElement.style.width);
svg.setAttribute('height', this.canvasElement.style.height);
if (this.postCreateSVGCallback) {
// Allow the callback function to modify the exported SVG string
// before it is finalized and returned.
const modifiedSvg = this.postCreateSVGCallback(svg);
return modifiedSvg;
}
return svg;
}
createSVGString() {
const svg = this.createSVG();
let svgString = vkbeautify.xml(
new window.XMLSerializer().serializeToString(svg),
);
svgString = svgString.replace(/<a0:/g, '<');
svgString = svgString.replace(/<\/a0:/g, '</');
// Remove duplicated xhtml namespace property
svgString = svgString.replace(
/(<svg[\n\r])(\s+xmlns="http:\/\/www\.w3\.org\/1999\/xhtml"[\n\r])/gm,
'$1',
);
// Remove duplicated svg namespace
svgString = svgString.replace(
/(\s+<clipPath[\n\r]\s+)(xmlns="http:\/\/www\.w3\.org\/2000\/svg")/gm,
'$1',
);
const xmlDeclaration =
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>';
const doctype =
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">';
return `${xmlDeclaration}\n${doctype}\n${svgString}`;
}
handleExportSVG() {
download(
'export.svg',
new Blob([this.createSVGString()], { type: 'image/svg+xml' }),
);
}
offPostCreateSVG() {
this.postCreateSVGCallback = null;
}
onPostCreateSVG(callback) {
this.postCreateSVGCallback = callback;
}
createPNGBlobPromise() {
return new Promise((resolve) => {
// It would seem easier to call canvas.toDataURL()...
// Except that with webgl context, it swaps buffers after drawing
// and you don't have direct access to what is on-screen.
// (You end up getting a PNG of the desired dimensions, but it is empty.)
//
// We'd either need to
// - Turn on preserveDrawingBuffer and rerender, and add a callback
// - Or leave it off, and somehow synchronously export before the swap
// - Or look into low-level stuff like copyBufferSubData.
//
// Basing it on the SVG also guarantees us that the two exports are the same.
const svgString = this.createSVGString();
const img = new Image(
this.canvasElement.width,
this.canvasElement.height,
);
img.src = `data:image/svg+xml;base64,${btoa(
unescape(encodeURIComponent(svgString)),
)}`;
img.onload = () => {
const targetCanvas = document.createElement('canvas');
// TODO: I have no idea why dimensions are doubled!
targetCanvas.width = this.canvasElement.width / 2;
targetCanvas.height = this.canvasElement.height / 2;
targetCanvas.getContext('2d').drawImage(img, 0, 0);
targetCanvas.toBlob((blob) => {
resolve(blob);
});
};
});
}
handleExportPNG() {
this.createPNGBlobPromise().then((blob) => {
download('export.png', blob);
});
}
/*
* The scales of some view have changed (presumably in response to zooming).
*
* Mark the new scales and update any locked views.
*
* @param uid: The view of whom the scales have changed.
*/
handleScalesChanged(uid, xScale, yScale, notify = true) {
this.xScales[uid] = xScale;
this.yScales[uid] = yScale;
if (notify) {
if (uid in this.scalesChangedListeners) {
dictValues(this.scalesChangedListeners[uid]).forEach((x) => {
x(xScale, yScale);
});
}
}
if (this.zoomLocks[uid]) {
// this view is locked to another
const lockGroup = this.zoomLocks[uid];
const lockGroupItems = dictItems(lockGroup);
const [centerX, centerY, k] = scalesCenterAndK(
this.xScales[uid],
this.yScales[uid],
);
for (let i = 0; i < lockGroupItems.length; i++) {
const key = lockGroupItems[i][0];
const value = lockGroupItems[i][1];
if (!this.xScales[key] || !this.yScales[key]) {
continue;
}
if (key === uid) {
// no need to notify oneself that the scales have changed
continue;
}
const [keyCenterX, keyCenterY, keyK] = scalesCenterAndK(
this.xScales[key],
this.yScales[key],
);
const rk = value[2] / lockGroup[uid][2];
// let newCenterX = centerX + dx;
// let newCenterY = centerY + dy;
const newK = k * rk;
if (!this.setCenters[key]) {
continue;
}
// the key here is the target of zoom lock, so we want to keep its
// x center and y center unchanged
const [newXScale, newYScale] = this.setCenters[key](
keyCenterX,
keyCenterY,
newK,
false,
);
// because the setCenters call above has a 'false' notify, the new scales won't
// be propagated from there, so we have to store them here
this.xScales[key] = newXScale;
this.yScales[key] = newYScale;
// notify the listeners of all locked views that the scales of
// this view have changed
if (key in this.scalesChangedListeners) {
dictValues(this.scalesChangedListeners[key]).forEach((x) => {
x(newXScale, newYScale);
});
}
}
}
if (this.locationLocks[uid]) {
// this view is locked to another
const lockGroup = this.locationLocks[uid];
const lockGroupItems = dictItems(lockGroup);
const [centerX, centerY, k] = scalesCenterAndK(
this.xScales[uid],
this.yScales[uid],
);
for (let i = 0; i < lockGroupItems.length; i++) {
const key = lockGroupItems[i][0];
const value = lockGroupItems[i][1];
if (!this.xScales[key] || !this.yScales[key]) {
continue;
}
const [keyCenterX, keyCenterY, keyK] = scalesCenterAndK(
this.xScales[key],
this.yScales[key],
);
if (key === uid) {
// no need to notify oneself that the scales have changed
continue;
}
const dx = value[0] - lockGroup[uid][0];
const dy = value[1] - lockGroup[uid][1];
const newCenterX = centerX + dx;
const newCenterY = centerY + dy;
if (!this.setCenters[key]) {
continue;
}
const [newXScale, newYScale] = this.setCenters[key](
newCenterX,
newCenterY,
keyK,
false,
);
// because the setCenters call above has a 'false' notify, the new scal