// @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, { getCompactor } from 'react-grid-layout';
import slugid from 'slugid';
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 = {
      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?.getRootNode().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.
    // Also measure initial width so that ReactGridLayout renders grid items
    // with valid dimensions on the first mounted render. Without this,
    // width=0 causes react-grid-layout v2 to calculate negative column
    // widths, and TiledPlot's ResizeSensor fails to detect the subsequent
    // resize from zero to the actual size.
    const [initialWidth, initialHeight] = getElementDim(this.element);
    this.setState({
      svgElement: this.svgElement,
      canvasElement: this.canvasElement,
      ...(initialWidth > 0 ? { width: initialWidth } : {}),
      ...(initialHeight > 0 ? { height: initialHeight } : {}),
    });

    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();
  }

  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 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.locationLocksAxisWise.x[uid]) {
      // the x axis of this view is locked to an axis of another view
      const lockGroup = this.locationLocksAxisWise.x[uid].lock;
      const lockGroupItems = dictItems(lockGroup);

      // this means the x axis of this view (uid) is locked to the y axis of another view
      const lockCrossAxis = this.locationLocksAxisWise.x[uid].axis !== 'x';

      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 newCenterX = centerX + dx;

        if (!this.setCenters[key]) {
          continue;
        }

        const [newXScale, newYScale] = this.setCenters[key](
          lockCrossAxis ? keyCenterX : newCenterX,
          lockCrossAxis ? newCenterX : keyCenterY,
          keyK,
          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.locationLocksAxisWise.y[uid]) {
      // the y axis of this view is locked to an axis of another view
      const lockGroup = this.locationLocksAxisWise.y[uid].lock;
      const lockGroupItems = dictItems(lockGroup);

      // this means the y axis of this view (uid) is locked to the x axis of another view
      const lockCrossAxis = this.locationLocksAxisWise.y[uid].axis !== 'y';

      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 dy = value[1] - lockGroup[uid][1];

        const newCenterY = centerY + dy;

        if (!this.setCenters[key]) {
          continue;
        }

        const [newXScale, newYScale] = this.setCenters[key](
          lockCrossAxis ? newCenterY : keyCenterX,
          lockCrossAxis ? keyCenterY : newCenterY,
          keyK,
          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);
          });
        }
      }
    }

    this.animate();

    // Call view change handler
    this.triggerViewChangeDb();
  }

  handleProjectViewport(uid) {
    /**
     * We want to show the extent of this viewport on another view.
     */

    this.setState({
      chooseTrackHandler: (viewUid, trackUid) =>
        this.handleViewportProjected(uid, viewUid, trackUid),
    });
  }

  /**
   * Adjust the zoom level so that all of the data is visible
   *
   * @param viewUid: The view uid for which to adjust the zoom level
   */
  handleZoomToData(viewUid) {
    if (viewUid && !this.tiledPlots[viewUid]) {
      throw new Error(
        `View uid ${viewUid} does not exist in the current viewConfig`,
      );
    }

    if (viewUid) {
      this.tiledPlots[viewUid].handleZoomToData();
    } else {
      Object.values(this.tiledPlots).forEach((tiledPlot) =>
        tiledPlot.handleZoomToData(),
      );
    }
  }

  /**
   * Reset the viewport to the initial x and y domain
   * @param  {number} viewId - ID of the view for which the viewport should be
   *  reset.
   */
  resetViewport(viewId) {
    if (viewId && !this.tiledPlots[viewId]) {
      throw new Error(
        `View uid ${viewId} does not exist in the current viewConfig`,
      );
    }

    if (viewId) {
      this.tiledPlots[viewId].resetViewport();
    } else {
      Object.values(this.tiledPlots).forEach((tiledPlot) =>
        tiledPlot.resetViewport(),
      );
    }
  }

  /**
   * We want to yank some attributes from another view.
   *
   * This will create a view selection overlay and then call the selected
   * provided function.
   */
  handleYankFunction(uid, yankFunction) {
    this.setState({
      chooseViewHandler: (uid2) => yankFunction(uid, uid2),
      mouseOverOverlayUid: uid,
    });
  }

  /**
   * We want to unlock uid from the zoom group that it's in.
   *
   * @param uid: The uid of a view.
   */
  handleUnlock(uid, lockGroups) {
    // if this function is being called, lockGroup has to exist
    const lockGroup = lockGroups[uid];
    const lockGroupKeys = dictKeys(lockGroup);

    if (lockGroupKeys.length === 2) {
      // there's only two items in this lock group so we need to
      // remove them both (no point in having one view locked to itself)
      delete lockGroups[lockGroupKeys[0]];
      delete lockGroups[lockGroupKeys[1]];

      return;
    }
    // delete this view from the zoomLockGroup
    if (lockGroups[uid]) {
      if (lockGroups[uid][uid]) {
        delete lockGroups[uid][uid];
      }
    }

    // remove the handler
    if (lockGroups[uid]) {
      delete lockGroups[uid];
    }
  }

  viewScalesLockData(uid) {
    if (!this.xScales[uid] || !this.yScales[uid]) {
      console.warn("View scale lock doesn't correspond to existing uid: ", uid);
      return null;
    }

    return scalesCenterAndK(this.xScales[uid], this.yScales[uid]);
  }

  /*
   * :param uid1 (string): The uid of the first element to be locked (e.g. viewUid)
   * :param uid2 (string): The uid of the second element to be locked (e.g. viewUid)
   * :param lockGroups (dict): The set of locks where to store this lock (e.g. this.locationLocks)
   * :parma lockData (function): A function that takes two uids and calculates some extra data
   * to store with this lock data (e.g. scalesCenterAndK(this.xScales[uid1], this.yScales[uid1]))
   */
  addLock(uid1, uid2, lockGroups, lockData) {
    let group1Members = [];
    let group2Members = [];

    if (!lockGroups[uid1]) {
      // view1 isn't already in a group
      group1Members = [[uid1, lockData.bind(this)(uid1)]];
    } else {
      // view1 is already in a group
      group1Members = dictItems(lockGroups[uid1])
        .filter((x) => lockData(x[0])) // make sure we can create the necessary data for this lock
        // in the case of location locks, this implies that the
        // views it's locking exist
        .map((x) => [x[0], lockData(x[0])]); // x is [uid, [centerX, centerY, k]]
    }

    if (!lockGroups[uid2]) {
      // view1 isn't already in a group
      group2Members = [[uid2, lockData.bind(this)(uid2)]];
    } else {
      // view2 is already in a group
      group2Members = dictItems(lockGroups[uid2])
        .filter((x) => lockData.bind(this)(x[0]))
        // make sure we can create the necessary data for this lock
        // in the case of location locks, this implies that the
        // views it's locking exist
        .map((x) =>
          // x is [uid, [centerX, centerY, k]]
          [x[0], lockData.bind(this)(x[0])],
        );
    }

    const allMembers = group1Members.concat(group2Members);

    const groupDict = dictFromTuples(allMembers);

    allMembers.forEach((m) => {
      lockGroups[m[0]] = groupDict;
    });
  }

  /* Views uid1 and uid2 need to be locked so that they always maintain the current
   * zoom and translation difference.
   * @param uid1: The view that the lock was called from
   * @param uid2: The view that the lock was called on (the view that was selected)
   */
  handleLocationLockChosen(uid1, uid2) {
    if (uid1 === uid2) {
      this.setState({
        chooseViewHandler: null,
      });

      return; // locking a view to itself is silly
    }

    this.addLock(
      uid1,
      uid2,
      this.locationLocks,
      this.viewScalesLockData.bind(this),
    );

    this.setState({
      chooseViewHandler: null,
    });
  }

  /* Views uid1 and uid2 need to be locked so that they always maintain the current
   * zoom and translation difference.
   * @param uid1: The view that the lock was called from
   * @param uid2: The view that the lock was called on (the view that was selected)
   */
  handleZoomLockChosen(uid1, uid2) {
    if (uid1 === uid2) {
      this.setState({
        chooseViewHandler: null,
      });

      return; // locking a view to itself is silly
    }

    this.addLock(
      uid1,
      uid2,
      this.zoomLocks,
      this.viewScalesLockData.bind(this),
    );

    this.setState({
      chooseViewHandler: null,
    });
  }

  /**
   * We want to project the viewport of fromView onto toTrack of toView.
   *
   * @param fromView: The uid of the view that we want to project
   * @param toView: The uid of the view that we want to project to
   * @param toTrack: The track we want to project to
   *
   * Returns
   * -------
   *
   *  newTrackUid: string
   *      The uid of the newly created viewport projection track
   */
  handleViewportProjected(fromView, toView, toTrack) {
    let newTrackUid = null;

    if (fromView === toView) {
      console.warn('A view can not show its own viewport.');
    } else {
      const hostTrack = getTrackByUid(this.state.views[toView].tracks, toTrack);
      const position = getTrackPositionByUid(
        this.state.views[toView].tracks,
        toTrack,
      );
      newTrackUid = slugid.nice();

      const projectionTypes = {
        top: 'horizontal',
        bottom: 'horizontal',
        center: 'center',
        left: 'vertical',
        right: 'vertical',
      };

      const newTrack = {
        uid: newTrackUid,
        type: `viewport-projection-${projectionTypes[position]}`,
        fromViewUid: fromView,
      };

      this.addCallbacks(toView, newTrack);
      this.handleTrackAdded(toView, newTrack, position, null, hostTrack);
    }
    this.setState({
      chooseTrackHandler: null,
    });

    return newTrackUid;
  }

  /**
   * Uid1 is copying the center of uid2
   */
  handleLocationYanked(uid1, uid2) {
    // where we're taking the zoom from
    const sourceXScale = this.xScales[uid2];
    const sourceYScale = this.yScales[uid2];

    const targetXScale = this.xScales[uid1];
    const targetYScale = this.yScales[uid1];

    const [targetCenterX, targetCenterY, targetK] = scalesCenterAndK(
      targetXScale,
      targetYScale,
    );

    const [sourceCenterX, sourceCenterY, sourceK] = scalesCenterAndK(
      sourceXScale,
      sourceYScale,
    );

    // set target center
    this.setCenters[uid1](sourceCenterX, sourceCenterY, targetK, true);

    this.setState({
      chooseViewHandler: null,
    });
  }

  /**
   * Uid1 yanked the zoom of uid2, now  make sure that they're synchronized.
   */
  handleZoomYanked(uid1, uid2) {
    // where we're taking the zoom from
    const sourceXScale = this.xScales[uid2];
    const sourceYScale = this.yScales[uid2];

    const targetXScale = this.xScales[uid1];
    const targetYScale = this.yScales[uid1];

    const [targetCenterX, targetCenterY, targetK] = scalesCenterAndK(
      targetXScale,
      targetYScale,
    );

    const [sourceCenterX, sourceCenterY, sourceK] = scalesCenterAndK(
      sourceXScale,
      sourceYScale,
    );

    // set target center
    this.setCenters[uid1](targetCenterX, targetCenterY, sourceK, true);

    this.setState({
      chooseViewHandler: null,
    });
  }

  /**
   * The user has chosen a position for the new track. The actual
   * track selection will be handled by TiledPlot
   *
   * We just need to close the menu here.
   */
  handleTrackPositionChosen(viewUid, position, extent) {
    this.setState({
      addTrackPosition: position,
      addTrackExtent: extent,
      addTrackPositionView: viewUid,
    });
  }

  /**
   * Update the height of each row in the layout so that it takes up all
   * of the available space in the div.
   */
  updateRowHeight() {
    if (
      !this.props.options ||
      (this.sizeMode !== SIZE_MODE_BOUNDED &&
        this.sizeMode !== SIZE_MODE_BOUNDED_OVERFLOW) ||
      this.props.options.pixelPreciseMarginPadding
    ) {
      // not bounded so we don't need to update the row height
      return;
    }

    // const width = this.element.parentNode.clientWidth;
    const height = this.element.parentNode.clientHeight;

    let maxHeight = 1;
    let perTrackRowHeight = 0;

    for (const view of dictValues(this.state.views)) {
      maxHeight = Math.max(maxHeight, view.layout.y + view.layout.h);

      const topTrackHeights = view.tracks.top
        .filter((x) => x.height)
        .map((x) => x.height)
        .reduce((a, b) => a + b, 0);
      const bottomTrackHeights = view.tracks.bottom
        .filter((x) => x.height)
        .map((x) => x.height)
        .reduce((a, b) => a + b, 0);

      // centerTrackHeight is excluded because center tracks
      // are resized to fit into the remaining part of the view
      // const centerTrackHeight = view.tracks.center
      //   .filter(x => x.height)
      //   .map(x => x.height)
      //   .reduce((a, b) => a + b, 0);

      perTrackRowHeight = Math.ceil(
        Math.max(
          perTrackRowHeight,
          (topTrackHeights + bottomTrackHeights) / view.layout.h,
        ),
      );
    }

    this.handleDragStart();
    this.handleDragStop();

    const MARGIN_HEIGHT = this.isEditable() ? 10 : 0;

    const marginHeight = MARGIN_HEIGHT * maxHeight - 1;
    const availableHeight = height - marginHeight;

    // if we're bounded, then maxHeight * rowHeight = availableHeight
    // but the catch is that if we have tracks that would be larger
    // than the largest div, we have to adjust the height to make sure
    // all of them are visible
    // to do that, we have to go view by view and calculate the heights
    // of the top and bottom sections and then divide that by the view's height
    // if there's a center track

    const prospectiveRowHeight = Math.floor(availableHeight / maxHeight); // maxHeight is the number of
    // rows necessary to display this view
    const chosenRowHeight = Math.max(prospectiveRowHeight, perTrackRowHeight);

    if (this.topDivRef.current) {
      this.setState({
        rowHeight: chosenRowHeight,
      });
    }
  }

  /**
   * Notify the children that the layout has changed so that they
   * know to redraw themselves
   */
  handleLayoutChange(layout) {
    if (!this.element) return;

    layout.forEach((l) => {
      const view = this.state.views[l.i];

      if (view) {
        // Bad design pattern. We directly manipulate the state and rely on
        // `this.updateRowHeight()` to trigger that the state updated
        view.layout.x = l.x;
        view.layout.y = l.y;
        view.layout.w = l.w;
        view.layout.h = l.h;
        view.layout.i = l.i;
      }
    });

    // The following method actually trigger a state update
    this.updateRowHeight();
    this.refreshView(LONG_DRAG_TIMEOUT);
  }

  /**
   * Maybe somebody started dragging again before the previous drag
   * timeout fired. In that case, we need to clear this timeout so
   * that it doesn't override a previously set one.
   */
  clearDragTimeout() {
    if (this.dragTimeout) {
      clearTimeout(this.dragTimeout);
      this.dragTimeout = null;
    }
  }

  getTrackInfo(trackType) {
    if (TRACKS_INFO_BY_TYPE[trackType]) {
      return TRACKS_INFO_BY_TYPE[trackType];
    }

    if (this.pluginTracks?.[trackType]) {
      return this.pluginTracks[trackType].config;
    }

    if (window.higlassTracksByType?.[trackType]) {
      return window.higlassTracksByType[trackType].config;
    }

    console.warn(
      'Track type not found:',
      trackType,
      '(check app/scripts/config/ for a list of defined track types)',
    );
    return undefined;
  }

  forceRefreshView() {
    // force everything to rerender
    this.setState(this.state);
  }

  refreshView(timeout = SHORT_DRAG_TIMEOUT) {
    this.clearDragTimeout();

    this.notifyDragChangedListeners(true);

    this.clearDragTimeout();
    this.dragTimeout = setTimeout(() => {
      this.notifyDragChangedListeners(false);
    }, timeout);
  }

  handleDragStart(/* layout, oldItem, newItem, placeholder, e, element */) {
    this.clearDragTimeout();
    this.notifyDragChangedListeners(true);
  }

  handleDragStop() {
    // wait for the CSS transitions to end before
    // turning off the dragging state
    //
    this.clearDragTimeout();
    this.dragTimeout = setTimeout(() => {
      this.notifyDragChangedListeners(false);
    }, LONG_DRAG_TIMEOUT);
  }

  resizeHandler() {
    objVals(this.viewHeaders)
      .filter((x) => x)
      .forEach((viewHeader) => viewHeader.checkWidth());
  }

  /**
   * Get the dimensions for this view, counting just the tracks
   * that are present in it
   *
   * @param view: A view containing a list of tracks as a member.
   * @return: A width and a height pair (e.g. [width, height])
   */
  calculateViewDimensions(view) {
    const defaultHorizontalHeight = 20;
    const defaultVerticalWidth = 0;
    const defaultCenterHeight = 100;
    const defaultCenterWidth = 100;
    let currHeight =
      this.viewMarginTop +
      this.viewMarginBottom +
      this.viewPaddingTop +
      this.viewPaddingBottom;
    let currWidth =
      this.viewMarginLeft +
      this.viewMarginRight +
      this.viewPaddingLeft +
      this.viewPaddingRight;
    // currWidth will generally be ignored because it will just be set to
    // the width of the enclosing container
    let minNecessaryHeight = 0;
    minNecessaryHeight += 10; // the header

    const MIN_VERTICAL_HEIGHT = 20;

    if (view.tracks.top) {
      // tally up the height of the top tracks

      for (let i = 0; i < view.tracks.top.length; i++) {
        const track = view.tracks.top[i];
        currHeight += track.height ? track.height : defaultHorizontalHeight;
        minNecessaryHeight += track.height
          ? track.height
          : defaultHorizontalHeight;
      }
    }

    if (view.tracks.bottom) {
      // tally up the height of the top tracks

      for (let i = 0; i < view.tracks.bottom.length; i++) {
        const track = view.tracks.bottom[i];
        currHeight += track.height ? track.height : defaultHorizontalHeight;
        minNecessaryHeight += track.height
          ? track.height
          : defaultHorizontalHeight;
      }
    }

    if (
      (view.tracks.left && view.tracks.left.length > 0) ||
      (view.tracks.right && view.tracks.right.length > 0) ||
      (view.tracks.center && view.tracks.center.length > 0)
    ) {
      minNecessaryHeight += MIN_VERTICAL_HEIGHT;
    }

    let leftHeight = 0;
    if (view.tracks.left) {
      // tally up the height of the top tracks

      for (let i = 0; i < view.tracks.left.length; i++) {
        const track = view.tracks.left[i];
        const thisHeight = track.height ? track.height : defaultCenterHeight;
        currWidth += track.width ? track.width : defaultVerticalWidth;

        leftHeight = Math.max(leftHeight, thisHeight);
      }
    }

    let rightHeight = 0;

    if (view.tracks.right) {
      // tally up the height of the top tracks

      for (let i = 0; i < view.tracks.right.length; i++) {
        const track = view.tracks.right[i];
        const thisHeight = track.height ? track.height : defaultCenterHeight;
        currWidth += track.width ? track.width : defaultVerticalWidth;

        rightHeight = Math.max(rightHeight, thisHeight);
      }
    }

    const sideHeight = Math.max(leftHeight, rightHeight);

    let centerHeight = 0;
    let centerWidth = 0;

    if (view.tracks.center && dictValues(view.tracks.center).length > 0) {
      if (
        !view.tracks.center[0].contents ||
        view.tracks.center[0].contents.length > 0
      ) {
        let height = null;
        let width = null;

        if (view.tracks.center[0].contents) {
          // combined track in the center
          for (const track of view.tracks.center[0].contents) {
            height = Math.max(
              height,
              track.height ? track.height : defaultCenterHeight,
            );
            width = Math.max(
              width,
              track.width ? track.width : defaultCenterWidth,
            );
          }
        } else {
          height = view.tracks.center[0].height
            ? view.tracks.center[0].height
            : defaultCenterHeight;
          width = view.tracks.center[0].width
            ? view.tracks.center[0].width
            : defaultCenterWidth;
        }

        centerHeight = height;
        centerWidth = width;
      }
    } else if (
      ((view.tracks.top && dictValues(view.tracks.top).length > 1) ||
        (view.tracks.bottom && dictValues(view.tracks.bottom).length > 1)) &&
      ((view.tracks.left && dictValues(view.tracks.left).length) ||
        (view.tracks.right && dictValues(view.tracks.right).length))
    ) {
      centerWidth = defaultCenterWidth;
      centerHeight = defaultCenterHeight;
    }

    // make the total height the greater of the left height
    // and the center height
    if (sideHeight > centerHeight) {
      currHeight += sideHeight;
    } else {
      currHeight += centerHeight;
    }

    let topHeight = 0;
    let bottomHeight = 0;
    let leftWidth = 0;
    let rightWidth = 0;

    if ('top' in view.tracks) {
      topHeight = view.tracks.top
        .map((x) => (x.height ? x.height : defaultHorizontalHeight))
        .reduce((a, b) => a + b, 0);
    }
    if ('bottom' in view.tracks) {
      bottomHeight = view.tracks.bottom
        .map((x) => (x.height ? x.height : defaultHorizontalHeight))
        .reduce((a, b) => a + b, 0);
    }
    if ('left' in view.tracks) {
      leftWidth = view.tracks.left
        .map((x) => (x.width ? x.width : defaultVerticalWidth))
        .reduce((a, b) => a + b, 0);
    }
    if ('right' in view.tracks) {
      rightWidth = view.tracks.right
        .map((x) => (x.width ? x.width : defaultVerticalWidth))
        .reduce((a, b) => a + b, 0);
    }

    return {
      totalWidth: currWidth,
      totalHeight: currHeight,
      topHeight,
      bottomHeight,
      leftWidth,
      rightWidth,
      centerWidth,
      centerHeight,
      minNecessaryHeight,
    };
  }

  calculateZoomLimits(view, initialXDomain) {
    const limits = [0, Number.POSITIVE_INFINITY];

    if ('zoomLimits' in view) {
      const diffX = initialXDomain[1] - initialXDomain[0];
      const viewConfLimit = view.zoomLimits;

      if (viewConfLimit.length !== 2) {
        return limits;
      }

      if (viewConfLimit[0] !== null && viewConfLimit[0] > 0) {
        const upperLimit = diffX / viewConfLimit[0];
        limits[1] = Math.max(upperLimit, 1);
        if (upperLimit < 1) {
          console.warn(`Invalid zoom limits. Lower limit set to ${diffX}`);
        }
      }

      if (viewConfLimit[1] !== null && viewConfLimit[1] > viewConfLimit[0]) {
        const lowerLimit = diffX / viewConfLimit[1];
        limits[0] = Math.min(lowerLimit, 1);
        if (lowerLimit > 1) {
          console.warn(`Invalid zoom limits. Upper limit set to ${diffX}`);
        }
      }
    }
    return limits;
  }

  generateViewLayout(view) {
    let layout = null;

    if ('layout' in view) {
      ({ layout } = view.layout);
    } else {
      /*
      const minTrackHeight = 30;
      const elementWidth = this.element.clientWidth;

      let { totalWidth, totalHeight,
        topHeight, bottomHeight,
        leftWidth, rightWidth,
        centerWidth, centerHeight } = this.calculateViewDimensions(view);

      if (view.searchBox) { totalHeight += 30; }

      const heightGrid = Math.ceil(totalHeight / this.rowHeight);

      */

      // we're keeping this simple, just make the view 12x12
      layout = {
        x: 0,
        y: 0,
        w: NUM_GRID_COLUMNS,
        h: DEFAULT_NEW_VIEW_HEIGHT,
      };

      // the height should be adjusted when the layout changes

      /*
        if ('center' in view.tracks || 'left' in view.tracks || 'right' in view.tracks) {
            let desiredHeight = ((elementWidth - leftWidth - rightWidth - 2 * this.viewMarginTop) );
            desiredHeight +=  topHeight + bottomHeight + 2*this.viewMarginBottom + 20;

            // how much height is left in the browser?

            // limit the height of the container to the window height
            // the number 160 is relatively arbitrary and should be
            // replaced with a concrete measure of the element below and
            // above the canvas area
            let availableHeight = window.innerHeight - 160;

            if (desiredHeight > availableHeight )
                desiredHeight = availableHeight;

            // stretch the view out
            layout.h = Math.ceil(desiredHeight / this.rowHeight);
        }
        else
            layout.h = heightGrid;

        layout.minH = heightGrid;
        layout.i = slugid.nice();
        */
    }

    return layout;
  }

  /**
   * Remove all the tracks from a view
   *
   * @param {viewUid} Thie view's identifier
   */
  handleClearView(viewUid) {
    const { views } = this.state;

    views[viewUid].tracks.top = [];
    views[viewUid].tracks.bottom = [];
    views[viewUid].tracks.center = [];
    views[viewUid].tracks.left = [];
    views[viewUid].tracks.right = [];
    views[viewUid].tracks.whole = [];

    this.setState({
      views,
    });
  }

  /**
   * A view needs to be closed. Remove it from from the viewConfig and then clean
   * up all of its connections (zoom links, workers, etc...)
   *
   * @param {uid} This view's identifier
   */
  handleCloseView(uid) {
    // check if this is the only view
    // if it is, don't close it (display an error message)
    if (dictValues(this.state.views).length === 1) {
      return;
    }

    // if this view was zoom locked to another, we need to unlock it
    this.handleUnlock(uid, this.zoomLocks);

    // might want to notify the views that they're beig closed
    this.setState((prevState) => {
      delete prevState.views[uid];

      const viewsByUid = this.removeInvalidTracks(prevState.views);
      return {
        views: viewsByUid,
      };
    });
  }

  /**
   * We're adding a new dataset to an existing track
   *
   * @param newTrack: The new track to be added.
   * @param position: Where the new series should be placed.
   *  (This could also be inferred from the hostTrack, but since
   *  we already have it, we might as well use it)
   * @param hostTrack: The track that will host the new series.
   */
  handleSeriesAdded(viewId, newTrack, position, hostTrack) {
    // is the host track a combined track?
    // if so, easy, just append the new track to its contents
    // if not, remove the current track from the track list
    // create a new combined track, add the current and the new
    // tracks and then update the whole track list
    const { tracks } = this.state.views[viewId];

    if (hostTrack.type === 'combined') {
      hostTrack.contents.push(newTrack);
      hostTrack.uid = slugid.nice();

      if (newTrack.type === 'heatmap') {
        // For stacked heatmaps we will adjust some options automatically for convenience
        this.compatibilityfyStackedHeatmaps(newTrack, hostTrack);
      }
    } else {
      const newHost = {
        type: 'combined',
        uid: slugid.nice(),
        height: hostTrack.height,
        width: hostTrack.width,
        contents: [hostTrack, newTrack],
      };

      const positionTracks = tracks[position];

      for (let i = 0; i < positionTracks.length; i++) {
        if (positionTracks[i].uid === hostTrack.uid) {
          positionTracks[i] = newHost;
        }
      }
    }

    this.setState((prevState) => ({
      views: prevState.views,
    }));

    this.triggerViewChangeDb();
  }

  handleNoTrackAdded() {
    if (this.state.addTrackPosition) {
      // we've already added the track, remove the add track dialog
      this.setState({
        addTrackPosition: null,
        addTrackExtent: null,
      });
    }
  }

  /**
   * Add multiple new tracks (likely from the AddTrackModal dialog)
   *
   * @param trackInfo: A JSON object that can be used as a track
   *                   definition
   * @param position: The position the track is being added to
   * @param host: If this track is being added to another track
   */
  handleTracksAdded(viewId, newTracks, position, extent, host) {
    this.storeTrackSizes(viewId);

    for (const newTrack of newTracks) {
      this.handleTrackAdded(viewId, newTrack, position, extent, host);
    }
  }

  /**
   * Change the type of a track. For example, convert a line to a bar track.
   *
   * Parameters
   * ----------
   *  viewUid: string
   *    The view containing the track to be changed
   *  trackUid: string
   *    The uid identifying the existin track
   *  newType: string
   *    The type to switch this track to.
   */
  handleChangeTrackType(viewUid, trackUid, newType) {
    const view = this.state.views[viewUid];
    const trackConfig = getTrackByUid(view.tracks, trackUid);

    // this track needs a new uid so that it will be rerendered
    const oldUid = trackConfig.uid;
    trackConfig.uid = slugid.nice();
    trackConfig.type = newType;
    const newUid = trackConfig.uid;

    this.updateTrackLocks(viewUid, oldUid, newUid);
    this.setState((prevState) => ({
      views: prevState.views,
    }));
  }

  /**
   * Change the data source for a track. E.g. when adding or
   * removing a divisor.
   *
   * Parameters
   * ----------
   *  viewUid: string
   *    The view containing the track to be changed
   *  trackUid: string
   *    The uid identifying the existin track
   *  newData: object
   *    The new data source section
   */
  handleChangeTrackData(viewUid, trackUid, newData) {
    const view = this.state.views[viewUid];
    const trackConfig = getTrackByUid(view.tracks, trackUid);

    // this track needs a new uid so that it will be rerendered
    trackConfig.uid = slugid.nice();
    trackConfig.data = newData;

    this.setState((prevState) => ({
      views: prevState.views,
    }));
  }

  /**
   * A track was added from the AddTrackModal dialog.
   *
   * @param trackInfo: A JSON object that can be used as a track
   *                   definition
   * @param position: The position the track is being added to
   * @param host: If this track is being added to another track
   *
   * @returns {Object}: A trackConfig (\{ uid: "", width: x \})
   *  describing this track
   */
  handleTrackAdded(viewId, newTrack, position, extent = null, host = null) {
    this.addDefaultTrackOptions(newTrack);

    // make sure the new track has a uid
    if (!newTrack.uid) newTrack.uid = slugid.nice();

    if (newTrack.contents) {
      // add default options to combined tracks
      for (const ct of newTrack.contents) {
        this.addDefaultTrackOptions(ct);
      }
    }

    if (this.state.addTrackPosition) {
      // we've already added the track, remove the add track dialog
      this.setState({
        addTrackPosition: null,
        addTrackExtent: null,
      });
    }

    if (host) {
      // we're adding a series rather than a whole new track
      this.handleSeriesAdded(viewId, newTrack, position, host);
      return null;
    }

    newTrack.position = position;
    if (extent) {
      newTrack.options.extent = extent;
    }
    const trackInfo = this.getTrackInfo(newTrack.type);

    newTrack.width =
      trackInfo.defaultWidth ||
      trackInfo.defaultOptions?.minWidth ||
      this.minVerticalWidth;
    newTrack.height =
      trackInfo.defaultHeight ||
      trackInfo.defaultOptions?.minHeight ||
      this.minHorizontalHeight;

    const { tracks } = this.state.views[viewId];

    let numTracks = 0;
    visitPositionedTracks(tracks, () => numTracks++);

    if (position === 'left' || position === 'top') {
      // if we're adding a track on the left or the top, we want the
      // new track to appear at the begginning of the track list
      tracks[position].unshift(newTrack);
    } else if (position === 'center') {
      // we're going to have to either overlay the existing track with a new one
      // or add another one on top
      if (tracks.center.length === 0) {
        // no existing tracks
        const newCombined = {
          uid: slugid.nice(),
          type: 'combined',
          contents: [newTrack],
        };
        tracks.center = [newCombined];
      } else if (tracks.center[0].type === 'combined') {
        // if it's a combined track, we just need to add this track to the
        // contents
        tracks.center[0].contents.push(newTrack);

        if (newTrack.type === 'heatmap') {
          // For stacked heatmaps we will adjust some options automatically for convenience
          this.compatibilityfyStackedHeatmaps(newTrack, tracks.center[0]);
        }
      } else {
        // if it's not, we have to create a new combined track
        const newCombined = {
          uid: slugid.nice(),
          type: 'combined',
          contents: [tracks.center[0], newTrack],
        };

        tracks.center = [newCombined];

        if (newTrack.type === 'heatmap') {
          this.compatibilityfyStackedHeatmaps(newTrack, newCombined);
        }
      }
    } else {
      // otherwise, we want it at the end of the track list
      if (!tracks[position]) {
        // this position wasn't defined in the original viewconf
        tracks[position] = [];
      }

      tracks[position].push(newTrack);
    }

    this.adjustLayoutToTrackSizes(this.state.views[viewId]);

    if (Object.keys(this.state.views).length === 1 && numTracks === 0) {
      // Zoom to data extent since this is the first track we added and we only
      // have one view

      // This doesn't work because the tilesetInfo is probably not there yet
      this.handleZoomToData(viewId);

      // This might work but sometimes `TiledPlot.handleTilesetInfoReceived`
      // isn't triggered. E.g., when adding `dm6` as `chromosome-labels`.
      this.zoomToDataExtentOnInit.add(viewId);
    }

    return newTrack;
  }

  /**
   * We're adding a new heatmap to a combined track. We need to make sure that
   * their options are compatible.
   *
   * @param   {object}  newTrack  New heatmap track
   * @param   {object}  combinedTrack  Combined track the new heatmap is added to
   */
  compatibilityfyStackedHeatmaps(newTrack, combinedTrack) {
    let otherHeatmap;

    const hasHeatmaps = combinedTrack.contents.some((track) => {
      otherHeatmap = track;
      return track.type === 'heatmap';
    });

    if (hasHeatmaps) {
      // There already exist a heatmap let's set the background of the new
      // heatmap to `transparent`
      newTrack.options.backgroundColor = 'transparent';
      newTrack.options.showTooltip = otherHeatmap.options.showTooltip;
      newTrack.options.showMousePosition =
        otherHeatmap.options.showMousePosition;
      newTrack.options.mousePositionColor =
        otherHeatmap.options.mousePositionColor;
    }
  }

  /**
   * Go through each track and store its size in the viewconf.
   *
   * This is so that sizes don't get lost when the view is unbounded
   * and new tracks are added.
   *
   * Parameters
   * ----------
   *
   *  viewId : string
   *      The id of the view whose tracks we're measuring
   *
   * Returns
   * -------
   *
   *  Nothing
   */
  storeTrackSizes(viewId) {
    visitPositionedTracks(this.state.views[viewId].tracks, (track) => {
      const trackObj = this.tiledPlots[viewId].trackRenderer.getTrackObject(
        track.uid,
      );

      if (trackObj) [track.width, track.height] = trackObj.dimensions;
    });
  }

  /*
   * Adjust the layout to match the size of the contained tracks. If tracks
   * are added, the layout size needs to expand. If they're removed, it needs
   * to contract.
   *
   * This function should be called from handleTrackAdded and handleCloseTrack.
   *
   * Parameters
   * ----------
   *
   *  view : {...}
   *      The definition from the viewconf
   */
  adjustLayoutToTrackSizes(view) {
    // if the view is too short, expand the view so that it fits this track
    if (!view.layout) return;

    const isEditable = this.isEditable();

    let totalTrackHeight = 0;

    // we are not checking for this.viewHeaders because this function may be
    // called before the component is mounted
    if (isEditable) totalTrackHeight += VIEW_HEADER_HEIGHT;

    // the tracks are larger than the height of the current view, so we need
    // to extend it
    const { totalHeight } = this.calculateViewDimensions(view);
    totalTrackHeight += totalHeight;

    const MARGIN_HEIGHT = this.isEditable() ? 10 : 0;

    totalTrackHeight += MARGIN_HEIGHT;
    const rowHeight = this.state.rowHeight + MARGIN_HEIGHT;

    if (
      this.sizeMode !== SIZE_MODE_BOUNDED &&
      this.sizeMode !== SIZE_MODE_BOUNDED_OVERFLOW
    ) {
      view.layout.h = Math.ceil(totalTrackHeight / rowHeight);
    }
  }

  handleCloseTrack(viewId, uid) {
    const { tracks } = this.state.views[viewId];

    this.handleUnlockValueScale(viewId, uid);

    for (const trackType in tracks) {
      const theseTracks = tracks[trackType];
      const newTracks = theseTracks.filter((d) => d.uid !== uid);

      if (newTracks.length === theseTracks.length) {
        // no whole tracks need to removed, see if any of the combined tracks
        // contain series which need to go
        const combinedTracks = newTracks.filter((x) => x.type === 'combined');

        combinedTracks.forEach((ct) => {
          ct.contents = ct.contents.filter((x) => x.uid !== uid);
        });
      } else {
        tracks[trackType] = newTracks;
      }
    }

    this.storeTrackSizes(viewId);
    this.adjustLayoutToTrackSizes(this.state.views[viewId]);

    this.setState((prevState) => ({
      views: prevState.views,
    }));

    return this.state.views;
  }

  handleLockValueScale(fromViewUid, fromTrackUid) {
    this.setState({
      chooseTrackHandler: (toViewUid, toTrackUid) =>
        this.handleValueScaleLocked(
          fromViewUid,
          fromTrackUid,
          toViewUid,
          toTrackUid,
        ),
    });
  }

  combineViewAndTrackUid(viewUid, trackUid) {
    // see if we've already created a uid for this view / track combo
    const uid = `${viewUid}.${trackUid}`;

    this.combinedUidToViewTrack[uid] = { view: viewUid, track: trackUid };

    if (this.viewTrackUidsToCombinedUid[viewUid]) {
      if (this.viewTrackUidsToCombinedUid[trackUid]) {
        return this.viewTrackUidsToCombinedUid[viewUid][trackUid];
      }

      this.viewTrackUidsToCombinedUid[viewUid][trackUid] = uid;
    } else {
      this.viewTrackUidsToCombinedUid[viewUid] = {};
      this.viewTrackUidsToCombinedUid[viewUid][trackUid] = uid;
    }

    return uid;
  }

  /**
   * Update all locks involving this track to use a new track uid.
   *
   * @param  {string} viewUid The view's uid
   * @param  {string} oldTrackUid The track's old uid
   * @param  {string} newTrackUid The track's new uid
   */
  updateTrackLocks(viewUid, oldTrackUid, newTrackUid) {
    // update location locks
    // update zoom locks
    // update value scale locks
    const oldLockGroupUid = this.combineViewAndTrackUid(viewUid, oldTrackUid);
    const newLockGroupUid = this.combineViewAndTrackUid(viewUid, newTrackUid);

    if (this.valueScaleLocks[oldLockGroupUid]) {
      const lockGroup = this.valueScaleLocks[oldLockGroupUid];
      this.valueScaleLocks[newLockGroupUid] = lockGroup;
      delete this.valueScaleLocks[oldLockGroupUid];
    }

    for (const lockGroupUid in this.valueScaleLocks) {
      if (this.valueScaleLocks[lockGroupUid][oldLockGroupUid]) {
        const oldEntry = this.valueScaleLocks[lockGroupUid][oldLockGroupUid];
        this.valueScaleLocks[lockGroupUid][newLockGroupUid] = oldEntry;
        oldEntry.track = newTrackUid;
        delete this.valueScaleLocks[lockGroupUid][oldLockGroupUid];
      }
    }
  }

  handleUnlockValueScale(viewUid, trackUid) {
    // if it's combined track, unlock each individual component
    if (
      this.tiledPlots[viewUid].trackRenderer.getTrackObject(trackUid)
        .createdTracks
    ) {
      // if the from view is a combined track, recurse and add links between its child tracks
      const childTrackUids = dictKeys(
        this.tiledPlots[viewUid].trackRenderer.getTrackObject(trackUid)
          .createdTracks,
      );
      for (const childTrackUid of childTrackUids) {
        this.handleUnlock(
          this.combineViewAndTrackUid(viewUid, childTrackUid),
          this.valueScaleLocks,
        );
      }
    } else {
      this.handleUnlock(
        this.combineViewAndTrackUid(viewUid, trackUid),
        this.valueScaleLocks,
      );
    }
  }

  handleValueScaleLocked(fromViewUid, fromTrackUid, toViewUid, toTrackUid) {
    if (
      this.tiledPlots[fromViewUid].trackRenderer.getTrackObject(fromTrackUid)
        .createdTracks
    ) {
      // if the from view is a combined track, recurse and add links between its child tracks
      const childTrackUids = dictKeys(
        this.tiledPlots[fromViewUid].trackRenderer.getTrackObject(fromTrackUid)
          .createdTracks,
      );
      for (const childTrackUid of childTrackUids) {
        this.handleValueScaleLocked(
          fromViewUid,
          childTrackUid,
          toViewUid,
          toTrackUid,
        );
      }

      return;
    }

    if (
      this.tiledPlots[toViewUid].trackRenderer.getTrackObject(toTrackUid)
        .createdTracks
    ) {
      // if the from view is a combined track, recurse and add links between its child tracks
      const childTrackUids = dictKeys(
        this.tiledPlots[toViewUid].trackRenderer.getTrackObject(toTrackUid)
          .createdTracks,
      );
      for (const childTrackUid of childTrackUids) {
        this.handleValueScaleLocked(
          fromViewUid,
          fromTrackUid,
          toViewUid,
          childTrackUid,
        );
      }

      return;
    }

    const fromUid = this.combineViewAndTrackUid(fromViewUid, fromTrackUid);
    const toUid = this.combineViewAndTrackUid(toViewUid, toTrackUid);

    this.addLock(
      fromUid,
      toUid,
      this.valueScaleLocks,
      (uid) => this.combinedUidToViewTrack[uid],
    );

    this.syncValueScales(fromViewUid, fromTrackUid);

    this.setState({
      chooseTrackHandler: null,
    });
  }

  /**
   * Add callbacks for functions that need them
   *
   * Done in place.
   *
   * @param {string} viewUid
   * @param {{
   *   type: string;
   *   uid: string;
   *   fromViewUid: string;
   *   registerViewportChanged: (string: trackId, listener: () => void) => void;
   *   removeViewportChanged: (string: trackId) => void;
   *   removeScalesChangedListener: (string: trackId) => void;
   * }} track A view with tracks.
   */
  addCallbacks(viewUid, track) {
    const trackInfo = this.getTrackInfo(track.type);
    if (
      track.type === 'viewport-projection-center' ||
      track.type === 'viewport-projection-horizontal' ||
      track.type === 'viewport-projection-vertical' ||
      trackInfo?.projection
    ) {
      const fromView = track.fromViewUid;

      track.registerViewportChanged = (trackId, listener) =>
        this.addScalesChangedListener(fromView, trackId, listener);
      track.removeViewportChanged = (trackId) =>
        this.removeScalesChangedListener(fromView, trackId);
      track.setDomainsCallback = (xDomain, yDomain) => {
        if (!fromView) {
          // If there is no `fromView`, then there must be a `projectionXDomain` instead.
          // Update the viewconfig to reflect the new `projectionXDomain` array
          // on the `viewport-projection-horizontal` track.
          if (!this.projectionXDomains[viewUid]) {
            this.projectionXDomains[viewUid] = {};
          }
          if (!this.projectionYDomains[viewUid]) {
            this.projectionYDomains[viewUid] = {};
          }
          if (
            track.type === 'viewport-projection-horizontal' ||
            track.type === 'viewport-projection-center'
          ) {
            this.projectionXDomains[viewUid][track.uid] = xDomain;
          }
          if (
            track.type === 'viewport-projection-vertical' ||
            track.type === 'viewport-projection-center'
          ) {
            this.projectionYDomains[viewUid][track.uid] = yDomain;
          }
          this.triggerViewChangeDb();
          // Return early, since the remaining code uses the `fromView` variable.
          return;
        }
        const tXScale = scaleLinear()
          .domain(xDomain)
          .range(this.xScales[fromView].range());
        const tYScale = scaleLinear()
          .domain(yDomain)
          .range(this.yScales[fromView].range());

        const [tx, ty, k] = scalesCenterAndK(tXScale, tYScale);
        this.setCenters[fromView](tx, ty, k, false);

        let zoomLocked = false;
        let locationLocked = false;

        // if we drag the brush and this view is locked to others, we don't
        // want the movement we induce in them to come back and modify this
        // view and set up a feedback loop
        if (viewUid in this.zoomLocks) {
          zoomLocked = fromView in this.zoomLocks[viewUid];
        }
        if (zoomLocked) {
          this.handleUnlock(viewUid, this.zoomLocks);
        }

        if (viewUid in this.locationLocks) {
          locationLocked = fromView in this.locationLocks[viewUid];
        }
        if (locationLocked) {
          this.handleUnlock(viewUid, this.locationLocks);
        }

        this.handleScalesChanged(fromView, tXScale, tYScale, true);

        if (zoomLocked) {
          this.addLock(
            viewUid,
            fromView,
            this.zoomLocks,
            this.viewScalesLockData,
          );
        }
        if (locationLocked) {
          this.addLock(
            viewUid,
            fromView,
            this.locationLocks,
            this.viewScalesLockData,
          );
        }
      };
    }
  }

  validateLocks(locks) {
    // locks are organized like this:
    // { v1: { v1: [0,0,0], v2: [1,1,1]}}
    // v1 marks the name of a lock group and also
    // corresponds to a view uid (a view cannot belong)
    // to more than one lock group. The numbers are the
    // zoom, xOffset, yOffset values that were present when
    // the view was created

    const toRemove = [];

    for (const viewUid of dictKeys(locks)) {
      if (!locks[viewUid] || !locks[viewUid][viewUid]) {
        // we need to have the starting location of the
        // lock's namesake view
        toRemove.push(viewUid);
      }
    }

    for (const viewUid of toRemove) {
      if (locks[viewUid]) {
        console.warn(`View ${viewUid} not present in lock group. Removing...`);
        this.handleUnlock(viewUid, locks);
        delete locks[viewUid];
      }
    }
  }

  deserializeLocationLocks(viewConfig) {
    this.locationLocks = {};

    if (viewConfig.locationLocks) {
      for (const viewUid of dictKeys(viewConfig.locationLocks.locksByViewUid)) {
        if (
          typeof viewConfig.locationLocks.locksByViewUid[viewUid] !== 'object'
        ) {
          this.locationLocks[viewUid] =
            viewConfig.locationLocks.locksDict[
              viewConfig.locationLocks.locksByViewUid[viewUid]
            ];
        } else {
          // This means we need to link x and y axes separately.

          // x-axis specific locks. The x-axis of this view is linked with an axis in another view.
          if ('x' in viewConfig.locationLocks.locksByViewUid[viewUid]) {
            const lockInfo =
              viewConfig.locationLocks.locksDict[
                viewConfig.locationLocks.locksByViewUid[viewUid].x.lock
              ];
            this.locationLocksAxisWise.x[viewUid] = {
              lock: lockInfo,
              axis: viewConfig.locationLocks.locksByViewUid[viewUid].x.axis, // The axis of another view, either 'x' or 'y'
            };
          }

          // y-axis specific locks. The y-axis of this view is linked with an axis in another view.
          if ('y' in viewConfig.locationLocks.locksByViewUid[viewUid]) {
            const lockInfo =
              viewConfig.locationLocks.locksDict[
                viewConfig.locationLocks.locksByViewUid[viewUid].y.lock
              ];
            this.locationLocksAxisWise.y[viewUid] = {
              lock: lockInfo,
              axis: viewConfig.locationLocks.locksByViewUid[viewUid].y.axis, // The axis of another view, either 'x' or 'y'
            };
          }
        }
      }
    }

    this.validateLocks(this.locationLocks);
  }

  deserializeZoomLocks(viewConfig) {
    this.zoomLocks = {};

    //
    if (viewConfig.zoomLocks) {
      for (const viewUid of dictKeys(viewConfig.zoomLocks.locksByViewUid)) {
        this.zoomLocks[viewUid] =
          viewConfig.zoomLocks.locksDict[
            viewConfig.zoomLocks.locksByViewUid[viewUid]
          ];
      }
    }

    this.validateLocks(this.zoomLocks);
  }

  deserializeValueScaleLocks(viewConfig) {
    this.valueScaleLocks = {};

    if (viewConfig.valueScaleLocks) {
      for (const viewUid of dictKeys(
        viewConfig.valueScaleLocks.locksByViewUid,
      )) {
        this.valueScaleLocks[viewUid] =
          viewConfig.valueScaleLocks.locksDict[
            viewConfig.valueScaleLocks.locksByViewUid[viewUid]
          ];
      }
    }
  }

  serializeLocks(locks) {
    const locksDict = {};
    const locksByViewUid = {};

    for (const viewUid of dictKeys(locks)) {
      let lockUid = locks[viewUid]?.uid;

      if (!lockUid) {
        // otherwise, assign this locationLock its own uid
        lockUid = slugid.nice();
      }
      locks[viewUid].uid = lockUid;

      // make a note that we've seen this lock
      locksDict[lockUid] = locks[viewUid];

      // note that this view has a reference to this lock
      locksByViewUid[viewUid] = locks[viewUid].uid;
    }

    // remove the uids we just added
    // for (let viewUid of dictKeys(locks)) {
    //   if (locks[viewUid].hasOwnProperty('uid')) {
    //     locks[viewUid].uid = undefined;
    //     delete locks[viewUid].uid;
    //   }
    // }

    return { locksByViewUid, locksDict };
  }

  getViewsAsJson(views) {
    const newJson = JSON.parse(JSON.stringify(this.state.viewConfig));
    newJson.views = Object.values(views || this.state.views).map((k) => {
      const newView = JSON.parse(JSON.stringify(k));

      visitPositionedTracks(newView.tracks, (track) => {
        if (track.server) {
          if (!URL.canParse(track.server)) {
            // Not a valid URL, so we'll extend the current one
            const url = new URL(window.location);
            url.pathname = track.server;
            track.server = url.href;
          }
        }

        if (
          (track.type === 'viewport-projection-center' ||
            track.type === 'viewport-projection-horizontal') &&
          this.projectionXDomains[k.uid] &&
          this.projectionXDomains[k.uid][track.uid]
        ) {
          // There is no "from" view attached to this projection track,
          // so the `projectionXDomain` field must be used.
          track.projectionXDomain = this.projectionXDomains[k.uid][track.uid];
        }

        if (
          (track.type === 'viewport-projection-center' ||
            track.type === 'viewport-projection-vertical') &&
          this.projectionYDomains[k.uid] &&
          this.projectionYDomains[k.uid][track.uid]
        ) {
          // There is no "from" view attached to this projection track,
          // so the `projectionYDomain` field must be used.
          track.projectionYDomain = this.projectionYDomains[k.uid][track.uid];
        }

        delete track.name;
        delete track.position;
        delete track.header;
        delete track.description;
        delete track.created;
        delete track.project;
        delete track.project_name;
        delete track.serverUidKey;
        delete track.uuid;
        delete track.private;
        delete track.maxZoom;
        delete track.coordSystem;
        delete track.coordSystem2;
        delete track.datatype;
        delete track.maxWidth;
        delete track.datafile;
        delete track.filetype;
        delete track.binsPerDimension;
        delete track.resolutions;
        delete track.row_infos;
        delete track.aggregationModes;
      });

      newView.uid = k.uid;
      newView.initialXDomain = this.xScales[k.uid].domain();
      newView.initialYDomain = this.yScales[k.uid].domain();

      delete newView.layout.i;

      return newView;
    });

    newJson.zoomLocks = this.serializeLocks(this.zoomLocks);
    newJson.locationLocks = this.serializeLocks(this.locationLocks);
    newJson.valueScaleLocks = this.serializeLocks(this.valueScaleLocks);

    return newJson;
  }

  getViewsAsString() {
    return JSON.stringify(this.getViewsAsJson(), null, 2);
  }

  handleExportViewAsJSON() {
    const data = this.getViewsAsString();
    // const file = new Blob([data], { type: 'text/json' });

    download('viewconf.json', data);
  }

  handleExportViewsAsLink(
    url = this.state.viewConfig.exportViewUrl,
    fromApi = false,
  ) {
    const parsedUrl = new URL(url, window.location.origin);

    const req = fetch(url, {
      method: 'POST',
      headers: {
        Accept: 'application/json, text/plain, */*',
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
      },
      body: `{"viewconf":${this.getViewsAsString()}}`,
      credentials: 'same-origin',
    })
      .then((response) => {
        if (!response.ok) {
          throw response;
        }
        return response.json();
      })
      .catch((err) => {
        console.warn('err:', err);
      })
      .then((_json) => ({
        id: _json.uid,
        url: `${parsedUrl.origin}/l/?d=${_json.uid}`,
      }));

    if (!fromApi) {
      req
        .then((sharedView) => {
          this.openModal(
            <ExportLinkDialog
              onDone={() => {
                this.closeModalBound();
              }}
              url={sharedView.url}
            />,
          );
        })
        .catch((e) =>
          console.error('Exporting view config as link failed:', e),
        );
    }

    return req;
  }

  /*
   * The initial[XY]Domain of a view has changed. Update its definition
   * and rerender.
   */
  handleDataDomainChanged(viewUid, newXDomain, newYDomain) {
    const { views } = this.state;

    views[viewUid].initialXDomain = newXDomain;
    views[viewUid].initialYDomain = newYDomain;

    this.xScales[viewUid] = scaleLinear().domain(newXDomain);
    this.yScales[viewUid] = scaleLinear().domain(newYDomain);

    if (this.zoomLocks[viewUid]) {
      const lockGroup = this.zoomLocks[viewUid];
      const lockGroupItems = dictItems(lockGroup);

      for (let i = 0; i < lockGroupItems.length; i++) {
        const key = lockGroupItems[i][0];

        if (!(key in this.locationLocks)) {
          // only zoom to extent if both zoom and location
          // are locked
          continue;
        }

        views[key].initialXDomain = newXDomain;
        views[key].initialYDomain = newYDomain;

        this.xScales[key] = scaleLinear().domain(newXDomain);
        this.yScales[key] = scaleLinear().domain(newYDomain);
      }
    }

    this.setState({ views });
  }

  /**
   * Check if we can place a view at this position
   */
  viewPositionAvailable(pX, pY, w, h) {
    const pEndX = pX + w;
    const pEndY = pY + h;

    if (pX + w > NUM_GRID_COLUMNS) {
      // this view will go over the right edge of our grid
      return false;
    }

    const sortedViews = dictValues(this.state.views);

    // check if this position
    for (let j = 0; j < sortedViews.length; j++) {
      const svX = sortedViews[j].layout.x;
      const svY = sortedViews[j].layout.y;

      const svEndX = svX + sortedViews[j].layout.w;
      const svEndY = svY + sortedViews[j].layout.h;

      if (pX < svEndX && pEndX > svX) {
        // x range intersects
        if (pY < svEndY && pEndY > svY) {
          // y range intersects
          return false;
        }
      }
    }

    return true;
  }

  /**
   * User clicked on the "Add View" button. We'll duplicate the last
   * view.
   */
  handleAddView(view, newViewParams, noUpdate) {
    const views = dictValues(this.state.views);
    const lastView = view;

    const potentialPositions = [];

    if (view.layout.w === 12) {
      // this view is full width, we can cut it in half (#259)
      view.layout.w = 6;
    }

    for (let i = 0; i < views.length; i++) {
      let pX = views[i].layout.x + views[i].layout.w;
      let pY = views[i].layout.y;

      // can we place the new view to the right of this view?
      if (this.viewPositionAvailable(pX, pY, view.layout.w, view.layout.h)) {
        potentialPositions.push([pX, pY]);
      }

      pX = views[i].layout.x;
      pY = views[i].layout.y + views[i].layout.h;
      // can we place the new view below this view
      if (this.viewPositionAvailable(pX, pY, view.layout.w, view.layout.h)) {
        potentialPositions.push([pX, pY]);
      }
    }

    potentialPositions.sort((a, b) => {
      const n = a[1] - b[1];

      if (n === 0) {
        return a[0] - b[0];
      }

      return n;
    });

    const jsonString = JSON.stringify(lastView);

    let newView = JSON.parse(jsonString); // ghetto copy

    newView.initialXDomain = this.xScales[newView.uid].domain();
    newView.initialYDomain = this.yScales[newView.uid].domain();

    if (newViewParams) {
      newView = { ...newView, ...newViewParams };
    }
    // place this new view below all the others

    [[newView.layout.x, newView.layout.y]] = potentialPositions;

    // give it its own unique id
    newView.uid = slugid.nice();
    newView.layout.i = newView.uid;

    this.xScales[newView.uid] = scaleLinear().domain(newView.initialXDomain);
    this.yScales[newView.uid] = scaleLinear().domain(newView.initialYDomain);

    visitPositionedTracks(newView.tracks, (track) => {
      this.addCallbacks(newView.uid, track);
    });

    const createNewViews = (prevViews) => {
      const _views = JSON.parse(JSON.stringify(prevViews));
      _views[newView.uid] = newView;

      return _views;
    };

    let newViews = null;

    if (!noUpdate) {
      this.setState((prevState) => {
        newViews = createNewViews(prevState.views);

        return {
          views: newViews,
        };
      });
    } else {
      newViews = createNewViews(this.state.views);
    }

    return {
      newViewUid: newView.uid,
      newViewconf: this.getViewsAsJson(newViews),
    };
  }

  createGenomePostionSearchBoxEntry(
    existingGenomePositionSearchBox,
    suggestedAssembly,
  ) {
    /*
     * Create genomePositionSearchBox settings. If existing settings for this view exist,
     * then use those. Otherwise use defaults.
     *
     * Arguments:
     *     existingGenomePositionSearchBox:
     *          {
     *              autocompleteServer: string (e.g. higlass.io/api/v1),
     *              autocompleteId: string (e.g. Xz1f)
     *              chromInfoServer: string (e.g. higlass.io/api/v1)
     *              chromInfoId: string (e.g. hg19)
     *              visible: boolean (e.g. true)
     *           }
     *          If there's already information about which assembly and autocomplete
     *          source to use, it should be in this format.
     *
     *      suggestedAssembly:
     *          Guess which assembly should be displayed based on the tracks visible.
     *          In all meaningful scenarios, all tracks should be of the same assembly
     *          but in case they're not, suggest the most common one
     *
     * Return:
     *      A valid genomePositionSearchBox object
     *
     */
    let newGpsb = existingGenomePositionSearchBox;
    const defaultGpsb = {
      autocompleteServer: DEFAULT_SERVER,
      // "autocompleteId": "OHJakQICQD6gTD7skx4EWA",
      chromInfoServer: DEFAULT_SERVER,
      visible: false,
    };

    if (!newGpsb) {
      newGpsb = JSON.parse(JSON.stringify(defaultGpsb));
    }

    if (!newGpsb.autocompleteServer) {
      newGpsb.autocompleteServer = defaultGpsb.autocompleteServer;
    }

    /*
     * If we don't have an autocompleteId, we'll try to look it up in
     * the autocomplete server
     */
    /*
        if (!newGpsb.autocompleteId)
            newGpsb.autocompleteId = defaultGpsb.autocompleteId;
        */

    if (!newGpsb.chromInfoId) {
      newGpsb.chromInfoId = suggestedAssembly;
    }

    if (!newGpsb.chromInfoServer) {
      newGpsb.chromInfoServer = defaultGpsb.chromInfoServer;
    }

    if (!newGpsb.visible) {
      newGpsb.visible = false;
    }

    return newGpsb;
  }

  handleTogglePositionSearchBox(viewUid) {
    /*
     * Show or hide the genome position search box for a given view
     */

    const view = this.state.views[viewUid];
    view.genomePositionSearchBoxVisible = !view.genomePositionSearchBoxVisible;

    // count the number of tracks that are part of some assembly
    const assemblyCounts = {};

    visitPositionedTracks(view.tracks, (track) => {
      if (track.coordSystem) {
        if (!assemblyCounts[track.coordSystem]) {
          assemblyCounts[track.coordSystem] = 0;
        }

        assemblyCounts[track.coordSystem] += 1;
      }
    });

    const sortedAssemblyCounts = dictItems(assemblyCounts).sort(
      (a, b) => b[1] - a[1],
    );
    let selectedAssembly = 'hg19'; // always the default if nothing is otherwise selected

    if (sortedAssemblyCounts.length) {
      selectedAssembly = sortedAssemblyCounts[0][0];
    }

    view.genomePositionSearchBox = this.createGenomePostionSearchBoxEntry(
      view.genomePositionSearchBox,
      selectedAssembly,
    );
    view.genomePositionSearchBox.visible =
      !view.genomePositionSearchBox.visible;

    this.refreshView();

    this.setState({
      configMenuUid: null,
    });
  }

  handleTrackOptionsChanged(viewUid, trackUid, newOptions) {
    // some track's options changed...
    // redraw the track  and store the changes in the config file
    const view = this.state.views[viewUid];
    const track = getTrackByUid(view.tracks, trackUid);

    if (!track) return;

    track.options = Object.assign(track.options, newOptions);

    if (this.mounted) {
      this.setState((prevState) => ({
        views: prevState.views,
      }));
      this.adjustSplitHeatmapTrackOptions(
        track,
        newOptions,
        view.tracks,
        viewUid,
      );
    }
  }

  /**
   * For convenience we adjust some options of split heatmap tracks when they are newly added.
   * This function has no effect when we get split heatmap tracks that are already correctly configured
   * (i.e. correctly set "lower-extend"/"upper-extend" options)
   * @param   {object}  track  Track whose options have changed
   * @param   {object}  options  New track options
   * @param   {list}  allTracks  All tracks
   * @param   {string}  viewUid  Related view UID
   */
  adjustSplitHeatmapTrackOptions(track, options, allTracks, viewUid) {
    if (track.type === 'heatmap') {
      if (
        options.extent === 'upper-right' &&
        allTracks.center[0].type === 'combined' &&
        allTracks.center[0].contents.length > 1
      ) {
        allTracks.center[0].contents.some((otherTrack) => {
          if (
            otherTrack.type === 'heatmap' &&
            otherTrack.uid !== track.uid &&
            otherTrack.options.extent === 'full'
          ) {
            // Automatically change the extent of the other track to
            // `lower-left``
            const otherNewOptions = {
              ...otherTrack.options,
              extent: 'lower-left',
              labelPosition: 'bottomLeft',
              colorbarPosition: 'bottomLeft',
            };

            // Automatically set positions of label and colorbar of the current track
            // to the opposite corner. We don't want overlapping labels.
            const originalNewOptions = {
              ...options,
              labelPosition: 'topRight',
              colorbarPosition: 'topRight',
            };

            this.handleTrackOptionsChanged(
              viewUid,
              otherTrack.uid,
              otherNewOptions,
            );

            this.handleTrackOptionsChanged(
              viewUid,
              track.uid,
              originalNewOptions,
            );
            return true;
          }
          return false;
        });
      }
      if (options.extent === 'lower-left') {
        if (
          options.extent === 'lower-left' &&
          allTracks.center[0].type === 'combined' &&
          allTracks.center[0].contents.length > 1
        ) {
          allTracks.center[0].contents.some((otherTrack) => {
            if (
              otherTrack.type === 'heatmap' &&
              otherTrack.uid !== track.uid &&
              otherTrack.options.extent === 'full'
            ) {
              // Automatically change the extent of the other track to
              // `upper-right``
              const otherNewOptions = {
                ...otherTrack.options,
                extent: 'upper-right',
                labelPosition: 'topRight',
                colorbarPosition: 'topRight',
              };

              // Automatically set positions of label and colorbar of the current track
              // to the opposite corner. We don't want overlapping labels.
              const originalNewOptions = {
                ...options,
                labelPosition: 'bottomLeft',
                colorbarPosition: 'bottomLeft',
              };

              this.handleTrackOptionsChanged(
                viewUid,
                otherTrack.uid,
                otherNewOptions,
              );

              this.handleTrackOptionsChanged(
                viewUid,
                track.uid,
                originalNewOptions,
              );
              return true;
            }
            return false;
          });
        }
      }
    }
  }

  handleViewOptionsChanged(viewUid, newOptions) {
    const view = this.state.views[viewUid];

    if (!view) {
      return;
    }

    view.options = Object.assign(view.options || {}, newOptions);

    if (this.mounted) {
      this.setState((prevState) => ({
        views: prevState.views,
      }));
    }
  }

  /**
   * Determine whether a track is valid and can be displayed.
   *
   * Tracks can be invalid due to inconsistent input such as
   * referral to views that don't exist
   *
   * @param track (object): A track definition
   * @param viewUidsPresent (Set): The view uids which are available
   */
  isTrackValid(track, viewUidsPresent) {
    if (track.type === 'viewport-projection-center') {
      if (!viewUidsPresent.has(track.fromViewUid)) {
        return false;
      }
    }

    return true;
  }

  /**
   * Remove tracks which can no longer be shown (possibly because the views they
   * refer to no longer exist
   */
  removeInvalidTracks(viewsByUid) {
    const viewUidsSet = new Set(dictKeys(viewsByUid));

    for (const v of dictValues(viewsByUid)) {
      for (const trackOrientation of [
        'left',
        'top',
        'center',
        'right',
        'bottom',
      ]) {
        if (trackOrientation in (v.tracks ?? {})) {
          // filter out invalid tracks
          v.tracks[trackOrientation] = v.tracks[trackOrientation].filter((t) =>
            this.isTrackValid(t, viewUidsSet),
          );

          // filter out invalid tracks in combined tracks
          v.tracks[trackOrientation].forEach((t) => {
            if (t.type === 'combined') {
              t.contents = t.contents.filter((c) =>
                this.isTrackValid(c, viewUidsSet),
              );
            }
          });
        }
      }
    }

    return viewsByUid;
  }

  processViewConfig(viewConfig) {
    let { views } = viewConfig;
    let viewsByUid = {};
    let maxHeight = 1;

    if (!viewConfig.views || viewConfig.views.length === 0) {
      console.warn('No views provided in viewConfig');
      views = [
        {
          editable: true,
          tracks: {},
        },
      ];
    }

    views.forEach((v) => {
      if (v.tracks) {
        fillInMinWidths(v.tracks);
      }

      // if a view doesn't have a uid, assign it one
      if (!v.uid) {
        v.uid = slugid.nice();
      }

      viewsByUid[v.uid] = v;

      if (this.zoomToDataExtentOnInit.has(v.uid)) {
        this.zoomToDataExtentOnInit.delete(v.uid);
      }

      if (!v.initialXDomain) {
        v.initialXDomain = [0, 100];

        this.zoomToDataExtentOnInit.add(v.uid);
      } else {
        v.initialXDomain[0] = +v.initialXDomain[0];
        v.initialXDomain[1] = +v.initialXDomain[1];
      }

      // if there's no y domain specified just use the x domain instead
      // effectively centers the view on the diagonal
      if (!v.initialYDomain) {
        v.initialYDomain = [v.initialXDomain[0], v.initialXDomain[1]];
      } else {
        v.initialXDomain[0] = +v.initialXDomain[0];
        v.initialXDomain[1] = +v.initialXDomain[1];
      }

      if (!this.xScales[v.uid]) {
        this.xScales[v.uid] = scaleLinear().domain(v.initialXDomain);
      }
      if (!this.yScales[v.uid]) {
        this.yScales[v.uid] = scaleLinear().domain(v.initialYDomain);
      }

      visitPositionedTracks(v.tracks, (track) => {
        if (this.state?.views?.[v.uid]) {
          // This whole song and dance is to ensure that if the higlass
          // viewer is reinstantiated all of the tileset info meta that
          // is stored with the track gets passed on.
          // Necessary to make sure that the "Zoom Limit" option still
          // functions.
          const existingTrack = getTrackByUid(
            this.state.views[v.uid].tracks,
            track.uid,
          );

          if (existingTrack) {
            track.maxZoom = existingTrack.maxZoom;
            track.resolutions = existingTrack.resolutions;
            track.binsPerDimension = existingTrack.binsPerDimension;
            track.maxWidth = existingTrack.maxWidth;
          }
        }

        if (!track.uid) track.uid = slugid.nice();

        this.addCallbacks(v.uid, track);
        this.addDefaultTrackOptions(track);

        if (track.contents) {
          // add default options to combined tracks
          for (const ct of track.contents) this.addDefaultTrackOptions(ct);
        }
      });

      // make sure that the layout for this view refers to this view
      if (v.layout) {
        v.layout.i = v.uid;
      } else {
        v.layout = this.generateViewLayout(v);
      }

      maxHeight = Math.max(maxHeight, v.layout.y + v.layout.h);
    });

    // we want to scale up the height of each view so that the maxHeight
    // is closer to 12
    const TARGET_MAX_HEIGHT = 12;
    const multiplier = Math.ceil(TARGET_MAX_HEIGHT / maxHeight);

    views.forEach((v) => {
      v.layout.h *= multiplier;
      v.layout.y *= multiplier;
    });

    this.deserializeZoomLocks(viewConfig);
    this.deserializeLocationLocks(viewConfig);
    this.deserializeValueScaleLocks(viewConfig);

    viewsByUid = this.removeInvalidTracks(viewsByUid);

    return viewsByUid;
  }

  handleWindowFocused() {
    /*
     * The window housing this view gained focus. That means the bounding boxes
     * may have changed so we need to redraw everything.
     *
     */
  }

  /**
   * Handle range selection events.
   *
   * @description
   * Store active range selectio and forward the range selection event to the
   * API.
   *
   * @param  {Array}  range  Double array of the selected range.
   */
  rangeSelectionHandler(range) {
    this.rangeSelection = range;
    this.apiPublish('rangeSelection', range);
  }

  offViewChange(callback) {
    const index = this.viewChangeListener.indexOf(callback);

    if (index > -1) {
      this.viewChangeListener.splice(index, 1);
    }
  }

  onViewChange(callback) {
    return this.viewChangeListener.push(callback) - 1;
  }

  triggerViewChange() {
    if (!this.mounted) return;
    const viewsString = this.getViewsAsString();
    this.viewChangeListener.forEach((callback) => callback(viewsString));
  }

  getGenomeLocation(viewId) {
    return chromInfo
      .get(this.state.views[viewId].chromInfoPath)
      .then((chrInfo) =>
        scalesToGenomeLoci(this.xScales[viewId], this.yScales[viewId], chrInfo),
      );
  }

  offLocationChange(viewId, listenerId) {
    this.removeScalesChangedListener(viewId, listenerId);
  }

  zoomTo(viewUid, start1Abs, end1Abs, start2Abs, end2Abs, animateTime) {
    if (!(viewUid in this.setCenters)) {
      throw Error(
        `Invalid viewUid. Current uuids: ${Object.keys(this.setCenters).join(
          ',',
        )}`,
      );
    }

    if (Number.isNaN(+start1Abs) || Number.isNaN(+end1Abs)) {
      const coords = [start1Abs, end1Abs].join(', ');
      console.warn(
        [
          `Invalid coordinates (${coords}). All coordinates need to be numbers
        and should represent absolute coordinates (not chromosome
        coordinates).`,
        ].join(' '),
      );
      return;
    }

    if (
      Number.isNaN(+start2Abs) ||
      Number.isNaN(+end2Abs) ||
      start2Abs === null ||
      end2Abs === null
    ) {
      start2Abs = start1Abs;
      end2Abs = end1Abs;
    }

    const [centerX, centerY, k] = scalesCenterAndK(
      this.xScales[viewUid].copy().domain([start1Abs, end1Abs]),
      this.yScales[viewUid].copy().domain([start2Abs, end2Abs]),
    );

    this.setCenters[viewUid](centerX, centerY, k, false, animateTime);
  }

  zoomToGene(viewUid, geneName, padding, animateTime) {
    if (!(viewUid in this.setCenters)) {
      throw Error(
        `Invalid viewUid. Current uuids: ${Object.keys(this.setCenters).join(
          ',',
        )}`,
      );
    }

    if (
      !this.state.views[viewUid].genomePositionSearchBox ||
      !this.state.views[viewUid].genomePositionSearchBox.autocompleteServer ||
      !this.state.views[viewUid].genomePositionSearchBox.autocompleteId ||
      !this.state.views[viewUid].chromInfoPath
    ) {
      console.warn(
        'Please set chromInfoPath, autocompleteServer, and autocompleteId to use the zoomToGene API',
      );
      return;
    }

    this.suggestGene(viewUid, geneName, (suggestions) => {
      if (suggestions) {
        // extract the position of exact match
        const exactMatch = suggestions.find(
          (d) => d.geneName.toLowerCase() === geneName.toLowerCase(),
        );

        if (exactMatch) {
          const { chr, txStart, txEnd } = exactMatch;

          // extract absolute positions
          ChromosomeInfo(
            this.state.views[viewUid].chromInfoPath,
            (loadedChromInfo) => {
              // using the absolution positions, zoom to the position near a gene
              const startAbs =
                loadedChromInfo.chrToAbs([chr, txStart]) - padding;
              const endAbs = loadedChromInfo.chrToAbs([chr, txEnd]) + padding;

              const [centerX, centerY, k] = scalesCenterAndK(
                this.xScales[viewUid].copy().domain([startAbs, endAbs]),
                this.yScales[viewUid].copy().domain([startAbs, endAbs]),
              );

              this.setCenters[viewUid](centerX, centerY, k, false, animateTime);
            },
            this.pubSub,
          );
        } else {
          console.warn(`Couldn't find the gene symbol: ${geneName}`);
        }
      }
    });
  }

  suggestGene(viewUid, keyword, callback) {
    if (!(viewUid in this.setCenters)) {
      throw Error(
        `Invalid viewUid. Current uuids: ${Object.keys(this.setCenters).join(
          ',',
        )}`,
      );
    }

    if (
      !this.state.views[viewUid].genomePositionSearchBox ||
      !this.state.views[viewUid].genomePositionSearchBox.autocompleteServer ||
      !this.state.views[viewUid].genomePositionSearchBox.autocompleteId
    ) {
      console.warn(
        'Please set autocompleteServer and autocompleteId to use the suggestGene API',
      );
      return;
    }

    const autocompleteServer =
      this.state.views[viewUid].genomePositionSearchBox.autocompleteServer;
    const autocompleteId =
      this.state.views[viewUid].genomePositionSearchBox.autocompleteId;

    const url = `${autocompleteServer}/suggest/?d=${autocompleteId}&ac=${keyword.toLowerCase()}`;

    tileProxy
      .json(url, toVoid, this.pubSub)
      .then((suggestions) => {
        callback(suggestions);
      })
      .catch((error) => console.error(error));
  }

  onLocationChange(viewId, callback, callbackId) {
    const viewsIds = Object.keys(this.state.views);

    if (!viewsIds.length) {
      // HiGlass was probably initialized with an URL instead of a viewconfig
      // and that remote viewConfig is not yet loaded.
      this.unsetOnLocationChange.push({
        viewId,
        callback,
        callbackId,
      });
      return null;
    }

    viewId =
      typeof viewId === 'undefined' && viewsIds.length === 1
        ? viewsIds[0]
        : viewId;

    if (typeof viewId === 'undefined' || viewsIds.indexOf(viewId) === -1) {
      console.error(
        'onLocationChange either missing a viewId or passed an invalid viewId: ',
        viewId,
      );
      return null;
    }

    const view = this.state.views[viewId];

    // Convert scales into genomic locations
    const middleLayerListener = (xScale, yScale) => {
      callback({
        xDomain: xScale.domain(),
        yDomain: yScale.domain(),
        xRange: xScale.range(),
        yRange: yScale.range(),
      });
    };

    let newListenerId = 1;
    if (this.scalesChangedListeners[view.uid]) {
      newListenerId =
        Object.keys(this.scalesChangedListeners[view.uid])
          .filter(
            (listenerId) => listenerId.indexOf(LOCATION_LISTENER_PREFIX) === 0,
          )
          .map((listenerId) =>
            Number.parseInt(
              listenerId.slice(LOCATION_LISTENER_PREFIX.length + 1),
              10,
            ),
          )
          .reduce((max, value) => Math.max(max, value), 0) + 1;
    }

    this.addScalesChangedListener(
      view.uid,
      `${LOCATION_LISTENER_PREFIX}.${newListenerId}`,
      middleLayerListener,
    );

    if (callbackId) {
      callbackId(`${LOCATION_LISTENER_PREFIX}.${newListenerId}`);
    }

    return newListenerId;
  }

  /**
   * List all the views that are at the given position view position
   */
  getTiledPlotAtPosition(x, y) {
    let foundTiledPlot;

    const views = dictValues(this.state.views);

    for (let i = 0; i < views.length; i++) {
      const tiledPlot = this.tiledPlots[views[i].uid];

      const area = this.tiledAreasDivs[views[i].uid].getBoundingClientRect();

      const { top, left } = area;
      const bottom = top + area.height;
      const right = left + area.width;

      const withinX = x >= left && x <= right;
      const withinY = y >= top && y <= bottom;

      if (withinX && withinY) {
        foundTiledPlot = tiledPlot;
        break;
      }
    }

    return foundTiledPlot;
  }

  /**
   * Handle mousemove events by republishing the event using pubSub.
   *
   * @param {object}  e  Event object.
   */
  mouseMoveHandler(e) {
    if (!this.topDivRef.current || this.state.modal) return;

    const absX = e.clientX;
    const absY = e.clientY;
    const relPos = pointer(e, this.topDivRef.current);
    // We need to add the scrollTop
    relPos[1] += this.scrollTop;
    const hoveredTiledPlot = this.getTiledPlotAtPosition(absX, absY);

    const hoveredTracks = hoveredTiledPlot
      ? hoveredTiledPlot
          .listTracksAtPosition(relPos[0], relPos[1], true)
          .map((track) => track.originalTrack || track)
      : [];

    const hoveredTrack = hoveredTracks.find(
      (track) => !track.isAugmentationTrack,
    );

    const relTrackPos = hoveredTrack
      ? [
          relPos[0] - hoveredTrack.position[0],
          relPos[1] - hoveredTrack.position[1],
        ]
      : relPos;

    let dataX = -1;
    let dataY = -1;

    if (hoveredTrack) {
      dataX = !hoveredTrack.flipText
        ? hoveredTrack._xScale.invert(relTrackPos[0]) // dataX
        : hoveredTrack._xScale.invert(relTrackPos[1]); // dataY

      dataY = hoveredTrack.is2d
        ? hoveredTrack._yScale.invert(relTrackPos[1])
        : dataX;
    }

    const evt = {
      x: relPos[0],
      y: relPos[1],
      relTrackX: hoveredTrack?.flipText ? relTrackPos[1] : relTrackPos[0],
      relTrackY: hoveredTrack?.flipText ? relTrackPos[0] : relTrackPos[1],
      dataX,
      dataY,
      // See below why we need these derived boolean values
      isFrom2dTrack: !!hoveredTrack?.is2d,
      isFromVerticalTrack: !!hoveredTrack?.flipText,
      track: hoveredTrack,
      origEvt: e,
      sourceUid: this.uid,
      hoveredTracks,
      // See below why we need these derived boolean values
      noHoveredTracks: hoveredTracks.length === 0,
    };

    this.pubSub.publish('app.mouseMove', evt);

    if (this.isBroadcastMousePositionGlobally) {
      // In order to broadcast information globally with the
      // Broadcast Channel API we have to remove properties that reference local
      // objects as those can't be cloned and broadcasted to another context
      // (i.e., another browser window or tab).
      // This is also the reason why created some derived boolean variables,
      // like `noHoveredTracks`.
      const eventDataOnly = { ...evt };
      eventDataOnly.origEvt = undefined;
      eventDataOnly.track = undefined;
      eventDataOnly.hoveredTracks = undefined;
      delete eventDataOnly.origEvt;
      delete eventDataOnly.track;
      delete eventDataOnly.hoveredTracks;
      globalPubSub.publish('higlass.mouseMove', eventDataOnly);
    }

    this.apiPublish('cursorLocation', {
      absX,
      absY,
      relX: evt.x,
      relY: evt.y,
      relTrackX: evt.relTrackX,
      relTrackY: evt.relTrackY,
      dataX: evt.dataX,
      dataY: evt.dataY,
      isFrom2dTrack: evt.isFrom2dTrack,
      isFromVerticalTrack: evt.isFromVerticalTrack,
    });

    this.showHoverMenu(evt);
    this.showHoverUids(evt);
  }

  getMinMaxValue(viewId, trackId, ignoreOffScreenValues, ignoreFixedScale) {
    const track = getTrackObjById(this.tiledPlots, viewId, trackId);

    if (!track) {
      console.warn(`Track with ID: ${trackId} not found!`);
      return undefined;
    }

    if (!track.minVisibleValueInTiles || !track.maxVisibleValueInTiles) {
      console.warn(
        `Track ${trackId} doesn't support the retrieval of min or max values.`,
      );
      return undefined;
    }

    if (ignoreOffScreenValues && track.getAggregatedVisibleValue) {
      return [
        track.getAggregatedVisibleValue('min'),
        track.getAggregatedVisibleValue('max'),
      ];
    }

    return [
      track.minVisibleValueInTiles(ignoreFixedScale),
      track.maxVisibleValueInTiles(ignoreFixedScale),
    ];
  }

  /*
   * Someone has hovered over an item in sourceTrack so we need to inform
   * any other track that has that item that it's been hovered over.
   */
  showHoverUids(evt) {
    const mouseOverUids = evt.track?.getMouseOverUids
      ? evt.track.getMouseOverUids(evt.relTrackX, evt.relTrackY)
      : null;

    for (const track of this.iterateOverTracks()) {
      const trackObj = getTrackObjById(
        this.tiledPlots,
        track.viewId,
        track.trackId,
      );

      if (trackObj.itemsHovered) {
        trackObj.itemsHovered(mouseOverUids);
      }
    }
  }

  /**
   * Show a menu displaying some information about the track under it
   */
  showHoverMenu(evt) {
    // each track should have a function that returns an HTML representation
    // of the data at a give position
    const mouseOverHtml = evt.track?.getMouseOverHtml
      ? evt.track.getMouseOverHtml(evt.relTrackX, evt.relTrackY)
      : '';

    if (evt.track !== this.prevMouseHoverTrack) {
      if (this.prevMouseHoverTrack?.stopHover) {
        this.prevMouseHoverTrack.stopHover();
      }
    }

    this.prevMouseHoverTrack = evt.track;

    if (this.zooming) return;

    const data = mouseOverHtml?.length ? [1] : [];

    // try to select the mouseover div
    let mouseOverDiv = select('body')
      .selectAll('.track-mouseover-menu')
      .data(data);

    mouseOverDiv.exit().remove();

    mouseOverDiv
      .enter()
      .append('div')
      .classed('track-mouseover-menu', true)
      .classed(styles['track-mouseover-menu'], true);

    mouseOverDiv = select('body').selectAll('.track-mouseover-menu');
    const mousePos = pointer(evt.origEvt, select('body').node());
    const normalizedMousePos = [
      mousePos[0] - window.scrollX,
      mousePos[1] - window.scrollY,
    ];

    /*
    mouseOverDiv.selectAll('.mouseover-marker')
      .data([1])
      .enter()
      .append('div')
      .classed('.mouseover-marker', true)
    */

    mouseOverDiv
      .style('left', `${normalizedMousePos[0]}px`)
      .style('top', `${normalizedMousePos[1]}px`);

    // probably not over a track so there's no mouseover rectangle
    if (!mouseOverDiv.node()) return;

    const bbox = mouseOverDiv.node().getBoundingClientRect();

    if (bbox.x + bbox.width > window.innerWidth) {
      // the overlay box is spilling outside of the track so switch
      // to showing it on the left
      mouseOverDiv.style('left', `${normalizedMousePos[0] - bbox.width}px`);
    }

    if (bbox.y + bbox.height > window.innerHeight) {
      // the overlay box is spilling outside of the track so switch
      // to showing it on the left
      mouseOverDiv.style('top', `${normalizedMousePos[1] - bbox.height}px`);
    }

    mouseOverDiv.html(mouseOverHtml);
  }

  /**
   * Hide the hover menu when e.g. the user starts zooming
   */
  hideHoverMenu() {
    select('body').selectAll('.track-mouseover-menu').remove();
  }

  /**
   * Close the custom dialog  (e.g. when the close button is clicked)
   */
  closeCustomDialog() {
    this.setState((prevState) => ({
      customDialog: null,
    }));
  }

  /**
   * Handle internally broadcasted click events
   */
  appClickHandler(data) {
    this.apiPublish('click', data);
  }

  /** Handle click events on the canvas. The canvas is preferrable
   * to the top level div because the canvas's events aren't forwarded
   * so we only receive one click event
   */
  canvasClickHandler(evt) {
    const nativeEvent = evt.nativeEvent || evt;
    const absX = nativeEvent.clientX;
    const absY = nativeEvent.clientY;

    const hoveredTiledPlot = this.getTiledPlotAtPosition(absX, absY);

    const relPos = pointer(nativeEvent, this.topDivRef.current);
    relPos[1] += this.scrollTop;

    const hoveredTracks = hoveredTiledPlot
      ? hoveredTiledPlot
          .listTracksAtPosition(relPos[0], relPos[1], true)
          .map((track) => track.originalTrack || track)
      : [];

    const hoveredTrack = hoveredTracks.find(
      (track) => !track.isAugmentationTrack,
    );

    // Get the position of the click event relative to the
    // the track. This is passed on to the track's click handler
    // so that the track can handle it and potentially return
    // any additional information (i.e. annotations under the cursor)
    const relTrackPos = hoveredTrack
      ? [
          relPos[0] - hoveredTrack.position[0],
          relPos[1] - hoveredTrack.position[1],
        ]
      : relPos;

    const relTrackX = hoveredTrack?.flipText ? relTrackPos[1] : relTrackPos[0];
    const relTrackY = hoveredTrack?.flipText ? relTrackPos[0] : relTrackPos[1];

    for (const track of this.iterateOverTracks()) {
      const trackObj = getTrackObjById(
        this.tiledPlots,
        track.viewId,
        track.trackId,
      );

      if (!trackObj.respondsToPosition(relPos[0], relPos[1])) {
        // Some tracks may want to click event even if it's outside
        // of their bounds (an annotation track may want to deselect
        // an annotation, for example)
        trackObj.clickOutside();
      }
    }

    const clickReturns = [];

    for (const track of hoveredTracks) {
      if (track.childTracks) {
        // This is a combined track so add events for all child
        // tracks
        for (const subtrack of track.childTracks) {
          clickReturns.push({
            trackUid: subtrack.context.trackUid,
            viewUid: subtrack.context.viewUid,
            trackType: subtrack.context.trackType,
            data: subtrack.click(relTrackX, relTrackY, evt),
          });
        }

        // Add an event for the combined track itself
        clickReturns.push({
          trackUid: track.context.trackUid,
          viewUid: track.context.viewUid,
          trackType: track.context.trackType,
          data: {
            type: 'generic',
            event: evt,
          },
        });
      } else {
        // Not a combined track so just add an event for
        // this track
        clickReturns.push({
          trackUid: track.context.trackUid,
          viewUid: track.context.viewUid,
          trackType: track.context.trackType,
          data: track.click(relTrackX, relTrackY, evt),
        });
      }
    }

    // Go through clicked tracks and check if we need to display a dialog
    const customDialog = [];
    clickReturns.forEach((clickReturn, i) => {
      const trackObj = getTrackObjById(
        this.tiledPlots,
        clickReturn.viewUid,
        clickReturn.trackUid,
      );
      if (typeof trackObj.clickDialog === 'function') {
        const dialogData = trackObj.clickDialog();
        if (dialogData) {
          customDialog.push(dialogData);
        }
      }
    });
    // We don't want to rerender everytime the canvas is clicked.
    if (customDialog.length > 0) {
      this.setState((prevState) => ({
        customDialog,
      }));
    }

    this.pubSub.publish('app.click', clickReturns);
  }

  /**
   * Handle mousemove and zoom events.
   */
  mouseMoveZoomHandler(data) {
    this.apiPublish('mouseMoveZoom', data);
  }

  /**
   * Handle gene search events.
   */
  geneSearchHandler(data) {
    this.apiPublish('geneSearch', data);
  }

  /**
   * Handle mousedown events/
   */
  mouseDownHandler(evt) {}

  onScrollHandler() {
    if (this.sizeMode !== SIZE_MODE_SCROLL) return;
    this.scrollTop = this.scrollContainer.scrollTop;
    this.pixiStage.y = -this.scrollTop;
    this.pubSub.publish('app.scroll', this.scrollTop);
    this.animate();
  }

  setTrackValueScaleLimits(viewId, trackId, minValue, maxValue) {
    const track = getTrackObjById(this.tiledPlots, viewId, trackId);

    if (!track) {
      console.warn(`Could't find track: ${trackId}`);
      return;
    }

    if (track.setFixedValueScaleMin && track.setFixedValueScaleMax) {
      track.setFixedValueScaleMin(minValue);
      track.setFixedValueScaleMax(maxValue);

      track.rerender(track.options, true);
      track.animate();
    } else {
      console.warn("Track doesn't support fixed value scales.");
    }
  }

  setChromInfo(chromInfoPath, callback) {
    ChromosomeInfo(
      chromInfoPath,
      (newChromInfo) => {
        this.chromInfo = newChromInfo;
        callback();
      },
      this.pubSub,
    );
  }

  onMouseLeaveHandler() {
    this.pubSub.publish('app.mouseLeave');
    this.hideHoverMenu();
    this.animate();
  }

  onBlurHandler() {
    this.animate();
  }

  isZoomFixed(view) {
    return (
      this.props.zoomFixed ||
      this.props.options.zoomFixed ||
      this.state.viewConfig.zoomFixed ||
      this.sizeMode === SIZE_MODE_SCROLL ||
      view?.zoomFixed
    );
  }

  /**
   * Handle trackDimensionsModified events
   * @param {Object} settings
   * @param {string} settings.viewId = id of the view
   * @param {string} settings.trackId = id of the track
   * @param {number} settings.height = new height of the track or undefined if current height should remain
   * @param {nmber} settings.width = new width of the track or undefined if current width should remain
   */
  trackDimensionsModifiedHandler(settings) {
    const view = this.state.views[settings.viewId];

    if (!view) return;

    const track = getTrackByUid(view.tracks, settings.trackId);

    if (!track) return;

    if (settings.height !== undefined) {
      track.height = settings.height;
    }

    if (settings.width !== undefined) {
      track.width = settings.width;
    }
    this.adjustLayoutToTrackSizes(view);

    this.setState((prevState) => ({
      views: prevState.views,
    }));
  }

  wheelHandler(evt) {
    if (this.state.modal || this.sizeMode === SIZE_MODE_SCROLL) return;

    // The event forwarder wasn't written for React's SyntheticEvent
    const nativeEvent = evt.nativeEvent || evt;

    if (!hasParent(nativeEvent.target, this.topDivRef.current)) {
      // ignore events that don't come from within the
      // HiGlass container
      return;
    }

    if (this.isZoomFixed()) {
      // ignore events when in zoom fixed mode
      return;
    }

    const absX = nativeEvent.clientX;
    const absY = nativeEvent.clientY;
    const hoveredTiledPlot = this.getTiledPlotAtPosition(absX, absY);

    // Find the tracks at the wheel position
    if (this.apiStack.wheel && this.apiStack.wheel.length > 0) {
      const relPos = pointer(nativeEvent, this.topDiv);
      // We need to add the scrollTop
      relPos[1] += this.scrollTop;
      const hoveredTracks = hoveredTiledPlot
        ? hoveredTiledPlot
            .listTracksAtPosition(relPos[0], relPos[1], true)
            .map((track) => track.originalTrack || track)
        : [];
      const hoveredTrack = hoveredTracks.find(
        (track) => !track.isAugmentationTrack,
      );

      const relTrackPos = hoveredTrack
        ? [
            relPos[0] - hoveredTrack.position[0],
            relPos[1] - hoveredTrack.position[1],
          ]
        : relPos;

      const evtToPublish = {
        x: relPos[0],
        y: relPos[1],
        relTrackX: hoveredTrack?.flipText ? relTrackPos[1] : relTrackPos[0],
        relTrackY: hoveredTrack?.flipText ? relTrackPos[0] : relTrackPos[1],
        track: hoveredTrack,
        origEvt: nativeEvent,
        sourceUid: this.uid,
        hoveredTracks,
        noHoveredTracks: hoveredTracks.length === 0,
      };

      this.apiPublish('wheel', evtToPublish);
    }

    const isTargetCanvas = evt.target === this.canvasElement;
    if (nativeEvent.forwarded || isTargetCanvas) {
      evt.stopPropagation();
      evt.preventDefault();

      return;
    }

    evt.preventDefault();

    // forward the wheel event back to the TrackRenderer that it should go to
    // this is so that we can zoom when there's a viewport projection present
    if (hoveredTiledPlot) {
      const { trackRenderer } = hoveredTiledPlot;
      nativeEvent.forwarded = true;

      if (nativeEvent) {
        forwardEvent(nativeEvent, trackRenderer.eventTracker);

        nativeEvent.preventDefault();
      }
    }
  }

  /** Convert a viewUid to a view name so that when we create
   * viewport projects, we can show which view they refer to.
   * The name is usually a single letter like 'A'
   *  The view's name will also be visible in the header. */
  viewUidToName(viewUid) {
    const views = Object.keys(this.state.views);

    for (let i = 0; i < views.length; i++) {
      if (views[i] === viewUid) {
        // Starting from A and then onwards. God help us if there's
        // more than 52 views
        return String.fromCharCode(65 + i);
      }
    }

    return 'UU';
  }

  render() {
    this.tiledAreasDivs = {};
    this.tiledAreas = <div className={styles['tiled-area']} />;

    // The component needs to be mounted in order for the initial view to have
    // the right width
    if (this.mounted) {
      this.tiledAreas = dictValues(this.state.views).map((view) => {
        // only show the add track menu for the tiled plot it was selected for
        const addTrackPositionMenuPosition =
          view.uid === this.state.addTrackPositionMenuUid
            ? this.state.addTrackPositionMenuPosition
            : null;

        let overlay = null;
        if (this.state.chooseViewHandler) {
          let background = 'transparent';

          if (this.state.mouseOverOverlayUid === view.uid) background = 'green';

          overlay = (
            <div
              className="tiled-plot-overlay"
              onClick={() => this.state.chooseViewHandler(view.uid)}
              onMouseEnter={() => this.handleOverlayMouseEnter(view.uid)}
              onMouseLeave={() => this.handleOverlayMouseLeave(view.uid)}
              onMouseMove={() => this.handleOverlayMouseEnter(view.uid)}
              style={{
                position: 'absolute',
                width: '100%',
                height: '100%',
                background,
                opacity: 0.3,
                zIndex: 1,
              }}
            />
          );
        }

        const tiledPlot = (
          <TiledPlot
            // Reserved props
            key={`tp${view.uid}`}
            ref={(c) => {
              this.tiledPlots[view.uid] = c;
            }}
            // Custom props
            addTrackExtent={
              this.state.addTrackPositionView === view.uid
                ? this.state.addTrackExtent
                : null
            }
            addTrackPosition={
              this.state.addTrackPositionView === view.uid
                ? this.state.addTrackPosition
                : null
            }
            addTrackPositionMenuPosition={addTrackPositionMenuPosition}
            apiPublish={this.apiPublish}
            canvasElement={this.state.canvasElement}
            chooseTrackHandler={
              this.state.chooseTrackHandler
                ? (trackId, evt) =>
                    this.state.chooseTrackHandler(view.uid, trackId, evt)
                : null
            }
            customDialog={this.state.customDialog}
            closeCustomDialog={() => this.closeCustomDialog()}
            chromInfoPath={view.chromInfoPath}
            disableTrackMenu={this.isTrackMenuDisabled()}
            draggingHappening={this.state.draggingHappening}
            editable={this.isEditable()}
            genomePositionSearchBox={this.genomePositionSearchBoxes[view.uid]}
            getLockGroupExtrema={(uid) =>
              this.getLockGroupExtrema(view.uid, uid)
            }
            initialXDomain={view.initialXDomain}
            initialYDomain={view.initialYDomain}
            isShowGlobalMousePosition={this.isShowGlobalMousePosition}
            isValueScaleLocked={(uid) => this.isValueScaleLocked(view.uid, uid)}
            marginBottom={this.viewMarginBottom}
            marginLeft={this.viewMarginLeft}
            marginRight={this.viewMarginRight}
            marginTop={this.viewMarginTop}
            metaTracks={view.metaTracks}
            mouseTool={this.state.mouseTool}
            onChangeTrackData={(trackId, newData) =>
              this.handleChangeTrackData(view.uid, trackId, newData)
            }
            onChangeTrackType={(trackId, newType) =>
              this.handleChangeTrackType(view.uid, trackId, newType)
            }
            onCloseTrack={(uid) => this.handleCloseTrack(view.uid, uid)}
            onDataDomainChanged={(xDomain, yDomain) =>
              this.handleDataDomainChanged(view.uid, xDomain, yDomain)
            }
            onLockValueScale={(uid) => this.handleLockValueScale(view.uid, uid)}
            onMouseMoveZoom={this.mouseMoveZoomHandler.bind(this)}
            onNewTilesLoaded={(trackUid) =>
              this.handleNewTilesLoaded(view.uid, trackUid)
            }
            onNoTrackAdded={this.handleNoTrackAdded.bind(this)}
            onRangeSelection={this.rangeSelectionHandler.bind(this)}
            onResizeTrack={() => {
              this.adjustLayoutToTrackSizes(view);
              this.triggerViewChangeDb();
            }}
            onScalesChanged={(x, y) => this.handleScalesChanged(view.uid, x, y)}
            onTrackOptionsChanged={(trackId, options) =>
              this.handleTrackOptionsChanged(view.uid, trackId, options)
            }
            onTrackPositionChosen={this.handleTrackPositionChosen.bind(this)}
            onTracksAdded={(newTracks, position, extent, host) =>
              this.handleTracksAdded(
                view.uid,
                newTracks,
                position,
                extent,
                host,
              )
            }
            onUnlockValueScale={(uid) =>
              this.handleUnlockValueScale(view.uid, uid)
            }
            onValueScaleChanged={(uid) => this.syncValueScales(view.uid, uid)}
            overlays={view.overlays}
            overTrackChooser={this.state.overTrackChooser}
            paddingBottom={this.viewPaddingBottom}
            paddingLeft={this.viewPaddingLeft}
            paddingRight={this.viewPaddingRight}
            paddingTop={this.viewPaddingTop}
            pixiRenderer={this.pixiRenderer}
            pixiStage={this.pixiStage}
            pluginDataFetchers={this.pluginDataFetchers}
            pluginTracks={this.pluginTracks}
            rangeSelection1dSize={this.state.rangeSelection1dSize}
            rangeSelectionToInt={this.state.rangeSelectionToInt}
            registerDraggingChangedListener={(listener) =>
              this.addDraggingChangedListener(view.uid, view.uid, listener)
            }
            removeDraggingChangedListener={(listener) =>
              this.removeDraggingChangedListener(view.uid, view.uid, listener)
            }
            setCentersFunction={(c) => {
              this.setCenters[view.uid] = c;
            }}
            setOverTrackChooser={(val) =>
              this.setState({ overTrackChooser: val })
            }
            svgElement={this.state.svgElement}
            tracks={view.tracks}
            trackSourceServers={this.state.viewConfig.trackSourceServers}
            uid={view.uid}
            verticalMargin={this.verticalMargin}
            viewOptions={view.options}
            // dragging={this.state.dragging}
            xDomainLimits={view.xDomainLimits}
            yDomainLimits={view.yDomainLimits}
            zoomable={!this.isZoomFixed(view)}
            zoomLimits={this.calculateZoomLimits(view, view.initialXDomain)}
            zoomToDataExtentOnInit={() =>
              this.zoomToDataExtentOnInit.has(view.uid)
            }
          />
        );

        const looseTracks = positionedTracksToAllTracks(view.tracks);
        const annotationTracks = looseTracks.filter(
          (x) =>
            x.type === 'horizontal-gene-annotations' ||
            x.type === 'vertical-gene-annotations' ||
            x.type === 'gene-annotations',
        );
        const chromSizesTracks = looseTracks.filter(
          (x) =>
            x.type === 'horizontal-chromosome-labels' ||
            x.type === 'vertical-chromosome-labels' ||
            x.type === 'chromosome-labels',
        );
        const uniqueAnnotationTilesets = new Set(
          annotationTracks.map((x) => x.tilesetUid),
        );
        const uniqueChromSizesTilesets = new Set(
          chromSizesTracks.map((x) => x.tilesetUid),
        );

        const getGenomePositionSearchBox = (isFocused, onFocus) => (
          <GenomePositionSearchBox
            // Reserved props
            key={`gpsb${view.uid}`}
            ref={(c) => {
              this.genomePositionSearchBoxes[view.uid] = c;
            }}
            // Custom props
            autocompleteId={
              annotationTracks.length >= 1 &&
              uniqueAnnotationTilesets.size === 1
                ? annotationTracks[0].tilesetUid
                : null
            }
            autocompleteServer={
              annotationTracks.length >= 1 &&
              uniqueAnnotationTilesets.size === 1
                ? annotationTracks[0].server
                : null
            }
            chromInfoId={
              (chromSizesTracks.length && chromSizesTracks[0].tilesetUid) ||
              view.genomePositionSearchBox?.chromInfoId ||
              null
            }
            chromInfoServer={
              (chromSizesTracks.length && chromSizesTracks[0].server) ||
              view.genomePositionSearchBox?.chromInfoServer ||
              null
            }
            isFocused={isFocused}
            // the chromInfoId is either specified in the viewconfig or guessed based on
            // the visible tracks (see createGenomePositionSearchBoxEntry)
            onFocus={onFocus}
            registerViewportChangedListener={(listener) =>
              this.addScalesChangedListener(view.uid, view.uid, listener)
            }
            removeViewportChangedListener={() =>
              this.removeScalesChangedListener(view.uid, view.uid)
            }
            setCenters={(centerX, centerY, k, animateTime) =>
              this.setCenters[view.uid](centerX, centerY, k, false, animateTime)
            }
            trackSourceServers={this.state.viewConfig.trackSourceServers}
            twoD={true}
            error={
              (chromSizesTracks.length === 0 &&
                'no chromosome track present') ||
              (chromSizesTracks.length >= 2 &&
                uniqueChromSizesTilesets.size > 1 &&
                'multiple chromosome tracks present') ||
              (annotationTracks.length >= 2 &&
                uniqueAnnotationTilesets.size > 1 &&
                'multiple annotation tracks present')
            }
          />
        );
        const multiTrackHeader =
          this.isEditable() &&
          !this.isViewHeaderDisabled() &&
          !this.state.viewConfig.hideHeader ? (
            <ViewHeader
              ref={(c) => {
                this.viewHeaders[view.uid] = c;
              }}
              getGenomePositionSearchBox={getGenomePositionSearchBox}
              isGenomePositionSearchBoxVisible={true}
              mouseTool={this.state.mouseTool}
              onAddView={() => this.handleAddView(view)}
              onClearView={() => this.handleClearView(view.uid)}
              onCloseView={() => this.handleCloseView(view.uid)}
              onEditViewConfig={this.handleEditViewConfigBound}
              onExportPNG={this.handleExportPNG.bind(this)}
              onExportSVG={this.handleExportSVG.bind(this)}
              onExportViewsAsJSON={this.handleExportViewAsJSON.bind(this)}
              onExportViewsAsLink={this.handleExportViewsAsLink.bind(this)}
              onLockLocation={(uid) =>
                this.handleYankFunction(
                  uid,
                  this.handleLocationLockChosen.bind(this),
                )
              }
              onLockZoom={(uid) =>
                this.handleYankFunction(
                  uid,
                  this.handleZoomLockChosen.bind(this),
                )
              }
              onLockZoomAndLocation={(uid) =>
                this.handleYankFunction(uid, (a, b) => {
                  this.handleZoomLockChosen(a, b);
                  this.handleLocationLockChosen(a, b);
                })
              }
              onProjectViewport={this.handleProjectViewport.bind(this)}
              onTakeAndLockZoomAndLocation={(uid) => {
                this.handleYankFunction(uid, (a, b) => {
                  this.handleZoomYanked(a, b);
                  this.handleLocationYanked(a, b);
                  this.handleZoomLockChosen(a, b);
                  this.handleLocationLockChosen(a, b);
                });
              }}
              onTogglePositionSearchBox={this.handleTogglePositionSearchBox.bind(
                this,
              )}
              onTrackPositionChosen={(position, extent) =>
                this.handleTrackPositionChosen(view.uid, position, extent)
              }
              onUnlockLocation={(uid) =>
                this.handleUnlock(uid, this.locationLocks)
              }
              onUnlockZoom={(uid) => this.handleUnlock(uid, this.zoomLocks)}
              onUnlockZoomAndLocation={(uid) => {
                this.handleUnlock(uid, this.zoomLocks);
                this.handleUnlock(uid, this.locationLocks);
              }}
              onViewOptionsChanged={(newOptions) =>
                this.handleViewOptionsChanged(view.uid, newOptions)
              }
              onYankLocation={(uid) =>
                this.handleYankFunction(
                  uid,
                  this.handleLocationYanked.bind(this),
                )
              }
              onYankZoom={(uid) =>
                this.handleYankFunction(uid, this.handleZoomYanked.bind(this))
              }
              onYankZoomAndLocation={(uid) =>
                this.handleYankFunction(uid, (a, b) => {
                  this.handleZoomYanked(a, b);
                  this.handleLocationYanked(a, b);
                })
              }
              onZoomToData={(uid) => this.handleZoomToData(uid)}
              viewUid={view.uid}
            />
          ) : null;

        // Constrain the grid item child to the expected pixel height
        // derived from view.layout.h. react-grid-layout v2 syncs its
        // internal layout state via useEffect (async), so after a layout
        // change (e.g. track removal) the grid item's DOM dimensions can
        // be stale (too large). maxHeight ensures TiledPlot.measureSize()
        // reads the correct (new) height even before the grid re-syncs.
        const marginY = this.isEditable() ? 10 : 0;
        const expectedPixelHeight = view.layout
          ? Math.round(
              this.state.rowHeight * view.layout.h +
                Math.max(0, view.layout.h - 1) * marginY,
            )
          : undefined;

        return (
          <div
            key={view.uid}
            style={
              expectedPixelHeight !== undefined
                ? { maxHeight: expectedPixelHeight, overflow: 'hidden' }
                : undefined
            }
          >
            <div
              ref={(c) => {
                this.tiledAreasDivs[view.uid] = c;
              }}
              className={styles['tiled-area']}
            >
              {multiTrackHeader}
              {tiledPlot}
              {overlay}
            </div>
          </div>
        );
      });
    }

    let layouts = this.mounted
      ? Object.values(this.state.views)
          .filter((view) => view.layout)
          .map((view) => view.layout)
      : [];

    layouts = JSON.parse(JSON.stringify(layouts)); // make sure to copy the layouts

    const defaultContainerPaddingX = this.isEditable()
      ? DEFAULT_CONTAINER_PADDING_X
      : 0;
    const defaultContainerPaddingY = this.isEditable()
      ? DEFAULT_CONTAINER_PADDING_Y
      : 0;

    const containerPaddingX =
      +this.props.options.containerPaddingX >= 0
        ? +this.props.options.containerPaddingX
        : defaultContainerPaddingX;
    const containerPaddingY =
      +this.props.options.containerPaddingY >= 0
        ? +this.props.options.containerPaddingY
        : defaultContainerPaddingY;

    const gridLayout = (
      <ReactGridLayout
        gridConfig={{
          cols: 12,
          rowHeight: this.state.rowHeight,
          // for some reason, this becomes 40 within the react-grid component
          // (try resizing the component to see how much the height changes)
          // Programming by coincidence FTW :-/
          margin: this.isEditable() ? [10, 10] : [0, 0],
          containerPadding: [containerPaddingX, containerPaddingY],
        }}
        dragConfig={{
          enabled: this.isEditable(),
          handle: `.${stylesMTHeader['multitrack-header-grabber']}`,
        }}
        resizeConfig={{
          enabled: this.isEditable(),
        }}
        compactor={getCompactor(
          this.state.viewConfig.compactLayout === false ? null : 'vertical',
        )}
        layout={layouts}
        onDragStart={this.handleDragStart.bind(this)}
        onDragStop={this.handleDragStop.bind(this)}
        onLayoutChange={this.handleLayoutChange.bind(this)}
        onResize={this.resizeHandler.bind(this)}
        width={this.state.width}
      >
        {this.tiledAreas}
      </ReactGridLayout>
    );

    return (
      <div
        key={this.uid}
        ref={this.topDivRef}
        className={clsx('higlass', styles.higlass, {
          [styles['higlass-dark-theme']]: this.theme === THEME_DARK,
          [styles['higlass-container-overflow']]:
            this.sizeMode === SIZE_MODE_OVERFLOW ||
            this.sizeMode === SIZE_MODE_BOUNDED_OVERFLOW ||
            this.sizeMode === SIZE_MODE_SCROLL,
        })}
        onMouseLeave={this.onMouseLeaveHandlerBound}
        onMouseMove={this.mouseMoveHandlerBound}
      >
        <PubSubProvider value={this.pubSub}>
          <ModalProvider value={this.modal}>
            <ThemeProvider value={this.theme}>
              <HiGlassComponentContext.Provider
                value={{ viewUidToName: this.viewUidToNameBound }}
              >
                {this.state.modal}
                <canvas
                  key={this.uid}
                  ref={(c) => {
                    this.canvasElement = c;
                  }}
                  onClick={this.canvasClickHandlerBound}
                  className={styles['higlass-canvas']}
                />
                <div
                  ref={(c) => {
                    this.scrollContainer = c;
                  }}
                  className={clsx('higlass-scroll-container', {
                    [styles['higlass-scroll-container-overflow']]:
                      this.sizeMode === SIZE_MODE_OVERFLOW ||
                      this.sizeMode === SIZE_MODE_BOUNDED_OVERFLOW,
                    [styles['higlass-scroll-container-scroll']]:
                      this.sizeMode === SIZE_MODE_SCROLL,
                  })}
                  onScroll={this.onScrollHandlerBound}
                >
                  <div
                    ref={(c) => {
                      this.divDrawingSurface = c;
                    }}
                    className={clsx(
                      'higlass-drawing-surface',
                      styles['higlass-drawing-surface'],
                    )}
                  >
                    {gridLayout}
                  </div>
                  <svg
                    ref={(c) => {
                      this.svgElement = c;
                    }}
                    style={{
                      // inline the styles so they aren't overriden by other css
                      // on the web page
                      position: 'absolute',
                      width: '100%',
                      height: '100%',
                      left: 0,
                      top: 0,
                      pointerEvents: 'none',
                    }}
                    className={styles['higlass-svg']}
                  />
                </div>
              </HiGlassComponentContext.Provider>
            </ThemeProvider>
          </ModalProvider>
        </PubSubProvider>
      </div>
    );
  }
}

HiGlassComponent.defaultProps = {
  options: {},
  zoomFixed: false,
  compactLayout: true,
};

HiGlassComponent.propTypes = {
  getApi: PropTypes.func,
  options: PropTypes.object,
  viewConfig: PropTypes.oneOfType([PropTypes.string, PropTypes.object])
    .isRequired,
  zoomFixed: PropTypes.bool,
  compactLayout: PropTypes.bool,
};

export default HiGlassComponent;
