UNPKG

higlass

Version:

HiGlass Hi-C / genomic / large data viewer

1,521 lines (1,294 loc) 79.2 kB
import PropTypes from 'prop-types'; import React from 'react'; import clsx from 'clsx'; import { scaleLinear } from 'd3-scale'; import { pointer, select } from 'd3-selection'; import { zoom, zoomIdentity } from 'd3-zoom'; import slugid from 'slugid'; import BedLikeTrack from './BedLikeTrack'; import CombinedTrack from './CombinedTrack'; import HeatmapTiledPixiTrack from './HeatmapTiledPixiTrack'; import Id2DTiledPixiTrack from './Id2DTiledPixiTrack'; import IdHorizontal1DTiledPixiTrack from './IdHorizontal1DTiledPixiTrack'; import IdVertical1DTiledPixiTrack from './IdVertical1DTiledPixiTrack'; import LeftAxisTrack from './LeftAxisTrack'; import OverlayTrack from './OverlayTrack'; import PixiTrack from './PixiTrack'; import TopAxisTrack from './TopAxisTrack'; import BarTrack from './BarTrack'; import DivergentBarTrack from './DivergentBarTrack'; import Horizontal1dHeatmapTrack from './Horizontal1dHeatmapTrack'; import HorizontalLine1DPixiTrack from './HorizontalLine1DPixiTrack'; import HorizontalMultivecTrack from './HorizontalMultivecTrack'; import HorizontalPoint1DPixiTrack from './HorizontalPoint1DPixiTrack'; import Annotations1dTrack from './Annotations1dTrack'; import Annotations2dTrack from './Annotations2dTrack'; import ArrowheadDomainsTrack from './ArrowheadDomainsTrack'; import CNVIntervalTrack from './CNVIntervalTrack'; import HorizontalGeneAnnotationsTrack from './HorizontalGeneAnnotationsTrack'; import LeftTrackModifier from './LeftTrackModifier'; import Track from './Track'; import Horizontal2DDomainsTrack from './Horizontal2DDomainsTrack'; import Chromosome2DAnnotations from './Chromosome2DAnnotations'; import Chromosome2DLabels from './Chromosome2DLabels'; import ChromosomeGrid from './ChromosomeGrid'; import HorizontalChromosomeLabels from './HorizontalChromosomeLabels'; import SquareMarkersTrack from './SquareMarkersTrack'; import HorizontalHeatmapTrack from './HorizontalHeatmapTrack'; import UnknownPixiTrack from './UnknownPixiTrack'; import ValueIntervalTrack from './ValueIntervalTrack'; import ViewportTracker2D from './ViewportTracker2D'; import ViewportTrackerHorizontal from './ViewportTrackerHorizontal'; import ViewportTrackerVertical from './ViewportTrackerVertical'; import CrossRule from './CrossRule'; import HorizontalRule from './HorizontalRule'; import VerticalRule from './VerticalRule'; import MapboxTilesTrack from './MapboxTilesTrack'; import OSMTileIdsTrack from './OSMTileIdsTrack'; import OSMTilesTrack from './OSMTilesTrack'; import RasterTilesTrack from './RasterTilesTrack'; import SVGTrack from './SVGTrack'; // Higher-order components import withPubSub from './hocs/with-pub-sub'; import withTheme from './hocs/with-theme'; // Utils import { colorToHex, dictItems, forwardEvent, scalesCenterAndK, trimTrailingSlash, } from './utils'; import { isCombinedTrackConfig, isWheelEvent } from './utils/type-guards'; // Configs import { GLOBALS, THEME_DARK, TRACKS_INFO_BY_TYPE } from './configs'; // Plugins import { AVAILABLE_FOR_PLUGINS } from './plugins'; // Styles import classes from '../styles/TrackRenderer.module.scss'; const { getDataFetcher } = AVAILABLE_FOR_PLUGINS.dataFetchers; const SCROLL_TIMEOUT = 100; /** @typedef {import('./types').Scale} Scale */ /** @typedef {import('./types').TrackConfig} TrackConfig */ /** @typedef {import('./types').TrackObject} TrackObject */ /** @typedef {import('./types').TilesetInfo} TilesetInfo */ /** @typedef {TrackRenderer["setCenter"]} SetCentersFunction */ /** @typedef {(x: Scale, y: Scale) => [Scale, Scale]} ProjectorFunction */ /** * @typedef TrackDefinition * @property {TrackConfig} track * @property {number} width * @property {number} height * @property {number} top * @property {number} left */ /** * @typedef MetaPluginTrackContext * @property {(trackId: string) => TrackObject | undefined} getTrackObject * @property {() => void} onNewTilesLoaded * @property {TrackConfig} definition */ /** * @typedef {Object} PluginTrackContext * @property {string} id * @property {string} trackUid * @property {string} trackType * @property {string} viewUid * @property {import('pub-sub-es').PubSub} pubSub * @property {import("pixi.js").Graphics} scene * @property {Record<string, unknown>} dataConfig * @property {unknown} dataFetcher * @property {() => unknown} getLockGroupExtrema * @property {(tilesetInfo: TilesetInfo) => void} handleTilesetInfoReceived * @property {() => void} animate * @property {HTMLElement} svgElement * @property {() => boolean} isValueScaleLocked * @property {() => void} onValueScaleChanged * @property {(newOption: Record<string, unknown>) => void} onTrackOptionsChanged * @property {() => void} onMouseMoveZoom * @property {string=} chromInfoPath * @property {() => boolean} isShowGlobalMousePosition * @property {() => import('./types').Theme} getTheme * @property {unknown=} AVAILABLE_FOR_PLUGINS * @property {(HTMLDivElement | null)=} baseEl * @property {TrackConfig=} definition * @property {number=} x * @property {number=} y * @property {number=} xPosition * @property {number=} yPosition * @property {[number, number]=} projectionXDomain * @property {[number, number]=} projectionYDomain * @property {unknown=} registerViewportChanged * @property {unknown=} removeViewportChanged * @property {unknown=} setDomainsCallback * @property {TrackConfig[]=} tracks * @property {TrackRenderer["createTrackObject"]=} createTrackObject * @property {string=} orientation * @property {boolean=} isOverlay */ /** * @typedef PluginTrack * @property {{ new (availableForPlugins: unknown, context: PluginTrackContext, options: Record<string, unknown>): TrackObject }} track * @property {false=} isMetaTrack */ /** * @typedef MetaPluginTrack * @property {{ new (availableForPlugins: unknown, context: MetaPluginTrackContext, options: Record<string, unknown>): TrackObject }} track * @property {true} isMetaTrack */ /** * @template T * @typedef {T & { __zoom?: import('d3-zoom').ZoomTransform }} WithZoomTransform */ /** * @typedef TrackRendererProps * @property {HTMLElement} canvasElement * @property {number} centerHeight * @property {number} centerWidth * @property {Array<JSX.Element>} children * @property {number} galleryDim * @property {number} height * @property {[number, number]} initialXDomain * @property {[number, number]} initialYDomain * @property {boolean} isShowGlobalMousePosition * @property {boolean} isRangeSelection * @property {number} leftWidth * @property {number} leftWidthNoGallery * @property {number} paddingLeft * @property {number} paddingTop * @property {Array<TrackConfig>} metaTracks * @property {() => void} onMouseMoveZoom * @property {(trackId?: string) => void} onNewTilesLoaded * @property {(x: Scale, y: Scale) => void} onScalesChanged * @property {import("pixi.js").Renderer} pixiRenderer * @property {import("pixi.js").Container} pixiStage * @property {Record<string, unknown>} pluginDataFetchers * @property {Record<string, PluginTrack | MetaPluginTrack>} pluginTracks * @property {Array<TrackDefinition>} positionedTracks * @property {import('pub-sub-es').PubSub} pubSub * @property {(func: SetCentersFunction) => void} setCentersFunction * @property {HTMLElement} svgElement * @property {import('./types').Theme} theme * @property {number} topHeight * @property {number} topHeightNoGallery * @property {{ backgroundColor?: string }} viewOptions * @property {number} width * @property {[number, number]} xDomainLimits * @property {[number, number]} yDomainLimits * @property {boolean} valueScaleZoom * @property {boolean} zoomable * @property {[number, number]} zoomDomain * @property {[number, number]} zoomLimits * @property {string} uid * @property {boolean} dragging * @property {(func: (draggingStatus: boolean) => void) => void} registerDraggingChangedListener * @property {boolean} disableTrackMenu * @property {(listener: (draggingStatus: boolean) => void) => void} removeDraggingChangedListener * @property {(trackId: string, tilesetInfo: TilesetInfo) => void} onTilesetInfoReceived * @property {(trackId: string) => unknown} getLockGroupExtrema * @property {(trackId: string) => boolean} isValueScaleLocked * @property {(trackId: string) => void} onValueScaleChanged * @property {(trackId: string, newOption: Record<string, unknown>) => void} onTrackOptionsChanged */ /** * @extends {React.Component<TrackRendererProps>} */ export class TrackRenderer extends React.Component { /** * Maintain a list of tracks, and re-render them whenever either * their size changes or the zoom level changes * * Zooming changes the domain of the scales. * * Resizing changes the range. Both trigger a rerender. * * @param {TrackRendererProps} props */ constructor(props) { super(props); /** @type {boolean} */ this.dragging = false; // is this element being dragged? /** @type {WithZoomTransform<HTMLElement> | null} */ this.element = null; /** @type {HTMLElement | null} */ this.eventTracker = null; /** @type {HTMLElement | null} */ this.eventTrackerOld = null; /** @type {boolean} */ this.closing = false; /** @type {number} */ this.yPositionOffset = 0; /** @type {number} */ this.xPositionOffset = 0; /** @type {number} */ this.scrollTop = 0; /** @type {ReturnType<typeof setTimeout> | null} */ this.scrollTimeout = null; /** @type {number} */ this.activeTransitions = 0; /** @type {import('d3-zoom').ZoomTransform} */ this.zoomTransform = zoomIdentity; /** @type {() => void} */ this.windowScrolledBound = this.windowScrolled.bind(this); /** @type {(event?: import('d3-zoom').D3ZoomEvent<HTMLElement, unknown>) => void} */ this.zoomStartedBound = this.zoomStarted.bind(this); /** @type {(event: import('d3-zoom').D3ZoomEvent<HTMLElement, unknown> & { shiftKey?: boolean }) => void} */ this.zoomedBound = this.zoomed.bind(this); /** @type {() => void} */ this.zoomEndedBound = this.zoomEnded.bind(this); /** @type {string} */ this.uid = slugid.nice(); /** @type {string} */ this.viewUid = this.props.uid; /** @type {unknown} */ this.availableForPlugins = { ...AVAILABLE_FOR_PLUGINS, services: { ...AVAILABLE_FOR_PLUGINS.services, pubSub: this.props.pubSub, pixiRenderer: this.props.pixiRenderer, }, }; /** @type {boolean} */ this.mounted = false; // create a zoom behavior that we'll just use to transform selections // without having it fire an "onZoom" event /** @type {import("d3-zoom").ZoomBehavior<HTMLElement, unknown>} */ this.emptyZoomBehavior = zoom(); // a lot of the updates in TrackRenderer happen in response to // componentWillReceiveProps so we need to perform them with the // newest set of props. When cWRP is called, this.props still contains // the old props, so we need to store them in a new variable /** @type {TrackRendererProps} */ this.currentProps = props; /** @type {string} */ this.prevPropsStr = ''; // catch any zooming behavior within all of the tracks in this plot // this.zoomTransform = zoomIdentity(); /** @type {import("d3-zoom").ZoomBehavior<HTMLElement, unknown>} */ this.zoomBehavior = /** @type {import("d3-zoom").ZoomBehavior<HTMLElement, any>} */ (zoom()) .filter((event) => { if (event.target.classList.contains('no-zoom')) { return false; } if (event.target.classList.contains('react-resizable-handle')) { return false; } return true; }) .on('start', this.zoomStartedBound) .on('zoom', this.zoomedBound) .on('end', this.zoomEndedBound); /** @type {import('d3-zoom').ZoomTransform} */ this.zoomTransform = zoomIdentity; /** @type {import('d3-zoom').ZoomTransform} */ this.prevZoomTransform = zoomIdentity; /** @type {[number, number]} */ this.initialXDomain = [0, 1]; /** @type {[number, number]} */ this.initialYDomain = [0, 1]; /** @type {[number, number]} */ this.xDomainLimits = [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; /** @type {[number, number]} */ this.yDomainLimits = [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]; /** @type {[number, number]} */ this.zoomLimits = [0, Number.MAX_SAFE_INTEGER]; /** @type {number} */ this.prevCenterX = this.currentProps.paddingLeft + this.currentProps.leftWidth + this.currentProps.centerWidth / 2; /** @type {number} */ this.prevCenterY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2; // The offset of the center from the original. Used to keep the scales centered on resize events /** @type {number} */ this.cumCenterXOffset = 0; /** @type {number} */ this.cumCenterYOffset = 0; this.setUpInitialScales( this.currentProps.initialXDomain, this.currentProps.initialYDomain, this.currentProps.xDomainLimits, this.currentProps.yDomainLimits, this.currentProps.zoomLimits, ); this.setUpScales(); // maintain a list of trackDefObjects which correspond to the input // tracks // Each object will contain a trackDef // {'top': 100, 'left': 50,... 'track': {'source': 'http:...', 'type': 'heatmap'}} // And a trackObject which will be responsible for rendering it /** @type {Record<string, { trackObject: TrackObject, trackDef: TrackDefinition }>} */ this.trackDefObjects = {}; /** @type {Record<string, { trackObject: TrackObject | UnknownPixiTrack, trackDef: TrackConfig }>} */ this.metaTracks = {}; /** @type {Array<import("pub-sub-es").Subscription>} */ this.pubSubs = []; // if there's plugin tracks, they'll define new track // types and we'll want to use their information when // we look up the orientation of a track if (window.higlassTracksByType) { // Extend `TRACKS_INFO_BY_TYPE` with the configs of plugin tracks. for (const pluginTrackType in window.higlassTracksByType) { TRACKS_INFO_BY_TYPE[pluginTrackType] = window.higlassTracksByType[pluginTrackType].config; } } /** @type {<T extends Event>(event: T & { sourceUid?: string, forwarded?: boolean }) => void} */ this.boundForwardEvent = this.forwardEvent.bind(this); /** @type {() => void} */ this.boundScrollEvent = this.scrollEvent.bind(this); /** @type {(event: { altKey: boolean, preventDefault(): void }) => void} */ this.boundForwardContextMenu = this.forwardContextMenu.bind(this); /** @type {(event: Event & { sourceUid: string, type: string }) => void} */ this.dispatchEventBound = this.dispatchEvent.bind(this); /** @type {(opts: { pos: [number, number, number, number], animateTime: number, isMercator: boolean }) => void} */ this.zoomToDataPosHandlerBound = this.zoomToDataPosHandler.bind(this); /** @type {(scrollTop: number) => void} */ this.onScrollHandlerBound = this.onScrollHandler.bind(this); /** @type {{ height: number, width: number, left: number, top: number }} */ this.elementPos = { height: 0, width: 0, left: 0, top: 0 }; /** @type {import('d3-selection').Selection<WithZoomTransform<HTMLElement>, unknown, null, unknown> | null} */ this.elementSelection = null; } get xScale() { if (!this._xScale) { throw new Error('xScale is not defined'); } return this._xScale; } get yScale() { if (!this._yScale) { throw new Error('yScale is not defined'); } return this._yScale; } UNSAFE_componentWillMount() { this.pubSubs = []; this.pubSubs.push( this.props.pubSub.subscribe('scroll', this.windowScrolledBound), ); this.pubSubs.push( this.props.pubSub.subscribe('app.event', this.dispatchEventBound), ); this.pubSubs.push( this.props.pubSub.subscribe( 'zoomToDataPos', this.zoomToDataPosHandlerBound, ), ); this.pubSubs.push( this.props.pubSub.subscribe('app.scroll', this.onScrollHandlerBound), ); } componentDidMount() { if (!this.element) { throw new Error('Component did not mount, this.element is not defined.'); } this.elementPos = this.element.getBoundingClientRect(); this.elementSelection = select(this.element); /** @type {import('pixi.js').Graphics} */ this.pStage = new GLOBALS.PIXI.Graphics(); /** @type {import('pixi.js').Graphics} */ this.pMask = new GLOBALS.PIXI.Graphics(); /** @type {import('pixi.js').Graphics} */ this.pOutline = new GLOBALS.PIXI.Graphics(); /** @type {import('pixi.js').Graphics} */ this.pBackground = new GLOBALS.PIXI.Graphics(); this.pStage.addChild(this.pMask); this.pStage.addChild(this.pOutline); this.currentProps.pixiStage.addChild(this.pStage); this.pStage.mask = this.pMask; if (!this.props.isRangeSelection) this.addZoom(); // need to be mounted to make sure that all the renderers are // created before starting to draw tracks if (!this.currentProps.svgElement || !this.currentProps.canvasElement) { return; } this.svgElement = this.currentProps.svgElement; this.syncTrackObjects(this.currentProps.positionedTracks); this.syncMetaTracks(this.currentProps.metaTracks); this.currentProps.setCentersFunction(this.setCenter.bind(this)); this.currentProps.registerDraggingChangedListener( this.draggingChanged.bind(this), ); this.draggingChanged(true); this.addEventTracker(); // Init zoom and scale extent /** @type {[[number, number], [number, number]]} */ const transExt = [ [this.xScale(this.xDomainLimits[0]), this.yScale(this.yDomainLimits[0])], [this.xScale(this.xDomainLimits[1]), this.yScale(this.yDomainLimits[1])], ]; const svgBBox = this.svgElement.getBoundingClientRect(); /** @type {[[number, number], [number, number]]} */ const ext = [ [Math.max(transExt[0][0], 0), Math.max(transExt[0][1], 0)], [ Math.min(transExt[1][0], svgBBox.width), Math.min(transExt[1][1], svgBBox.height), ], ]; this.zoomBehavior .extent(ext) .translateExtent(transExt) .scaleExtent(this.zoomLimits); } /** @param {TrackRendererProps} nextProps */ UNSAFE_componentWillReceiveProps(nextProps) { /** * The size of some tracks probably changed, so let's just * redraw them. */ // don't initiate this component if it has nothing to draw on if (!nextProps.svgElement || !nextProps.canvasElement) { return; } const nextPropsStr = this.updatablePropsToString(nextProps); this.currentProps = nextProps; if (this.prevPropsStr === nextPropsStr) return; this.setBackground(); for (const uid in this.trackDefObjects) { const track = this.trackDefObjects[uid].trackObject; track.delayDrawing = true; } this.prevPropsStr = nextPropsStr; this.setUpInitialScales( nextProps.initialXDomain, nextProps.initialYDomain, nextProps.xDomainLimits, nextProps.yDomainLimits, nextProps.zoomLimits, ); this.setUpScales( nextProps.width !== this.props.width || nextProps.height !== this.props.height, ); this.svgElement = nextProps.svgElement; /** @type {[[number, number], [number, number]]} */ const transExt = [ [this.xScale(this.xDomainLimits[0]), this.yScale(this.yDomainLimits[0])], [this.xScale(this.xDomainLimits[1]), this.yScale(this.yDomainLimits[1])], ]; const svgBBox = this.svgElement.getBoundingClientRect(); /** @type {[[number, number], [number, number]]} */ const ext = [ [Math.max(transExt[0][0], 0), Math.max(transExt[0][1], 0)], [ Math.min(transExt[1][0], svgBBox.width), Math.min(transExt[1][1], svgBBox.height), ], ]; this.zoomBehavior .extent(ext) .translateExtent(transExt) .scaleExtent(this.zoomLimits); this.syncTrackObjects(nextProps.positionedTracks); this.syncMetaTracks(nextProps.metaTracks); for (const track of nextProps.positionedTracks) { // tracks all the way down const options = track.track.options; const trackObject = this.trackDefObjects[track.track.uid].trackObject; trackObject.rerender(options); if (isCombinedTrackConfig(track.track)) { /** @type {Record<string, TrackConfig>} */ const ctDefs = {}; for (const ct of track.track.contents) { ctDefs[ct.uid] = ct; } for (const uid in trackObject.createdTracks) { trackObject.createdTracks[uid].rerender(ctDefs[uid].options); } } } this.props.onNewTilesLoaded(); for (const uid in this.trackDefObjects) { const track = this.trackDefObjects[uid].trackObject; track.delayDrawing = false; track.draw(); } } /** @param {TrackRendererProps} prevProps */ componentDidUpdate(prevProps) { // If the initial domain changed, a new view config // probably has loaded. Reset the element's zoomTransform in this case. // In D3, an element’s transform is stored internally as element.__zoom if ( this.props.initialXDomain[0] !== prevProps.initialXDomain[0] || this.props.initialXDomain[1] !== prevProps.initialXDomain[1] || this.props.initialYDomain[0] !== prevProps.initialYDomain[0] || this.props.initialYDomain[1] !== prevProps.initialYDomain[1] ) { if (this.element) this.element.__zoom = zoomIdentity; } if (prevProps.isRangeSelection !== this.props.isRangeSelection) { if (this.props.isRangeSelection) { this.removeZoom(); } else { this.addZoom(); } } if (prevProps.zoomable !== this.props.zoomable) { if (this.props.zoomable) { this.addZoom(); } else { this.removeZoom(); } } this.addEventTracker(); } componentWillUnmount() { /** * This view has been removed so we need to get rid of all the tracks it contains */ this.mounted = false; this.removeTracks(Object.keys(this.trackDefObjects)); this.removeMetaTracks(Object.keys(this.metaTracks)); this.currentProps.removeDraggingChangedListener(this.draggingChanged); if (this.pStage) this.currentProps.pixiStage.removeChild(this.pStage); this.pMask?.destroy(true); this.pStage?.destroy(true); this.pubSubs.forEach((subscription) => this.props.pubSub.unsubscribe(subscription), ); this.pubSubs = []; this.removeEventTracker(); } /* --------------------------- Custom Methods ----------------------------- */ /** * Dispatch a forwarded event on the main DOM element * * @param {Event & { sourceUid: string, type: string }} event Event to be dispatched. */ dispatchEvent(event) { if (event.sourceUid === this.uid && event.type !== 'contextmenu') { if (this.element) forwardEvent(event, this.element); } } /** * Check of a view position (i.e., pixel coords) is within this view * * @param {number} x - X position to be tested. * @param {number} y - Y position to be tested. * @return {boolean} If `true` position is within this view. */ isWithin(x, y) { const withinX = x >= this.elementPos.left && x <= this.elementPos.width + this.elementPos.left; const withinY = y >= this.elementPos.top && y <= this.elementPos.height + this.elementPos.top; return withinX && withinY; } /** @param {{ pos: [number, number, number, number], animateTime: number }} opts */ zoomToDataPosHandler({ pos, animateTime }) { this.zoomToDataPos(...pos, animateTime); } addZoom() { if (!this.elementSelection || !this.currentProps.zoomable) return; this.elementSelection.call(this.zoomBehavior); this.zoomBehavior.transform(this.elementSelection, this.zoomTransform); } removeZoom() { if (this.elementSelection) { this.zoomEnded(); this.elementSelection.on('.zoom', null); } } /* * Add a mask to make sure that the tracks displayed in this view * don't overflow its bounds. */ setMask() { this.pMask?.clear(); this.pMask?.beginFill(); this.pMask?.drawRect( this.xPositionOffset, this.yPositionOffset, this.currentProps.width, this.currentProps.height, ); this.pMask?.endFill(); // show the bounds of this view /* this.pOutline.clear(); this.pOutline.lineStyle(1, '#000', 1); this.pOutline.drawRect( this.xPositionOffset, this.yPositionOffset, this.currentProps.width, this.currentProps.height ); */ } setBackground() { const defBgColor = this.props.theme === THEME_DARK ? 'black' : 'white'; const bgColor = colorToHex( this.currentProps.viewOptions?.backgroundColor ?? defBgColor, ); this.pBackground?.clear(); this.pBackground?.beginFill(bgColor); this.pBackground?.drawRect( this.xPositionOffset, this.yPositionOffset, this.currentProps.width, this.currentProps.height, ); this.pBackground?.endFill(); } windowScrolled() { this.removeZoom(); if (this.scrollTimeout) { clearTimeout(this.scrollTimeout); } this.scrollTimeout = setTimeout(() => { this.addZoom(); }, SCROLL_TIMEOUT); } /** * @param {[number, number]} initialXDomain * @param {[number, number]} initialYDomain * @param {[number, number]} xDomainLimits * @param {[number, number]} yDomainLimits * @param {[number, number]} zoomLimits */ setUpInitialScales( initialXDomain = [0, 1], initialYDomain = [0, 1], xDomainLimits = [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], yDomainLimits = [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], zoomLimits = [0, Number.MAX_SAFE_INTEGER], ) { // Make sure the initial domain is within the limits first zoomLimits[0] = zoomLimits[0] === null ? 0 : zoomLimits[0]; zoomLimits[1] = zoomLimits[1] === null ? Number.POSITIVE_INFINITY : zoomLimits[1]; // make sure the two scales are equally wide: const xWidth = initialXDomain[1] - initialXDomain[0]; const yCenter = (initialYDomain[0] + initialYDomain[1]) / 2; // initialYDomain = [yCenter - xWidth / 2, yCenter + xWidth / 2]; // stretch out the y-scale so that views aren't distorted (i.e. maintain // a 1 to 1 ratio) initialYDomain[0] = yCenter - xWidth / 2; initialYDomain[1] = yCenter + xWidth / 2; // if the inital domains haven't changed, then we don't have to // worry about resetting anything // initial domains should only change when loading a new viewconfig if ( initialXDomain[0] === this.initialXDomain[0] && initialXDomain[1] === this.initialXDomain[1] && initialYDomain[0] === this.initialYDomain[0] && initialYDomain[1] === this.initialYDomain[1] && xDomainLimits[0] === this.xDomainLimits[0] && xDomainLimits[1] === this.xDomainLimits[1] && yDomainLimits[0] === this.yDomainLimits[0] && yDomainLimits[1] === this.yDomainLimits[1] && zoomLimits[0] === this.zoomLimits[0] && zoomLimits[1] === this.zoomLimits[1] ) return; // only update the initial domain this.initialXDomain = initialXDomain; this.initialYDomain = initialYDomain; this.xDomainLimits = xDomainLimits; this.yDomainLimits = yDomainLimits; this.zoomLimits = zoomLimits; // Reset the local record of the zoom transform to avoid // pan & zoom jumps when saving the viewconfig this.zoomTransform = zoomIdentity; this.prevZoomTransform = zoomIdentity; this.cumCenterYOffset = 0; this.cumCenterXOffset = 0; this.drawableToDomainX = scaleLinear() .domain([ this.currentProps.paddingLeft + this.currentProps.leftWidth, this.currentProps.paddingLeft + this.currentProps.leftWidth + this.currentProps.centerWidth, ]) .range([initialXDomain[0], initialXDomain[1]]); let startY; let endY; if (this.currentProps.centerWidth === 0) { // If the width of the center track is zero, we do not want to make startY and endY equal. startY = this.currentProps.paddingTop + this.currentProps.topHeight; endY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight; } else { startY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2 - this.currentProps.centerWidth / 2; endY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2 + this.currentProps.centerWidth / 2; } this.drawableToDomainY = scaleLinear() .domain([startY, endY]) .range([initialYDomain[0], initialYDomain[1]]); this.prevCenterX = this.currentProps.paddingLeft + this.currentProps.leftWidth + this.currentProps.centerWidth / 2; this.prevCenterY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2; } /** @param {TrackRendererProps} props */ updatablePropsToString(props) { return JSON.stringify({ positionedTracks: props.positionedTracks, initialXDomain: props.initialXDomain, initialYDomain: props.initialYDomain, width: props.width, height: props.height, paddingLeft: props.paddingLeft, paddingTop: props.paddingTop, leftWidth: props.leftWidth, topHeight: props.topHeight, dragging: props.dragging, viewOptions: props.viewOptions, }); } /** @param {boolean} draggingStatus */ draggingChanged(draggingStatus) { this.dragging = draggingStatus; this.timedUpdatePositionAndDimensions(); } setUpScales(notify = false) { const currentCenterX = this.currentProps.paddingLeft + this.currentProps.leftWidth + this.currentProps.centerWidth / 2; const currentCenterY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2; // we need to maintain two scales: // 1. the scale that is shown // 2. the scale that the zooming behavior acts on // // These need to be separated because the zoom behavior acts on a larger // region than the visible scale shows // if the window is resized, we don't want to change the scale, but we do // want to move the center point. this needs to be tempered by the zoom // factor so that we keep the visible center point in the center if (!this.drawableToDomainX || !this.drawableToDomainY) { return; } const centerDomainXOffset = (this.drawableToDomainX(currentCenterX) - this.drawableToDomainX(this.prevCenterX)) / this.zoomTransform.k; const centerDomainYOffset = (this.drawableToDomainY(currentCenterY) - this.drawableToDomainY(this.prevCenterY)) / this.zoomTransform.k; this.cumCenterYOffset += centerDomainYOffset; this.cumCenterXOffset += centerDomainXOffset; this.prevCenterY = currentCenterY; this.prevCenterX = currentCenterX; // the domain of the visible (not drawable area) const visibleXDomain = [ this.drawableToDomainX(0) - this.cumCenterXOffset, this.drawableToDomainX(this.currentProps.width) - this.cumCenterXOffset, ]; const visibleYDomain = [ this.drawableToDomainY(0) - this.cumCenterYOffset, this.drawableToDomainY(this.currentProps.height) - this.cumCenterYOffset, ]; // [drawableToDomain(0), drawableToDomain(1)]: the domain of the visible area // if the screen has been resized, then the domain width should remain the same // this.xScale should always span the region that the zoom behavior is being called on this._xScale = scaleLinear() .domain(visibleXDomain) .range([0, this.currentProps.width]); this._yScale = scaleLinear() .domain(visibleYDomain) .range([0, this.currentProps.height]); for (const uid in this.trackDefObjects) { const track = this.trackDefObjects[uid].trackObject; // e.g. when the track is resized... we want to redraw it track.refScalesChanged(this.xScale, this.yScale); // track.draw(); } this.applyZoomTransform(notify); } /** * Get a track's viewconf definition by its object * * @param {TrackObject} trackObjectIn */ getTrackDef(trackObjectIn) { const trackDefItems = dictItems(this.trackDefObjects); for (const [, { trackDef, trackObject }] of trackDefItems) { if (trackObject === trackObjectIn) { return trackDef.track; } if (isCombinedTrackConfig(trackDef.track)) { // this is a combined track for (const subTrackDef of trackDef.track.contents) { if (trackObject.createdTracks[subTrackDef.uid] === trackObjectIn) { return subTrackDef; } } } } return null; } /** * Fetch the trackObject for a track with a given ID * * @param {string} trackId * @return {TrackObject | undefined} */ getTrackObject(trackId) { const trackDefItems = dictItems(this.trackDefObjects); for (let i = 0; i < trackDefItems.length; i++) { const uid = trackDefItems[i][0]; const trackObject = trackDefItems[i][1].trackObject; if (uid === trackId) { return trackObject; } // maybe this track is in a combined track if (trackObject.createdTracks) { const createdTrackItems = dictItems(trackObject.createdTracks); for (let j = 0; j < createdTrackItems.length; j++) { const createdTrackUid = createdTrackItems[j][0]; const createdTrackObject = createdTrackItems[j][1]; if (createdTrackUid === trackId) { return createdTrackObject; } } } } return undefined; } timedUpdatePositionAndDimensions() { if (this.closing || !this.element) return; this.elementPos = this.element.getBoundingClientRect(); if (this.dragging) { this.yPositionOffset = this.element.getBoundingClientRect().top - this.currentProps.canvasElement.getBoundingClientRect().top + this.scrollTop; this.xPositionOffset = this.element.getBoundingClientRect().left - this.currentProps.canvasElement.getBoundingClientRect().left; this.setMask(); this.setBackground(); const updated = this.updateTrackPositions(); if (updated) { // only redraw if positions changed this.applyZoomTransform(true); } requestAnimationFrame(this.timedUpdatePositionAndDimensions.bind(this)); } } /** * @param {Array<TrackConfig>} trackDefinitions */ syncMetaTracks(trackDefinitions) { const knownMetaTrackIds = Object.keys(this.metaTracks); const newMetaTracks = new Set(trackDefinitions.map((def) => def.uid)); // Add new meta tracks this.addMetaTracks( trackDefinitions.filter((def) => !this.metaTracks[def.uid]), ); // Update existing meta tracks this.updateMetaTracks( trackDefinitions.filter((def) => this.metaTracks[def.uid]), ); // Remove old meta tracks this.removeMetaTracks( knownMetaTrackIds.filter((def) => !newMetaTracks.has(def)), ); } /** * @param {Array<TrackDefinition>} trackDefinitions */ syncTrackObjects(trackDefinitions) { /** * Make sure we have a track object for every passed track definition. * * If we get a track definition for which we have no Track object, we * create a new one. * * If we have a track object for which we have no definition, we remove * the object. * * All the others we ignore. * * Track definitions should be of the following form: * * { height: 100, width: 50, top: 30, left: 40, track: {...}} * * @param trackDefinitions: The definition of the track * @return: Nothing */ this.prevTrackDefinitions = JSON.stringify(trackDefinitions); /** @type {Record<string, TrackDefinition>} */ const receivedTracksDict = {}; for (let i = 0; i < trackDefinitions.length; i++) { receivedTracksDict[trackDefinitions[i].track.uid] = trackDefinitions[i]; } const knownTracks = new Set(Object.keys(this.trackDefObjects)); const receivedTracks = new Set(Object.keys(receivedTracksDict)); // track definitions we don't have objects for const enterTrackDefs = new Set( [...receivedTracks].filter((x) => !knownTracks.has(x)), ); // track objects for which there is no definition // (i.e. they no longer need to exist) const exitTracks = new Set( [...knownTracks].filter((x) => !receivedTracks.has(x)), ); // we already have these tracks, but need to change their dimensions const updateTrackDefs = new Set( [...receivedTracks].filter((x) => knownTracks.has(x)), ); // update existing tracks this.updateExistingTrackDefs( [...updateTrackDefs].map((x) => receivedTracksDict[x]), ); // add new tracks and update them (setting dimensions and positions) this.addNewTracks([...enterTrackDefs].map((x) => receivedTracksDict[x])); this.updateExistingTrackDefs( [...enterTrackDefs].map((x) => receivedTracksDict[x]), ); this.removeTracks([...exitTracks]); } /** * Add new meta tracks * * @param {Array<TrackConfig>} metaTrackDefs Definitions of meta tracks to be added. */ addMetaTracks(metaTrackDefs) { metaTrackDefs .filter((metaTrackDef) => !this.metaTracks[metaTrackDef.uid]) .forEach((metaTrackDef) => { this.metaTracks[metaTrackDef.uid] = { trackDef: metaTrackDef, trackObject: this.createMetaTrack(metaTrackDef), }; }); } /** * @param {Array<TrackDefinition>} newTrackDefinitions */ addNewTracks(newTrackDefinitions) { /** * We need to create new track objects for the given track * definitions. */ if (!this.currentProps.pixiStage) { return; } // we need a pixi stage to start rendering // the parent component where it lives probably // hasn't been mounted yet for (let i = 0; i < newTrackDefinitions.length; i++) { const newTrackDef = newTrackDefinitions[i]; /** @type {TrackObject} */ // @ts-expect-error - FIXME: Should not need to lie about the return type from createTrackObject. const newTrackObj = this.createTrackObject(newTrackDef.track); // newTrackObj.refXScale(this.xScale); // newTrackObj.refYScale(this.yScale); newTrackObj.refScalesChanged(this.xScale, this.yScale); this.trackDefObjects[newTrackDef.track.uid] = { trackDef: newTrackDef, trackObject: newTrackObj, }; const zoomedXScale = this.zoomTransform.rescaleX(this.xScale); const zoomedYScale = this.zoomTransform.rescaleY(this.yScale); newTrackObj.setDimensions([newTrackDef.width, newTrackDef.height]); newTrackObj.zoomed(zoomedXScale, zoomedYScale); } // this could be replaced with a call that only applies the zoom // transform to the newly added tracks this.applyZoomTransform(false); } /** @param {unknown} _unused */ updateMetaTracks(_unused) { // Nothing } /** @param {Array<TrackDefinition>} newTrackDefs */ updateExistingTrackDefs(newTrackDefs) { for (const trackDef of newTrackDefs) { const ref = this.trackDefObjects[trackDef.track.uid]; ref.trackDef = trackDef; // if it's a CombinedTrack, we have to see if its contents have changed // e.g. somebody may have added a new Series if (isCombinedTrackConfig(trackDef.track)) { ref.trackObject .updateContents( trackDef.track.contents, this.createTrackObject.bind(this), ) .refScalesChanged(this.xScale, this.yScale); } } const updated = this.updateTrackPositions(); // this.applyZoomTransform(); if (updated) { // only redraw if positions changed this.applyZoomTransform(false); } } updateTrackPositions() { let updated = false; for (const uid in this.trackDefObjects) { const trackDef = this.trackDefObjects[uid].trackDef; const trackObject = this.trackDefObjects[uid].trackObject; const prevPosition = trackObject.position; const prevDimensions = trackObject.dimensions; /** @type {[number, number]} */ const newPosition = [ this.xPositionOffset + trackDef.left, this.yPositionOffset + trackDef.top, ]; /** @type {[number, number]} */ const newDimensions = [trackDef.width, trackDef.height]; // check if any of the track's positions have changed // before trying to update them if ( !prevPosition || newPosition[0] !== prevPosition[0] || newPosition[1] !== prevPosition[1] ) { trackObject.setPosition(newPosition); updated = true; } if ( !prevDimensions || newDimensions[0] !== prevDimensions[0] || newDimensions[1] !== prevDimensions[1] ) { trackObject.setDimensions(newDimensions); updated = true; } // const widthDifference = trackDef.width - this.currentProps.width; // const heightDifference = trackDef.height - this.currentProps.height; } // report on whether any track positions or dimensions have changed // so that downstream code can decide whether to redraw return updated; } /** @param {string[]} trackIds */ removeMetaTracks(trackIds) { trackIds.forEach((id) => { this.metaTracks[id].trackObject.remove(); // @ts-expect-error - We are deleting the track object here this.metaTracks[id] = undefined; delete this.metaTracks[id]; }); } /** @param {string[]} trackUids */ removeTracks(trackUids) { for (let i = 0; i < trackUids.length; i++) { this.trackDefObjects[trackUids[i]].trackObject.remove(); delete this.trackDefObjects[trackUids[i]]; } } /** * Set the center of this view to a paticular X and Y coordinate * @param {number} centerX Centeral X data? position. * @param {number} centerY Central Y data? position. * @param {number} sourceK Source zoom level? @Pete what's the source? * @param {boolean} notify If `true` notify listeners that the scales * have changed. This can be turned off to prevent circular updates when * scales are locked. * @param {number} animateTime Animation time in milliseconds. Only used * when `animate` is true. * @param {Scale} xScale The scale to use for the X axis. * @param {Scale} yScale The scale to use for the Y axis. */ setCenter( centerX, centerY, sourceK, notify = false, animateTime = 0, xScale = this.xScale, yScale = this.yScale, ) { const refK = this.xScale.invert(1) - this.xScale.invert(0); const k = refK / sourceK; const middleViewX = this.currentProps.paddingLeft + this.currentProps.leftWidth + this.currentProps.centerWidth / 2; const middleViewY = this.currentProps.paddingTop + this.currentProps.topHeight + this.currentProps.centerHeight / 2; // After applying the zoom transform, the xScale of the target centerX // should be equal to the middle of the viewport // xScale(centerX) * k + translate[0] = middleViewX const translateX = middleViewX - xScale(centerX) * k; const translateY = middleViewY - yScale(centerY) * k; /** @type {[Scale, Scale] | undefined} */ let last; const setZoom = () => { const newTransform = zoomIdentity .translate(translateX, translateY) .scale(k); this.zoomTransform = newTransform; if (this.elementSelection) { this.emptyZoomBehavior.transform(this.elementSelection, newTransform); } last = this.applyZoomTransform(notify); }; if (animateTime && this.elementSelection) { let selection = this.elementSelection; this.activeTransitions += 1; if (!document.hidden) { // only transition if the window is hidden // @ts-expect-error - Returns a TransitionSelection, which should be OK to use below selection = selection.transition().duration(animateTime); } selection .call( this.zoomBehavior.transform, zoomIdentity.translate(translateX, translateY).scale(k), ) .on('end', () => { setZoom(); this.activeTransitions -= 1; }); } else { setZoom(); } return last; } /** @param {number} movement */ valueScaleMove(movement) { if (!this.zoomStartPos) { return; } // mouse wheel from zoom event // const cp = pointer(event.sourceEvent, this.props.canvasElement); for (const track of this.getTracksAtPosition(...this.zoomStartPos)) { track.movedY(movement); } if (this.zoomStartTransform) this.zoomTransform = this.zoomStartTransform; } /** * @param {{ sourceEvent: Event }} event * @param {string | null} orientation */ valueScaleZoom(event, orientation) { // mouse move probably from a drag event if (!isWheelEvent(event.sourceEvent)) { return; } const mdy = event.sourceEvent.deltaY; const mdm = event.sourceEvent.deltaMode; /** * @param {number} dy * @param {number} dm * @return {number} */ const myWheelDelta = (dy, dm) => (dy * (dm ? 120 : 1)) / 500; const mwd = myWheelDelta(mdy, mdm); const cp = pointer(event.sourceEvent, this.props.canvasElement); for (const track of this.getTracksAtPosition(...cp)) { const yPos = orientation === '1d-horizontal' ? cp[1] - track.position[1] : cp[0] - track.position[0]; track.zoomedY(yPos, 2 ** mwd); } // reset the zoom transform if (this.zoomStartTransform) this.zoomTransform = this.zoomStartTransform; } /** * Respond to a zoom event. * * We need to update our local record of the zoom transform and apply it * to all the tracks. * * @param {import("d3-zoom").D3ZoomEvent<HTMLElement, unknown> & { shiftKey?: boolean }} event */ zoomed(event) { // the orientation of the track where we started zooming // if it's a 1d-horizontal, then mousemove events shouldn't // move the center track vertically /** @type {string | null} */ let trackOrientation = null; // see what orientation of track we're over so that we decide // whether to move the value scale or the position scale if (this.zoomStartPos) { const tracksAtZoomStart = this.getTracksAtPosition(...this.zoomStartPos); if (tracksAtZoomStart.length) { const trackAtZoomStart = tracksAtZoomStart[0]; const trackDef = this.getTrackDef(trackAtZoomStart); if (!trackDef) { return; } if (TRACKS_INFO_BY_TYPE[trackDef.type]?.orientation) { // some track types (like overlay-track don't have a track info) trackOrientation = TRACKS_INFO_BY_TYPE[trackDef.type].orientation; } if (trackAtZoomStart instanceof LeftTrackModifier) { // this is a LeftTrackModifier track so it's vertical trackOrientation = '1d-vertical'; } } } if (trackOrientation && event.sourceEvent) { // if somebody is holding down the shift key and is zooming over // a 1d track, try to apply value scale zooming if (event.shiftKey || this.valueScaleZooming) { if (event.sourceEvent.deltaY !== undefined) { this.valueScaleZoom(event, trackOrientation); return; } if (trackOrientation === '1d-horizontal') { this.valueScaleMove(event.sourceEvent.movementY); } else if (trackOrientation === '1d-vertical') { this.valueScaleMove(event.sourceEvent.movementX); } } // if somebody is dragging along a 1d track, do value scale moving if (trackOrientation === '1d-horizontal' && event.sourceEvent.movementY) { this.valueScaleMove(event.sourceEvent.movementY); } else if ( trackOrientation === '1d-vertical' && event.sourceEvent.movementX ) { this.valueScaleMove(event.sourceEvent.movementX); } } this.zoomTransform = !this.currentProps.zoomable ? zoomIdentity : event.transform; const zooming = this.prevZoomTransform.k !== this.zoomTransform.k; // if there is dragging along a 1d track, only allow panning // along the axis of the track if (!zooming) { if (trackOrientation === '1d-horizontal') { // horizontal tracks shouldn't allow movement in the y direction // don't move along y axis this.zoomTransform = zoomIdentity .translate(this.zoomTransform.x, this.prevZoomTransform.y) .scale(this.zoomTransform.k); } else if (trackOrientation === '1d-vertical') { // vertical tracks shouldn't allow movement in the x axis this.zoomTransform = zoomIdentity .translate(this.prevZoomTransform.x, this.zoomTransform.y) .scale(this.zoomTransform.k); } if (this.element) this.element.__zoom = this.zoomTransform; } this.applyZoomTransform(true); this.prevZoomTransform = this.zoomTransform; this.pr