<commit_before>import Component from 'ember-component';
import layout from '../../templates/components/basic-dropdown/content';
import $ from 'jquery';
import Ember from 'ember';
import computed from 'ember-computed';
import { join, scheduleOnce } from 'ember-runloop';
import { htmlSafe } from 'ember-string';

const { testing, getOwner } = Ember;
const MutObserver = self.window.MutationObserver || self.window.WebKitMutationObserver;
const rAF = self.window.requestAnimationFrame || function(cb) {
  cb();
};

function waitForAnimations(element, callback) {
  rAF(function() {
    let computedStyle = self.window.getComputedStyle(element);
    if (computedStyle.transitionDuration && computedStyle.transitionDuration !== '0s') {
      let eventCallback = function() {
        element.removeEventListener('transitionend', eventCallback);
        callback();
      };
      element.addEventListener('transitionend', eventCallback);
    } else if (computedStyle.animationName !== 'none' && computedStyle.animationPlayState === 'running') {
      let eventCallback = function() {
        element.removeEventListener('animationend', eventCallback);
        callback();
      };
      element.addEventListener('animationend', eventCallback);
    } else {
      callback();
    }
  });
}

export default Component.extend({
  layout,
  tagName: '',
  animationEnabled: !testing,
  isTouchDevice: (!!self.window && 'ontouchstart' in self.window),
  hasMoved: false,
  animationClass: '',
  transitioningInClass: 'ember-basic-dropdown--transitioning-in',
  transitionedInClass: 'ember-basic-dropdown--transitioned-in',
  transitioningOutClass: 'ember-basic-dropdown--transitioning-out',

  // Lifecycle hooks
  init() {
    this._super(...arguments);
    this.handleRootMouseDown = this.handleRootMouseDown.bind(this);
    this.touchStartHandler = this.touchStartHandler.bind(this);
    this.touchMoveHandler = this.touchMoveHandler.bind(this);
    let dropdown = this.get('dropdown');
    this.triggerId = `ember-basic-dropdown-trigger-${dropdown.uniqueId}`;
    this.dropdownId = `ember-basic-dropdown-content-${dropdown.uniqueId}`;
    if (this.get('animationEnabled')) {
      this.set('animationClass', this.get('transitioningInClass'));
    }
    this.runloopAwareReposition = function() {
      join(dropdown.actions.reposition);
    };
  },

  willDestroyElement() {
    this._super(...arguments);
    this._teardown();
  },

  didReceiveAttrs() {
    this._super(...arguments);
    let oldDropdown = this.get('oldDropdown') || {};
    let dropdown = this.get('dropdown');
    if (!oldDropdown.isOpen && dropdown.isOpen) {
      scheduleOnce('afterRender', this, this.open);
    } else if (oldDropdown.isOpen && !dropdown.isOpen) {
      this.close();
    }
    this.set('oldDropdown', dropdown);
  },

  // CPs
  to: computed({
    get() {
      return this._getDestinationId();
    },
    set(_, v) {
      return v === undefined ? this._getDestinationId() : v;
    }
  }),

  style: computed('top', 'left', 'right', 'width', function() {
    let style = '';
    let { top, left, right, width } = this.getProperties('top', 'left', 'right', 'width');
    if (top) {
      style += `top: ${top};`;
    }
    if (left) {
      style += `left: ${left};`;
    }
    if (right) {
      style += `right: ${right};`;
    }
    if (width) {
      style += `width: ${width}`;
    }
    if (style.length > 0) {
      return htmlSafe(style);
    }
  }),

  // Methods
  open() {
    let dropdown = this.get('dropdown');
    this.triggerElement = this.triggerElement || document.getElementById(this.triggerId);
    this.dropdownElement = document.getElementById(this.dropdownId);
    self.document.body.addEventListener('mousedown', this.handleRootMouseDown, true);
    if (this.get('isTouchDevice')) {
      self.document.body.addEventListener('touchstart', this.touchStartHandler, true);
      self.document.body.addEventListener('touchend', this.handleRootMouseDown, true);
    }
    let onFocusIn = this.get('onFocusIn');
    if (onFocusIn) {
      this.dropdownElement.addEventListener('focusin', (e) => onFocusIn(dropdown, e));
    }
    let onFocusOut = this.get('onFocusOut');
    if (onFocusOut) {
      this.dropdownElement.addEventListener('focusout', (e) => onFocusOut(dropdown, e));
    }
    if (!this.get('renderInPlace')) {
      this.addGlobalEvents();
    }
    dropdown.actions.reposition();
    if (this.get('animationEnabled')) {
      scheduleOnce('afterRender', this, this.animateIn);
    }
  },

  close() {
    this._teardown();
    if (this.get('animationEnabled')) {
      this.animateOut(this.dropdownElement);
    }
    this.dropdownElement = null;
  },

  // Methods
  handleRootMouseDown(e) {
    if (this.hasMoved || this.dropdownElement.contains(e.target) || this.triggerElement && this.triggerElement.contains(e.target)) {
      this.hasMoved = false;
      return;
    }

    let closestDropdown = $(e.target).closest('.ember-basic-dropdown-content').get(0);
    if (closestDropdown) {
      let trigger = document.querySelector(`[aria-controls=${closestDropdown.attributes.id.value}]`);
      let parentDropdown = $(trigger).closest('.ember-basic-dropdown-content').get(0);
      if (parentDropdown && parentDropdown.attributes.id.value === this.dropdownId) {
        this.hasMoved = false;
        return;
      }
    }

    this.get('dropdown').actions.close(e, true);
  },

  addGlobalEvents() {
    self.window.addEventListener('scroll', this.runloopAwareReposition);
    self.window.addEventListener('resize', this.runloopAwareReposition);
    self.window.addEventListener('orientationchange', this.runloopAwareReposition);
    if (MutObserver) {
      this.mutationObserver = new MutObserver((mutations) => {
        if (mutations[0].addedNodes.length || mutations[0].removedNodes.length) {
          this.runloopAwareReposition();
        }
      });
      this.mutationObserver.observe(this.dropdownElement, { childList: true, subtree: true });
    } else {
      this.dropdownElement.addEventListener('DOMNodeInserted', this.runloopAwareReposition, false);
      this.dropdownElement.addEventListener('DOMNodeRemoved', this.runloopAwareReposition, false);
    }
  },

  removeGlobalEvents() {
    self.window.removeEventListener('scroll', this.runloopAwareReposition);
    self.window.removeEventListener('resize', this.runloopAwareReposition);
    self.window.removeEventListener('orientationchange', this.runloopAwareReposition);
    if (MutObserver) {
      if (this.mutationObserver) {
        this.mutationObserver.disconnect();
        this.mutationObserver = null;
      }
    } else {
      if (this.dropdownElement) {
        this.dropdownElement.removeEventListener('DOMNodeInserted', this.runloopAwareReposition);
        this.dropdownElement.removeEventListener('DOMNodeRemoved', this.runloopAwareReposition);
      }
    }
  },

  animateIn() {
    waitForAnimations(this.dropdownElement, () => {
      this.set('animationClass', this.get('transitionedInClass'));
    });
  },

  animateOut(dropdownElement) {
    let parentElement = this.get('renderInPlace') ? dropdownElement.parentElement.parentElement : dropdownElement.parentElement;
    let clone = dropdownElement.cloneNode(true);
    clone.id = `${clone.id}--clone`;
    let $clone = $(clone);
    let transitioningInClass = this.get('transitioningInClass');
    $clone.removeClass(this.get('transitionedInClass'));
    $clone.removeClass(transitioningInClass);
    $clone.addClass(this.get('transitioningOutClass'));
    parentElement.appendChild(clone);
    this.set('animationClass', transitioningInClass);
    waitForAnimations(clone, function() {
      parentElement.removeChild(clone);
    });
  },

  touchStartHandler() {
    self.document.body.addEventListener('touchmove', this.touchMoveHandler, true);
  },

  touchMoveHandler() {
    this.hasMoved = true;
    self.document.body.removeEventListener('touchmove', this.touchMoveHandler, true);
  },

  _teardown() {
    this.removeGlobalEvents();
    self.document.body.removeEventListener('mousedown', this.handleRootMouseDown, true);
    if (this.get('isTouchDevice')) {
      self.document.body.removeEventListener('touchstart', this.touchStartHandler, true);
      self.document.body.removeEventListener('touchend', this.handleRootMouseDown, true);
    }
  },

  _getDestinationId() {
    if (testing) {
      return 'ember-testing';
    }
    let config = getOwner(this).resolveRegistration('config:environment');
    return config['ember-basic-dropdown'] && config['ember-basic-dropdown'].destination || 'ember-basic-dropdown-wormhole';
  }
});
<commit_msg>The approach based in transitions is fundamentally flawed. Animations still work.<commit_after>import Component from 'ember-component';
import layout from '../../templates/components/basic-dropdown/content';
import $ from 'jquery';
import Ember from 'ember';
import computed from 'ember-computed';
import { join, scheduleOnce } from 'ember-runloop';
import { htmlSafe } from 'ember-string';

const { testing, getOwner } = Ember;
const MutObserver = self.window.MutationObserver || self.window.WebKitMutationObserver;
const rAF = self.window.requestAnimationFrame || function(cb) {
  cb();
};

function waitForAnimations(element, callback) {
  rAF(function() {
    let computedStyle = self.window.getComputedStyle(element);
    if (computedStyle.animationName !== 'none' && computedStyle.animationPlayState === 'running') {
      let eventCallback = function() {
        element.removeEventListener('animationend', eventCallback);
        callback();
      };
      element.addEventListener('animationend', eventCallback);
    } else {
      callback();
    }
  });
}

export default Component.extend({
  layout,
  tagName: '',
  animationEnabled: !testing,
  isTouchDevice: (!!self.window && 'ontouchstart' in self.window),
  hasMoved: false,
  animationClass: '',
  transitioningInClass: 'ember-basic-dropdown--transitioning-in',
  transitionedInClass: 'ember-basic-dropdown--transitioned-in',
  transitioningOutClass: 'ember-basic-dropdown--transitioning-out',

  // Lifecycle hooks
  init() {
    this._super(...arguments);
    this.handleRootMouseDown = this.handleRootMouseDown.bind(this);
    this.touchStartHandler = this.touchStartHandler.bind(this);
    this.touchMoveHandler = this.touchMoveHandler.bind(this);
    let dropdown = this.get('dropdown');
    this.triggerId = `ember-basic-dropdown-trigger-${dropdown.uniqueId}`;
    this.dropdownId = `ember-basic-dropdown-content-${dropdown.uniqueId}`;
    if (this.get('animationEnabled')) {
      this.set('animationClass', this.get('transitioningInClass'));
    }
    this.runloopAwareReposition = function() {
      join(dropdown.actions.reposition);
    };
  },

  willDestroyElement() {
    this._super(...arguments);
    this._teardown();
  },

  didReceiveAttrs() {
    this._super(...arguments);
    let oldDropdown = this.get('oldDropdown') || {};
    let dropdown = this.get('dropdown');
    if (!oldDropdown.isOpen && dropdown.isOpen) {
      scheduleOnce('afterRender', this, this.open);
    } else if (oldDropdown.isOpen && !dropdown.isOpen) {
      this.close();
    }
    this.set('oldDropdown', dropdown);
  },

  // CPs
  to: computed({
    get() {
      return this._getDestinationId();
    },
    set(_, v) {
      return v === undefined ? this._getDestinationId() : v;
    }
  }),

  style: computed('top', 'left', 'right', 'width', function() {
    let style = '';
    let { top, left, right, width } = this.getProperties('top', 'left', 'right', 'width');
    if (top) {
      style += `top: ${top};`;
    }
    if (left) {
      style += `left: ${left};`;
    }
    if (right) {
      style += `right: ${right};`;
    }
    if (width) {
      style += `width: ${width}`;
    }
    if (style.length > 0) {
      return htmlSafe(style);
    }
  }),

  // Methods
  open() {
    let dropdown = this.get('dropdown');
    this.triggerElement = this.triggerElement || document.getElementById(this.triggerId);
    this.dropdownElement = document.getElementById(this.dropdownId);
    self.document.body.addEventListener('mousedown', this.handleRootMouseDown, true);
    if (this.get('isTouchDevice')) {
      self.document.body.addEventListener('touchstart', this.touchStartHandler, true);
      self.document.body.addEventListener('touchend', this.handleRootMouseDown, true);
    }
    let onFocusIn = this.get('onFocusIn');
    if (onFocusIn) {
      this.dropdownElement.addEventListener('focusin', (e) => onFocusIn(dropdown, e));
    }
    let onFocusOut = this.get('onFocusOut');
    if (onFocusOut) {
      this.dropdownElement.addEventListener('focusout', (e) => onFocusOut(dropdown, e));
    }
    if (!this.get('renderInPlace')) {
      this.addGlobalEvents();
    }
    dropdown.actions.reposition();
    if (this.get('animationEnabled')) {
      scheduleOnce('afterRender', this, this.animateIn);
    }
  },

  close() {
    this._teardown();
    if (this.get('animationEnabled')) {
      this.animateOut(this.dropdownElement);
    }
    this.dropdownElement = null;
  },

  // Methods
  handleRootMouseDown(e) {
    if (this.hasMoved || this.dropdownElement.contains(e.target) || this.triggerElement && this.triggerElement.contains(e.target)) {
      this.hasMoved = false;
      return;
    }

    let closestDropdown = $(e.target).closest('.ember-basic-dropdown-content').get(0);
    if (closestDropdown) {
      let trigger = document.querySelector(`[aria-controls=${closestDropdown.attributes.id.value}]`);
      let parentDropdown = $(trigger).closest('.ember-basic-dropdown-content').get(0);
      if (parentDropdown && parentDropdown.attributes.id.value === this.dropdownId) {
        this.hasMoved = false;
        return;
      }
    }

    this.get('dropdown').actions.close(e, true);
  },

  addGlobalEvents() {
    self.window.addEventListener('scroll', this.runloopAwareReposition);
    self.window.addEventListener('resize', this.runloopAwareReposition);
    self.window.addEventListener('orientationchange', this.runloopAwareReposition);
    if (MutObserver) {
      this.mutationObserver = new MutObserver((mutations) => {
        if (mutations[0].addedNodes.length || mutations[0].removedNodes.length) {
          this.runloopAwareReposition();
        }
      });
      this.mutationObserver.observe(this.dropdownElement, { childList: true, subtree: true });
    } else {
      this.dropdownElement.addEventListener('DOMNodeInserted', this.runloopAwareReposition, false);
      this.dropdownElement.addEventListener('DOMNodeRemoved', this.runloopAwareReposition, false);
    }
  },

  removeGlobalEvents() {
    self.window.removeEventListener('scroll', this.runloopAwareReposition);
    self.window.removeEventListener('resize', this.runloopAwareReposition);
    self.window.removeEventListener('orientationchange', this.runloopAwareReposition);
    if (MutObserver) {
      if (this.mutationObserver) {
        this.mutationObserver.disconnect();
        this.mutationObserver = null;
      }
    } else {
      if (this.dropdownElement) {
        this.dropdownElement.removeEventListener('DOMNodeInserted', this.runloopAwareReposition);
        this.dropdownElement.removeEventListener('DOMNodeRemoved', this.runloopAwareReposition);
      }
    }
  },

  animateIn() {
    waitForAnimations(this.dropdownElement, () => {
      this.set('animationClass', this.get('transitionedInClass'));
    });
  },

  animateOut(dropdownElement) {
    let parentElement = this.get('renderInPlace') ? dropdownElement.parentElement.parentElement : dropdownElement.parentElement;
    let clone = dropdownElement.cloneNode(true);
    clone.id = `${clone.id}--clone`;
    let $clone = $(clone);
    let transitioningInClass = this.get('transitioningInClass');
    $clone.removeClass(this.get('transitionedInClass'));
    $clone.removeClass(transitioningInClass);
    $clone.addClass(this.get('transitioningOutClass'));
    parentElement.appendChild(clone);
    this.set('animationClass', transitioningInClass);
    waitForAnimations(clone, function() {
      parentElement.removeChild(clone);
    });
  },

  touchStartHandler() {
    self.document.body.addEventListener('touchmove', this.touchMoveHandler, true);
  },

  touchMoveHandler() {
    this.hasMoved = true;
    self.document.body.removeEventListener('touchmove', this.touchMoveHandler, true);
  },

  _teardown() {
    this.removeGlobalEvents();
    self.document.body.removeEventListener('mousedown', this.handleRootMouseDown, true);
    if (this.get('isTouchDevice')) {
      self.document.body.removeEventListener('touchstart', this.touchStartHandler, true);
      self.document.body.removeEventListener('touchend', this.handleRootMouseDown, true);
    }
  },

  _getDestinationId() {
    if (testing) {
      return 'ember-testing';
    }
    let config = getOwner(this).resolveRegistration('config:environment');
    return config['ember-basic-dropdown'] && config['ember-basic-dropdown'].destination || 'ember-basic-dropdown-wormhole';
  }
});
<|endoftext|>


<commit_before>// Copyright 2021, University of Colorado Boulder

/**
 * CarouselComboBox behaves like a combo box, but its listbox is a carousel. It reuses ComboBoxItem, ComboBoxButton, and
 * Carousel.
 *
 * NOTE! This was created as a quick way to address situations where the listbox gets too long, for example
 * https://github.com/phetsims/sun/issues/673. This tends to happen in internal 'demo' applications
 * (sun, scenery-phet,... ) that have long lists of demos. And as a design best-practice, we tend to avoid
 * longs lists of things in sims. So that's why CarouselComboBox currently lives in sun/demo/.
 * It was written to be fairly general, so should be relatively easy to relocated if it's needed for wider use.
 *
 * FUTURE WORK:
 * - make CarouselComboBox pop up the Carousel in front of everything else
 * - a11y support in CarouselComboBox
 * - Modify ComboBox so that it can use different types of popups (ComboBoxListBox, Carousel,...)
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */

import Multilink from '../../../axon/js/Multilink.js';
import Property from '../../../axon/js/Property.js';
import Dimension2 from '../../../dot/js/Dimension2.js';
import dotRandom from '../../../dot/js/dotRandom.js';
import merge from '../../../phet-core/js/merge.js';
import AssertUtils from '../../../phetcommon/js/AssertUtils.js';
import PressListener from '../../../scenery/js/listeners/PressListener.js';
import AlignBox from '../../../scenery/js/nodes/AlignBox.js';
import AlignGroup from '../../../scenery/js/nodes/AlignGroup.js';
import Node from '../../../scenery/js/nodes/Node.js';
import Rectangle from '../../../scenery/js/nodes/Rectangle.js';
import VBox from '../../../scenery/js/nodes/VBox.js';
import Color from '../../../scenery/js/util/Color.js';
import Tandem from '../../../tandem/js/Tandem.js';
import Carousel from '../Carousel.js';
import ComboBoxButton from '../ComboBoxButton.js';
import ComboBoxItem from '../ComboBoxItem.js';
import sun from '../sun.js';

class CarouselComboBox extends Node {

  /**
   * @param {Property.<*>} property
   * @param {ComboBoxItem[]} comboBoxItems
   * @param options
   */
  constructor( property, comboBoxItems, options ) {

    assert && assert( property instanceof Property, 'invalid property' );
    assert && AssertUtils.assertArrayOf( comboBoxItems, ComboBoxItem );

    options = merge( {

      // Options specific to CarouselComboBox
      align: 'left', // {string} alignment of items in the carousel, 'left'|'right'|'center'
      overColor: Color.grayColor( 245 ), // {ColorDef} background color of the item that the pointer is over
      selectedColor: 'yellow', // {ColorDef} background color of the selected item
      itemXMargin: 6, // {number} x margin for backgrounds on the items in the carousel
      itemYMargin: 2, // {number} y margin for backgrounds on the items in the carousel

      // Options passed to Carousel
      carouselOptions: {
        arrowSize: new Dimension2( 20, 4 ),

        // Like combo box, 'vertical' is the only orientation supported (verified below).
        orientation: 'vertical',

        // Spacing and margins will be built into the backgrounds that are add to the item Nodes,
        // so set them to zero for the carousel.
        spacing: 0,
        margin: 0,

        // {number} If there are fewer items than this in the carousel, the actual number of items will be used.
        itemsPerPage: 15
      },

      // Options passed to ComboBoxButton
      buttonOptions: {
        arrowDirection: 'down', // 'up' is not currently supported (verified below)
        baseColor: 'rgb( 218, 236, 255 )',
        xMargin: 6, // You'll typically want this to be the same as itemXMargin.
        yMargin: 4
      },

      // phet-io
      tandem: Tandem.OPTIONAL
    }, options );

    // Validate options
    assert && assert( options.carouselOptions.orientation === 'vertical', 'orientation must be vertical' );
    assert && assert( options.buttonOptions.arrowDirection === 'down', 'arrowDirection must be down' );

    // Check for options that are not settable by the client
    assert && assert( !options.children, 'CarouselComboBox sets children' );
    assert && assert( !options.buttonOptions.content, 'CarouselComboBox sets buttonOptions.content' );
    assert && assert( !options.buttonOptions.listener, 'CarouselComboBox sets buttonOptions.listener' );

    // Don't create pages that are longer than the number of items
    options.carouselOptions.itemsPerPage = Math.min( options.carouselOptions.itemsPerPage, comboBoxItems.length );

    // Create tandems for subcomponents, if they were not provided
    options.carouselOptions.tandem = options.carouselOptions.tandem || options.tandem.createTandem( 'carousel' );
    options.buttonOptions.tandem = options.buttonOptions.tandem || options.tandem.createTandem( 'button' );

    // Make all Nodes for the comboBoxItems have the same width and height, left justified.
    const alignGroup = new AlignGroup();
    const itemNodes = _.map( comboBoxItems, item => {
      const node = new AlignBox( item.node, {
        group: alignGroup,
        xAlign: options.align
      } );
      node.itemValue = item.value; // attach the item's value to the Node, we'll need it later
      return node;
    } );

    // Size of the background that we'll put behind all item Nodes. Since we made all of the Nodes have the same
    // dimensions using AlignBox above, we can simply inspect the first Node.
    const backgroundSize = new Dimension2(
      itemNodes[ 0 ].width + 2 * options.itemXMargin,
      itemNodes[ 0 ].height + 2 * options.itemYMargin
    );

    // Create items for the carousel, whose API for 'items' is different than ComboBox. The Node for each item is a
    // label on a background. The background changes color when the item is selected or the pointer is over the item.
    const carouselItemNodes = [];
    const multilinks = [];
    for ( let i = 0; i < itemNodes.length; i++ ) {

      const itemNode = itemNodes[ i ];

      const backgroundNode = new Rectangle( 0, 0, backgroundSize.width, backgroundSize.height, {
        center: itemNode.center // Actual alignment of the items on the background is handled by AlignBox above.
      } );

      const carouselItemNode = new Node( {
        children: [ backgroundNode, itemNode ]
      } );

      // Press on an item to select its associated value.
      const pressListener = new PressListener( {
        press: () => {
          property.value = itemNode.itemValue;
        }
      } );
      carouselItemNode.addInputListener( pressListener );

      // Selecting an item sets its background to the selected color.
      // Pointer over an item sets its background to the highlighted color.
      // Must be disposed because we do not own property.
      const multilink = new Multilink(
        [ property, pressListener.isOverProperty ],
        ( propertyValue, isOver ) => {
          if ( propertyValue === itemNode.itemValue ) {
            backgroundNode.fill = options.selectedColor;
          }
          else if ( isOver ) {
            backgroundNode.fill = options.overColor;
          }
          else {
            backgroundNode.fill = 'transparent';
          }
        } );
      multilinks.push( multilink );

      carouselItemNodes.push( carouselItemNode );
    }
    assert && assert( carouselItemNodes.length === comboBoxItems.length, 'expected a carouselItem for each comboBoxItem' );

    // Create the carousel.
    const carousel = new Carousel( carouselItemNodes, options.carouselOptions );

    // Pressing this button pops up the carousel
    const button = new ComboBoxButton( property, comboBoxItems, merge( {}, {
      listener: () => { carousel.visible = !carousel.visible; }
    }, options.buttonOptions ) );

    // Put the button above the carousel, left aligned.
    const vBox = new VBox( {
      spacing: 0,
      align: 'left',
      children: [ button, carousel ]
    } );

    // Wrap everything with Node, to hide VBox's API.
    options.children = [ vBox ];

    super( options );

    // If the Property changes, hide the carousel. unlink is needed on disposed.
    const propertyListener = () => { carousel.visible = false; };
    property.link( propertyListener );

    // Clicking anywhere other than the button or carousel will hide the carousel.
    // NOTE: This was adapted from ComboBox.
    const clickToDismissListener = {
      down: event => {

        // If fuzzing is enabled, exercise this listener some percentage of the time, so that this listener is tested.
        // The rest of the time, ignore this listener, so that the listbox remains popped up, and we test making
        // choices from the listbox. See https://github.com/phetsims/sun/issues/677
        if ( !phet.chipper.isFuzzEnabled() || dotRandom.nextDouble() < 0.25 ) {

          // Ignore if we click over the button, since the button will handle hiding the list.
          if ( !( event.trail.containsNode( button ) || event.trail.containsNode( carousel ) ) ) {
            carousel.visible = false;
          }
        }
      }
    };

    // Add clickToDismissListener only when the carousel is visible. unlink is not needed.
    // NOTE: This was adapted from ComboBox.
    let display = null; // {Display}
    carousel.visibleProperty.link( visible => {
      if ( visible ) {
        assert && assert( !display, 'unexpected display' );
        display = this.getUniqueTrail().rootNode().getRootedDisplays()[ 0 ];
        display.addInputListener( clickToDismissListener );
      }
      else {
        if ( display && display.hasInputListener( clickToDismissListener ) ) {
          display.removeInputListener( clickToDismissListener );
          display = null;
        }
      }
    } );

    // @private
    this.disposeCarouselComboBox = () => {

      // Deregister observers
      property.unlink( propertyListener );
      multilinks.forEach( multilink => multilink.dispose() );

      // Dispose of PhET-iO instrumented subcomponents
      carousel.dispose();
      button.dispose();
    };
  }

  /**
   * @public
   * @override
   */
  dispose() {
    this.disposeCarouselComboBox();
    super.dispose();
  }
}

sun.register( 'CarouselComboBox', CarouselComboBox );
export default CarouselComboBox;<commit_msg>future<commit_after>// Copyright 2021, University of Colorado Boulder

/**
 * CarouselComboBox behaves like a combo box, but its listbox is a carousel. It reuses ComboBoxItem, ComboBoxButton, and
 * Carousel.
 *
 * NOTE! This was created as a quick way to address situations where the listbox gets too long, for example
 * https://github.com/phetsims/sun/issues/673. This tends to happen in internal 'demo' applications
 * (sun, scenery-phet,... ) that have long lists of demos. And as a design best-practice, we tend to avoid
 * longs lists of things in sims. So that's why CarouselComboBox currently lives in sun/demo/.
 * It was written to be fairly general, so should be relatively easy to relocated if it's needed for wider use.
 *
 * Possibilities for future work:
 * - Modify ComboBox so that it can use different types of popups (ComboBoxListBox, Carousel,...), or
 * - Make CarouselComboBox pop up the Carousel in front of everything else
 * - a11y support in CarouselComboBox
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */

import Multilink from '../../../axon/js/Multilink.js';
import Property from '../../../axon/js/Property.js';
import Dimension2 from '../../../dot/js/Dimension2.js';
import dotRandom from '../../../dot/js/dotRandom.js';
import merge from '../../../phet-core/js/merge.js';
import AssertUtils from '../../../phetcommon/js/AssertUtils.js';
import PressListener from '../../../scenery/js/listeners/PressListener.js';
import AlignBox from '../../../scenery/js/nodes/AlignBox.js';
import AlignGroup from '../../../scenery/js/nodes/AlignGroup.js';
import Node from '../../../scenery/js/nodes/Node.js';
import Rectangle from '../../../scenery/js/nodes/Rectangle.js';
import VBox from '../../../scenery/js/nodes/VBox.js';
import Color from '../../../scenery/js/util/Color.js';
import Tandem from '../../../tandem/js/Tandem.js';
import Carousel from '../Carousel.js';
import ComboBoxButton from '../ComboBoxButton.js';
import ComboBoxItem from '../ComboBoxItem.js';
import sun from '../sun.js';

class CarouselComboBox extends Node {

  /**
   * @param {Property.<*>} property
   * @param {ComboBoxItem[]} comboBoxItems
   * @param options
   */
  constructor( property, comboBoxItems, options ) {

    assert && assert( property instanceof Property, 'invalid property' );
    assert && AssertUtils.assertArrayOf( comboBoxItems, ComboBoxItem );

    options = merge( {

      // Options specific to CarouselComboBox
      align: 'left', // {string} alignment of items in the carousel, 'left'|'right'|'center'
      overColor: Color.grayColor( 245 ), // {ColorDef} background color of the item that the pointer is over
      selectedColor: 'yellow', // {ColorDef} background color of the selected item
      itemXMargin: 6, // {number} x margin for backgrounds on the items in the carousel
      itemYMargin: 2, // {number} y margin for backgrounds on the items in the carousel

      // Options passed to Carousel
      carouselOptions: {
        arrowSize: new Dimension2( 20, 4 ),

        // Like combo box, 'vertical' is the only orientation supported (verified below).
        orientation: 'vertical',

        // Spacing and margins will be built into the backgrounds that are add to the item Nodes,
        // so set them to zero for the carousel.
        spacing: 0,
        margin: 0,

        // {number} If there are fewer items than this in the carousel, the actual number of items will be used.
        itemsPerPage: 15
      },

      // Options passed to ComboBoxButton
      buttonOptions: {
        arrowDirection: 'down', // 'up' is not currently supported (verified below)
        baseColor: 'rgb( 218, 236, 255 )',
        xMargin: 6, // You'll typically want this to be the same as itemXMargin.
        yMargin: 4
      },

      // phet-io
      tandem: Tandem.OPTIONAL
    }, options );

    // Validate options
    assert && assert( options.carouselOptions.orientation === 'vertical', 'orientation must be vertical' );
    assert && assert( options.buttonOptions.arrowDirection === 'down', 'arrowDirection must be down' );

    // Check for options that are not settable by the client
    assert && assert( !options.children, 'CarouselComboBox sets children' );
    assert && assert( !options.buttonOptions.content, 'CarouselComboBox sets buttonOptions.content' );
    assert && assert( !options.buttonOptions.listener, 'CarouselComboBox sets buttonOptions.listener' );

    // Don't create pages that are longer than the number of items
    options.carouselOptions.itemsPerPage = Math.min( options.carouselOptions.itemsPerPage, comboBoxItems.length );

    // Create tandems for subcomponents, if they were not provided
    options.carouselOptions.tandem = options.carouselOptions.tandem || options.tandem.createTandem( 'carousel' );
    options.buttonOptions.tandem = options.buttonOptions.tandem || options.tandem.createTandem( 'button' );

    // Make all Nodes for the comboBoxItems have the same width and height, left justified.
    const alignGroup = new AlignGroup();
    const itemNodes = _.map( comboBoxItems, item => {
      const node = new AlignBox( item.node, {
        group: alignGroup,
        xAlign: options.align
      } );
      node.itemValue = item.value; // attach the item's value to the Node, we'll need it later
      return node;
    } );

    // Size of the background that we'll put behind all item Nodes. Since we made all of the Nodes have the same
    // dimensions using AlignBox above, we can simply inspect the first Node.
    const backgroundSize = new Dimension2(
      itemNodes[ 0 ].width + 2 * options.itemXMargin,
      itemNodes[ 0 ].height + 2 * options.itemYMargin
    );

    // Create items for the carousel, whose API for 'items' is different than ComboBox. The Node for each item is a
    // label on a background. The background changes color when the item is selected or the pointer is over the item.
    const carouselItemNodes = [];
    const multilinks = [];
    for ( let i = 0; i < itemNodes.length; i++ ) {

      const itemNode = itemNodes[ i ];

      const backgroundNode = new Rectangle( 0, 0, backgroundSize.width, backgroundSize.height, {
        center: itemNode.center // Actual alignment of the items on the background is handled by AlignBox above.
      } );

      const carouselItemNode = new Node( {
        children: [ backgroundNode, itemNode ]
      } );

      // Press on an item to select its associated value.
      const pressListener = new PressListener( {
        press: () => {
          property.value = itemNode.itemValue;
        }
      } );
      carouselItemNode.addInputListener( pressListener );

      // Selecting an item sets its background to the selected color.
      // Pointer over an item sets its background to the highlighted color.
      // Must be disposed because we do not own property.
      const multilink = new Multilink(
        [ property, pressListener.isOverProperty ],
        ( propertyValue, isOver ) => {
          if ( propertyValue === itemNode.itemValue ) {
            backgroundNode.fill = options.selectedColor;
          }
          else if ( isOver ) {
            backgroundNode.fill = options.overColor;
          }
          else {
            backgroundNode.fill = 'transparent';
          }
        } );
      multilinks.push( multilink );

      carouselItemNodes.push( carouselItemNode );
    }
    assert && assert( carouselItemNodes.length === comboBoxItems.length, 'expected a carouselItem for each comboBoxItem' );

    // Create the carousel.
    const carousel = new Carousel( carouselItemNodes, options.carouselOptions );

    // Pressing this button pops up the carousel
    const button = new ComboBoxButton( property, comboBoxItems, merge( {}, {
      listener: () => { carousel.visible = !carousel.visible; }
    }, options.buttonOptions ) );

    // Put the button above the carousel, left aligned.
    const vBox = new VBox( {
      spacing: 0,
      align: 'left',
      children: [ button, carousel ]
    } );

    // Wrap everything with Node, to hide VBox's API.
    options.children = [ vBox ];

    super( options );

    // If the Property changes, hide the carousel. unlink is needed on disposed.
    const propertyListener = () => { carousel.visible = false; };
    property.link( propertyListener );

    // Clicking anywhere other than the button or carousel will hide the carousel.
    // NOTE: This was adapted from ComboBox.
    const clickToDismissListener = {
      down: event => {

        // If fuzzing is enabled, exercise this listener some percentage of the time, so that this listener is tested.
        // The rest of the time, ignore this listener, so that the listbox remains popped up, and we test making
        // choices from the listbox. See https://github.com/phetsims/sun/issues/677
        if ( !phet.chipper.isFuzzEnabled() || dotRandom.nextDouble() < 0.25 ) {

          // Ignore if we click over the button, since the button will handle hiding the list.
          if ( !( event.trail.containsNode( button ) || event.trail.containsNode( carousel ) ) ) {
            carousel.visible = false;
          }
        }
      }
    };

    // Add clickToDismissListener only when the carousel is visible. unlink is not needed.
    // NOTE: This was adapted from ComboBox.
    let display = null; // {Display}
    carousel.visibleProperty.link( visible => {
      if ( visible ) {
        assert && assert( !display, 'unexpected display' );
        display = this.getUniqueTrail().rootNode().getRootedDisplays()[ 0 ];
        display.addInputListener( clickToDismissListener );
      }
      else {
        if ( display && display.hasInputListener( clickToDismissListener ) ) {
          display.removeInputListener( clickToDismissListener );
          display = null;
        }
      }
    } );

    // @private
    this.disposeCarouselComboBox = () => {

      // Deregister observers
      property.unlink( propertyListener );
      multilinks.forEach( multilink => multilink.dispose() );

      // Dispose of PhET-iO instrumented subcomponents
      carousel.dispose();
      button.dispose();
    };
  }

  /**
   * @public
   * @override
   */
  dispose() {
    this.disposeCarouselComboBox();
    super.dispose();
  }
}

sun.register( 'CarouselComboBox', CarouselComboBox );
export default CarouselComboBox;<|endoftext|>


<commit_before>import Ember from 'ember';
import computed from 'ember-computed';

const {
  Service,
  get,
  typeOf: getTypeOf,
  A: array
} = Ember;

/**
 * This service should be overwritten to configure IndexedDB.
 * Overwrite the `mapTable` property & add `versionX` properties to fit your application.
 *
 * @module Services
 * @class IndexedDbConfiguration
 * @extends Ember.Service
 * @public
 */
export default Service.extend({

  /**
   * Increment this whenever you do a new database version.
   * Set it to 1 on your initial version.
   *
   * For every version, you should provide a `versionX` property.
   * Each of these properties should be an object with "stores" and/or "upgrade" properties.
   *
   * stores should be an object where the keys are dasherized, singular model names (e.g. task-item),
   * and the value is a string with the indexedable fields.
   * See https://github.com/dfahlander/Dexie.js/wiki/Version.stores() for detailed options.
   *
   * upgrade is a function that gets a transaction as parameter, which can be used to run database migrations.
   * See https://github.com/dfahlander/Dexie.js/wiki/Version.upgrade() for detailed options/examples.
   *
   * An example would be:
   *
   * ```js
   * version1: {
   *    stores: {
   *      'task': '&id*,isRead',
   *      'task-item': '&id'
   *    }
   * },
   *
   * version2: {
   *    stores: {
   *      'task-item': '&id,*isNew'
   *    },
   *    upgrade: (transaction) => {
     *     transaction['task-item'].each((taskItem, cursor) => {
           taskItem.isNew = 0;
           cursor.update(taskItem);
          });
   *    }
   * }
   * ```
   *
   * The
   * You can also use upgrade/store without using the other option.
   *
   * @property currentVersion
   * @type {Number}
   * @public
   */
  currentVersion: 0,

  /**
   * The map functions for the tables.
   * This should be an object with one key per table
   * where the value is a function that takes an object and returns an object to save in IndexedDB.
   *
   * This object NEEDS to contain at least the properties id & json.
   * It should also contain one property per queryable property on the database table.
   *
   * There are a few things to note here:
   *
   *   1. Always convert your IDs to strings. You can use the provided `this._toString(val)` function for this.
   *   2. Always clean up your json. You can use the provided `this._cleanObject(item)` for this.
   *   3. IndexedDB doesn't work with boolean queries. You need to convert booleans to 1/0 when inserting it into the Database.
   *      You can use the provided `this._toZeroOne(val)` for this.
   *
   * For example, the following table config:
   *
   * ```js
   * {
   *    task: '++id,isRead,status,[isRead+status]'
   * }
   * ```
   *
   * should look something like this:
   *
   * ```js
   * return {
   *    task: (item) => {
   *      return {
   *        id: this._toString(get(item, 'id')),
   *        json: this._cleanObject(item),
   *        isRead: this._toZeroOne(get(item, 'attributes.isRead')),
   *        status: get(item, 'attributes.status')
   *      };
   *    }
   * };
   * ```
   *
   * Note that if you do not specify anything here, it will default to
   *
   * ```js
   * return {
   *    id: this._toString(get(item, 'id')),
   *    json: this._cleanObject(item)
   * };
   * ```
   *
   * @property mapTable
   * @type {Object}
   * @protected
   */
  mapTable: computed(function() {
    return {};
  }),

  /**
   * Map a payload to a database table.
   * This will use the function provided in mapTable to get a payload to insert into IndexedDB.
   * Returns null if no map function is found for the type.
   *
   * @method mapItem
   * @param {String} type The type of object to map
   * @param {Object} item The data to map
   * @return {Object}
   * @public
   */
  mapItem(type, item) {
    let tables = get(this, 'mapTable');
    let mapFunc = get(tables, type);

    if (!item) {
      return null;
    }

    if (!mapFunc) {
      return {
        id: this._toString(get(item, 'id')),
        json: this._cleanObject(item)
      };
    }

    return mapFunc(item);
  },

  /**
   * Setup the database and do all necessary database migrations.
   *
   * @method setupDatabase
   * @param {Dexie} db
   * @return {Dexie}
   * @public
   */
  setupDatabase(db) {
    let currentVersion = get(this, 'currentVersion');

    for (let v = 1; v <= currentVersion; v++) {
      let version = get(this, `version${v}`);
      let stores = get(version, 'stores');
      let upgrade = get(version, 'upgrade');

      if (stores && upgrade) {
        db.version(v).stores(stores).upgrade(upgrade);
      } else if (stores) {
        db.version(v).stores(stores);
      } else if (upgrade) {
        db.version(v).upgrade(upgrade);
      }

    }

    return db;
  },

  /**
   * Cleanup a json object.
   * This will convert array-like structures to actual arrays for saving.
   * It will strip out meta properties etc.
   *
   * @method _cleanObject
   * @param {Object} data
   * @return {{id, type, attributes: {}, relationships: {}}}
   * @private
   */
  _cleanObject(data) {
    if (!data) {
      return null;
    }

    let obj = {
      id: get(data, 'id'),
      type: get(data, 'type'),
      attributes: {},
      relationships: {}
    };

    let attributes = get(data, 'attributes') || {};
    let relationships = get(data, 'relationships') || {};

    let isArray = (item) => {
      return getTypeOf(item) === 'array' || (getTypeOf(item) === 'instance' && getTypeOf(item.toArray) === 'function');
    };

    for (let i in attributes) {
      if (!attributes.hasOwnProperty(i)) {
        continue;
      }

      // Convert array-like structures to real arrays
      if (isArray(attributes[i])) {
        obj.attributes[i] = array(attributes[i]).toArray();
      } else {
        obj.attributes[i] = attributes[i];
      }
    }

    for (let i in relationships) {
      if (!relationships.hasOwnProperty(i)) {
        continue;
      }
      if (isArray(relationships[i].data)) {
        obj.relationships[i] = { data: array(relationships[i].data).toArray() };
      } else {
        obj.relationships[i] = relationships[i];
      }
    }

    return obj;
  },

  /**
   * Convert a property to a string.
   *
   * @method _toString
   * @param {Mixed} val
   * @return {String}
   * @private
   */
  _toString(val) {
    return `${val}`;
  },

  /**
   * Convert a boolean to 1/0.
   *
   * @method _toZeroOne
   * @param val
   * @return {1|0}
   * @private
   */
  _toZeroOne(val) {
    return val ? 1 : 0;
  }

});
<commit_msg>Throw error if indexed-db-configuration is not extended<commit_after>import Ember from 'ember';
import computed from 'ember-computed';

const {
  Service,
  get,
  typeOf: getTypeOf,
  A: array
} = Ember;

/**
 * This service should be overwritten to configure IndexedDB.
 * Overwrite the `mapTable` property & add `versionX` properties to fit your application.
 *
 * @module Services
 * @class IndexedDbConfiguration
 * @extends Ember.Service
 * @public
 */
export default Service.extend({

  /**
   * Increment this whenever you do a new database version.
   * Set it to 1 on your initial version.
   *
   * For every version, you should provide a `versionX` property.
   * Each of these properties should be an object with "stores" and/or "upgrade" properties.
   *
   * stores should be an object where the keys are dasherized, singular model names (e.g. task-item),
   * and the value is a string with the indexedable fields.
   * See https://github.com/dfahlander/Dexie.js/wiki/Version.stores() for detailed options.
   *
   * upgrade is a function that gets a transaction as parameter, which can be used to run database migrations.
   * See https://github.com/dfahlander/Dexie.js/wiki/Version.upgrade() for detailed options/examples.
   *
   * An example would be:
   *
   * ```js
   * version1: {
   *    stores: {
   *      'task': '&id*,isRead',
   *      'task-item': '&id'
   *    }
   * },
   *
   * version2: {
   *    stores: {
   *      'task-item': '&id,*isNew'
   *    },
   *    upgrade: (transaction) => {
     *     transaction['task-item'].each((taskItem, cursor) => {
           taskItem.isNew = 0;
           cursor.update(taskItem);
          });
   *    }
   * }
   * ```
   *
   * The
   * You can also use upgrade/store without using the other option.
   *
   * @property currentVersion
   * @type {Number}
   * @public
   */
  currentVersion: 0,

  /**
   * The map functions for the tables.
   * This should be an object with one key per table
   * where the value is a function that takes an object and returns an object to save in IndexedDB.
   *
   * This object NEEDS to contain at least the properties id & json.
   * It should also contain one property per queryable property on the database table.
   *
   * There are a few things to note here:
   *
   *   1. Always convert your IDs to strings. You can use the provided `this._toString(val)` function for this.
   *   2. Always clean up your json. You can use the provided `this._cleanObject(item)` for this.
   *   3. IndexedDB doesn't work with boolean queries. You need to convert booleans to 1/0 when inserting it into the Database.
   *      You can use the provided `this._toZeroOne(val)` for this.
   *
   * For example, the following table config:
   *
   * ```js
   * {
   *    task: '++id,isRead,status,[isRead+status]'
   * }
   * ```
   *
   * should look something like this:
   *
   * ```js
   * return {
   *    task: (item) => {
   *      return {
   *        id: this._toString(get(item, 'id')),
   *        json: this._cleanObject(item),
   *        isRead: this._toZeroOne(get(item, 'attributes.isRead')),
   *        status: get(item, 'attributes.status')
   *      };
   *    }
   * };
   * ```
   *
   * Note that if you do not specify anything here, it will default to
   *
   * ```js
   * return {
   *    id: this._toString(get(item, 'id')),
   *    json: this._cleanObject(item)
   * };
   * ```
   *
   * @property mapTable
   * @type {Object}
   * @protected
   */
  mapTable: computed(function() {
    return {};
  }),

  /**
   * Map a payload to a database table.
   * This will use the function provided in mapTable to get a payload to insert into IndexedDB.
   * Returns null if no map function is found for the type.
   *
   * @method mapItem
   * @param {String} type The type of object to map
   * @param {Object} item The data to map
   * @return {Object}
   * @public
   */
  mapItem(type, item) {
    let tables = get(this, 'mapTable');
    let mapFunc = get(tables, type);

    if (!item) {
      return null;
    }

    if (!mapFunc) {
      return {
        id: this._toString(get(item, 'id')),
        json: this._cleanObject(item)
      };
    }

    return mapFunc(item);
  },

  /**
   * Setup the database and do all necessary database migrations.
   *
   * @method setupDatabase
   * @param {Dexie} db
   * @return {Dexie}
   * @public
   */
  setupDatabase(db) {
    let currentVersion = get(this, 'currentVersion');

    if (!currentVersion) {
      throw new Error('You need to override services/indexed-db-configuration.js and provide at least one version.');
    }

    for (let v = 1; v <= currentVersion; v++) {
      let version = get(this, `version${v}`);
      let stores = get(version, 'stores');
      let upgrade = get(version, 'upgrade');

      if (stores && upgrade) {
        db.version(v).stores(stores).upgrade(upgrade);
      } else if (stores) {
        db.version(v).stores(stores);
      } else if (upgrade) {
        db.version(v).upgrade(upgrade);
      }

    }

    return db;
  },

  /**
   * Cleanup a json object.
   * This will convert array-like structures to actual arrays for saving.
   * It will strip out meta properties etc.
   *
   * @method _cleanObject
   * @param {Object} data
   * @return {{id, type, attributes: {}, relationships: {}}}
   * @private
   */
  _cleanObject(data) {
    if (!data) {
      return null;
    }

    let obj = {
      id: get(data, 'id'),
      type: get(data, 'type'),
      attributes: {},
      relationships: {}
    };

    let attributes = get(data, 'attributes') || {};
    let relationships = get(data, 'relationships') || {};

    let isArray = (item) => {
      return getTypeOf(item) === 'array' || (getTypeOf(item) === 'instance' && getTypeOf(item.toArray) === 'function');
    };

    for (let i in attributes) {
      if (!attributes.hasOwnProperty(i)) {
        continue;
      }

      // Convert array-like structures to real arrays
      if (isArray(attributes[i])) {
        obj.attributes[i] = array(attributes[i]).toArray();
      } else {
        obj.attributes[i] = attributes[i];
      }
    }

    for (let i in relationships) {
      if (!relationships.hasOwnProperty(i)) {
        continue;
      }
      if (isArray(relationships[i].data)) {
        obj.relationships[i] = { data: array(relationships[i].data).toArray() };
      } else {
        obj.relationships[i] = relationships[i];
      }
    }

    return obj;
  },

  /**
   * Convert a property to a string.
   *
   * @method _toString
   * @param {Mixed} val
   * @return {String}
   * @private
   */
  _toString(val) {
    return `${val}`;
  },

  /**
   * Convert a boolean to 1/0.
   *
   * @method _toZeroOne
   * @param val
   * @return {1|0}
   * @private
   */
  _toZeroOne(val) {
    return val ? 1 : 0;
  }

});
<|endoftext|>


<commit_before>// watches changes in the file upload control (input[file]) and
// puts the files selected in an attribute
var app = angular.module("webui.directives.fileselect", ["webui.services.deps"]);
app.directive("indeterminate", [
    "$parse", function syncInput (parse) {
        return {
            require  : "ngModel",
            // Restrict the directive so it can only be used as an attribute
            restrict : "A",

            link : function link (scope, elem, attr, ngModelCtrl) {
                // Whenever the bound value of the attribute changes we update
                // use upward emit notification for change to prevent the performance penalty bring by $scope.$watch
                var getter = parse(attr["ngModel"]);
                var setter = getter.assign;
                var children = []; // cache children input
                var get = function () {
                    return getter(scope);
                };
                // the internal 'indeterminate' flag on the attached dom element
                var setIndeterminateState = function (newValue) {
                    elem.prop("indeterminate", newValue);
                };
                var setModelValueWithoutSideEffect = function (newVal) {
                    setter(scope, newVal);
                    ngModelCtrl.$modelValue = newVal;
                    ngModelCtrl.$viewValue = newVal;
                    ngModelCtrl.$render();
                };
                var setWithSideEffect = function (newVal) {
                    ngModelCtrl.$setViewValue(newVal);
                    ngModelCtrl.$render();
                };
                var passIfLeafChild = function (callback) {
                    return function () {
                        if (children.length > 0) {
                            callback.apply(this, arguments);
                        }
                    };
                };
                var passIfNotIsLeafChild = function (callback) {
                    return function () {
                        if (children.length === 0) {
                            callback.apply(this, arguments);
                        }
                    };
                };
                var passThroughThisScope = function (callback) {
                    return function (event) {
                        if (event.targetScope !== event.currentScope) {
                            return callback.apply(this, arguments);
                        }
                    };
                };
                if (attr["indeterminate"] && parse(attr["indeterminate"]).constant && !scope.$eval(attr["indeterminate"])) {
                    // is leaf input, Only receive parent change and emit child change event
                    setIndeterminateState(scope.$eval(attr["indeterminate"]));
                    ngModelCtrl.$viewChangeListeners.push(passIfNotIsLeafChild(function () {
                        scope.$emit("childSelectedChange");
                    }));
                    scope.$on("ParentSelectedChange", passThroughThisScope(
                        function (event, newVal) {
                            setWithSideEffect(newVal);
                        }));
                    scope.$emit("i'm child input", get);
                    setWithSideEffect(get());
                    scope.$emit("childSelectedChange");
                } else {
                    // init first time and only once
                    // establish parent-child's relation
                    scope.$on("i'm child input", passThroughThisScope(
                        function (event, child) {
                            children.push(child);
                        })
                    );
                    var updateBaseOnChildrenState = function () {
                        var allSelected = children.every(function (child) {
                            return child();
                        });
                        var anySeleted = children.some(function (child) {
                            return child();
                        });
                        setIndeterminateState(allSelected !== anySeleted);
                        setWithSideEffect(allSelected);
                    };
                    // is not leaf input, Only receive child change and parent change event
                    ngModelCtrl.$viewChangeListeners.push(passIfLeafChild(function () {
                        if (!elem.prop("indeterminate")) { // emit when prop indeterminate is set to false
                            scope.$broadcast("ParentSelectedChange", get());
                        }
                    }));
                    // reset input state base on children inputs
                    scope.$on("childSelectedChange", passThroughThisScope(passIfLeafChild(updateBaseOnChildrenState)));
                }
            }
        };
    }
]);
<commit_msg>add more comment<commit_after>// watches changes in the file upload control (input[file]) and
// puts the files selected in an attribute
var app = angular.module("webui.directives.fileselect", ["webui.services.deps"]);
app.directive("indeterminate", [
    "$parse", function syncInput (parse) {
        return {
            require  : "ngModel",
            // Restrict the directive so it can only be used as an attribute
            restrict : "A",

            link : function link (scope, elem, attr, ngModelCtrl) {
                // Whenever the bound value of the attribute changes we update
                // use upward emit notification for change to prevent the performance penalty bring by $scope.$watch
                var getter = parse(attr["ngModel"]);
                var setter = getter.assign;
                var children = []; // cache children input
                var get = function () {
                    return getter(scope);
                };
                // the internal 'indeterminate' flag on the attached dom element
                var setIndeterminateState = function (newValue) {
                    elem.prop("indeterminate", newValue);
                };
                var setWithSideEffect = function (newVal) {
                    ngModelCtrl.$setViewValue(newVal);
                    ngModelCtrl.$render();
                };
                var passIfLeafChild = function (callback) { // ensure to execute callback when this input have one or more subinputs
                    return function () {
                        if (children.length > 0) {
                            callback.apply(this, arguments);
                        }
                    };
                };
                var passIfNotIsLeafChild = function (callback) { // ensure to execute callback when this input havent subinput
                    return function () {
                        if (children.length === 0) {
                            callback.apply(this, arguments);
                        }
                    };
                };
                var passThroughThisScope = function (callback) { // pass through the event from the scope where they sent
                    return function (event) {
                        if (event.targetScope !== event.currentScope) {
                            return callback.apply(this, arguments);
                        }
                    };
                };
                if (attr["indeterminate"] && parse(attr["indeterminate"]).constant && !scope.$eval(attr["indeterminate"])) {
                    // is leaf input, Only receive parent change and emit child change event
                    setIndeterminateState(scope.$eval(attr["indeterminate"]));
                    ngModelCtrl.$viewChangeListeners.push(passIfNotIsLeafChild(function () {
                        scope.$emit("childSelectedChange");
                    }));
                    scope.$on("ParentSelectedChange", passThroughThisScope(
                        function (event, newVal) {
                            setWithSideEffect(newVal); // set value to parent's value; this will cause listener to emit childChange event; this won't be a infinite loop
                        }));
                    // init first time and only once
                    scope.$emit("i'm child input", get);
                    scope.$emit("childSelectedChange"); // force emitted, and force the parent change their state base on children at first time
                } else {
                    // establish parent-child's relation
                    // listen for the child emitted token
                    scope.$on("i'm child input", passThroughThisScope(
                        function (event, child) {
                            children.push(child);
                        })
                    );
                    var updateBaseOnChildrenState = function () {
                        var allSelected = children.every(function (child) {
                            return child();
                        });
                        var anySeleted = children.some(function (child) {
                            return child();
                        });
                        setIndeterminateState(allSelected !== anySeleted); // if at least one is selected, but not all then set input property indeterminate to true
                        setWithSideEffect(allSelected);
                    };
                    // is not leaf input, Only receive child change and parent change event
                    ngModelCtrl.$viewChangeListeners.push(passIfLeafChild(function () {
                        // emit when property indeterminate is set to false, prevent recursively emitting event from parent to children, children to parent
                        if (!elem.prop("indeterminate")) {
                            scope.$broadcast("ParentSelectedChange", get());
                        }
                    }));
                    // reset input state base on children inputs
                    scope.$on("childSelectedChange", passThroughThisScope(passIfLeafChild(updateBaseOnChildrenState)));
                }
            }
        };
    }
]);
<|endoftext|>


<commit_before>/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2014-2015 Broad Institute
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

"use strict";

var igv = (function (igv) {

    const MAX_GZIP_BLOCK_SIZE = (1 << 16);

    var queryableFormats = new Set(["bigwig", "bw", "bigbed", "bb", "tdf"]);

    /**
     * feature source for "bed like" files (tab delimited files with 1 feature per line: bed, gff, vcf, etc)
     *
     * @param config
     * @constructor
     */
    igv.FeatureSource = function (config, genome) {

        this.config = config || {};
        this.genome = genome;

        this.sourceType = (config.sourceType === undefined ? "file" : config.sourceType);

        // Default GFF filter -- these feature types will be filtered out
        if (undefined === config.filterTypes) {
            config.filterTypes = ['chromosome', 'gene']
        }

        if (config.features && Array.isArray(config.features)) {
            let features = config.features;
            if (config.mappings) {
                mapProperties(features, config.mappings)
            }
            this.featureCache = new igv.FeatureCache(features, genome);
            this.static = true;
        } else if (config.sourceType === "ga4gh") {
            this.reader = new igv.Ga4ghVariantReader(config, genome);
            this.queryable = true;
        } else if (config.sourceType === "immvar") {
            this.reader = new igv.ImmVarReader(config);
            this.queryable = true;
        } else if (config.type === "eqtl" && config.sourceType === "gtex-ws") {
            this.reader = new igv.GtexReader(config);
            this.queryable = true;
        } else if (config.sourceType === "bigquery") {
            this.reader = new igv.BigQueryFeatureReader(config);
            this.queryable = true;
        } else if (config.sourceType === 'ucscservice') {
            this.reader = new igv.UCSCServiceReader(config.source);
            this.queryable = true;
        } else if (config.sourceType === 'custom' || config.source !== undefined) {    // Second test for backward compatibility
            this.reader = new igv.CustomServiceReader(config.source);
            this.queryable = config.source.queryable !== undefined ? config.source.queryable : true;
        } else if ("civic-ws" === config.sourceType) {
            this.reader = new igv.CivicReader(config);
            this.queryable = false;
        } else {
            this.reader = new igv.FeatureFileReader(config, genome);
            if (config.queryable != undefined) {
                this.queryable = config.queryable
            } else if (queryableFormats.has(config.format)) {
                this.queryable = queryableFormats.has(config.format) || reader.indexed;
            }
            else {
                // Leav undefined -- will defer until we know if reader has an index
            }
        }

    };

    igv.FeatureSource.prototype.supportsWholeGenome = function () {
        return !this.queryable;
    }

    igv.FeatureSource.prototype.getFileHeader = async function () {

        if (!this.header) {
            if (this.reader && typeof this.reader.readHeader === "function") {

                const header = await this.reader.readHeader()
                if (header) {
                    this.header = header;
                    // Non-indexed readers will return features as a side effect (entire file is read).
                    const features = header.features;
                    if (features) {
                        this.ingestFeatures(features);
                    }
                    if (header.format)
                        this.config.format = header.format;
                }
                this.header = header;
            }
            else {
                this.header = {};
            }
        }
        return this.header

    };

    function addFeaturesToDB(featureList) {
        let self = this;

        featureList.forEach(function (feature) {
            if (feature.name) {
                //TODO igv.browser => igv.Globals or igv.FeatureDB
                self.config.browser.featureDB[feature.name.toLowerCase()] = feature;
            }
        });

    }

    /**
     * Required function for all data source objects.  Fetches features for the
     * range requested and passes them on to the success function.  Usually this is
     * a function that renders the features on the canvas
     *
     * @param chr
     * @param bpStart
     * @param bpEnd
     * @param bpPerPixel
     */

    igv.FeatureSource.prototype.getFeatures = async function (chr, bpStart, bpEnd, bpPerPixel, visibilityWindow) {

        const reader = this.reader;
        const genome = this.genome;
        const queryChr = genome ? genome.getChromosomeName(chr) : chr;
        const maxRows = this.config.maxRows || 500;
        const featureCache = await getFeatureCache.call(this)
        const isQueryable = this.queryable;

        if ("all" === chr.toLowerCase()) {
            if (isQueryable) {   // queryable sources don't support whole genome view
                return [];
            }
            else {
                return this.getWGFeatures(featureCache.getAllFeatures());
            }
        }
        else {
            return this.featureCache.queryFeatures(queryChr, bpStart, bpEnd);
        }

        async function getFeatureCache() {

            let intervalStart = bpStart;
            let intervalEnd = bpEnd;
            let genomicInterval = new igv.GenomicInterval(queryChr, intervalStart, intervalEnd);

            if (this.featureCache &&
                (this.static || this.featureCache.containsRange(genomicInterval) || "all" === chr.toLowerCase())) {

                return this.featureCache;
            }
            else {

                // If a visibility window is defined, potentially expand query interval.
                // This can save re-queries as we zoom out.  Visibility window <= 0 is a special case
                // indicating whole chromosome should be read at once.
                if (visibilityWindow <= 0) {
                    // Whole chromosome
                    intervalStart = 0;
                    intervalEnd = Number.MAX_VALUE;
                }
                else if (visibilityWindow > (bpEnd - bpStart)) {
                    const expansionWindow = Math.min(4.1 * (bpEnd - bpStart), visibilityWindow)
                    intervalStart = Math.max(0, (bpStart + bpEnd - expansionWindow) / 2);
                    intervalEnd = bpStart + expansionWindow;
                }
                genomicInterval = new igv.GenomicInterval(queryChr, intervalStart, intervalEnd);

                let featureList = await reader.readFeatures(queryChr, genomicInterval.start, genomicInterval.end)

                if (this.queryable === undefined) {
                    this.queryable = reader.indexed;
                }

                if (featureList) {
                    this.ingestFeatures(featureList, genomicInterval);
                }
                else {
                    this.featureCache = new igv.FeatureCache();     // Empty cache
                }

                return this.featureCache;

            }
        }
    };

    igv.FeatureSource.prototype.ingestFeatures = function (featureList, genomicInterval) {

        if ("gtf" === this.config.format || "gff3" === this.config.format || "gff" === this.config.format) {
            featureList = (new igv.GFFHelper(this.config)).combineFeatures(featureList);
        }

        // Assign overlapping features to rows
        packFeatures(featureList, this.maxRows);

        // Note - replacing previous cache with new one
        this.featureCache = this.queryable ?
            new igv.FeatureCache(featureList, this.genome, genomicInterval) :
            new igv.FeatureCache(featureList, this.genome);

        // If track is marked "searchable"< cache features by name -- use this with caution, memory intensive
        if (this.config.searchable) {
            addFeaturesToDB.call(this, featureList);
        }
    }

    function packFeatures(features, maxRows) {

        if (features == null || features.length === 0) {
            return;
        }

        // Segregate by chromosome

        var chrFeatureMap = {},
            chrs = [];
        features.forEach(function (feature) {

            var chr = feature.chr,
                flist = chrFeatureMap[chr];

            if (!flist) {
                flist = [];
                chrFeatureMap[chr] = flist;
                chrs.push(chr);
            }

            flist.push(feature);
        });

        // Loop through chrosomosomes and pack features;

        chrs.forEach(function (chr) {

            pack(chrFeatureMap[chr], maxRows);
        });

        // Assigns a row # to each feature.  If the feature does not fit in any row and #rows == maxRows no
        // row number is assigned.
        function pack(featureList, maxRows) {

            const rows = [];
            featureList.sort(function (a, b) {
                return a.start - b.start;
            })
            rows.push(-1000);

            featureList.forEach(function (feature) {

                let r = 0
                const len = Math.min(rows.length, maxRows)
                for (r = 0; r < len; r++) {
                    if (feature.start > rows[r]) {
                        feature.row = r;
                        rows[r] = feature.end;
                        return;
                    }
                }
                feature.row = r;
                rows[r] = feature.end;

            });
        }
    }

    // TODO -- filter by pixel size
    igv.FeatureSource.prototype.getWGFeatures = function (features) {

        const genome = this.genome;

        const wgChromosomeNames = new Set(genome.wgChromosomeNames);

        const wgFeatures = [];

        for (let f of features) {

            let queryChr = genome.getChromosomeName(f.chr);

            if (wgChromosomeNames.has(queryChr)) {

                const wg = Object.create(Object.getPrototypeOf(f));
                Object.assign(wg, f);

                wg.realChr = f.chr;
                wg.realStart = f.start;
                wg.realEnd = f.end;

                wg.chr = "all";
                wg.start = genome.getGenomeCoordinate(f.chr, f.start);
                wg.end = genome.getGenomeCoordinate(f.chr, f.end);

                // Don't draw exons in whole genome view
                if (wg["exons"]) delete wg["exons"]

                wg.popupData = function (genomeLocation) {
                    const clonedObject = Object.assign({}, this)
                    clonedObject.chr = this.realChr
                    clonedObject.start = this.realStart
                    clonedObject.end = this.realEnd
                    delete clonedObject.realChr
                    delete clonedObject.realStart
                    delete clonedObject.realEnd
                    return igv.TrackBase.extractPopupData(clonedObject, genome.id)
                }

                wgFeatures.push(wg);
            }
        }

        wgFeatures.sort(function (a, b) {
            return a.start - b.start;
        });

        return wgFeatures;

    }

    function mapProperties(features, mappings) {
        let mappingKeys = Object.keys(mappings);
        features.forEach(function (f) {
            mappingKeys.forEach(function (key) {
                f[key] = f[mappings[key]];
            });
        });
    }

    return igv;
})
(igv || {});
<commit_msg>feature packing bug fixes #960<commit_after>/*
 * The MIT License (MIT)
 *
 * Copyright (c) 2014-2015 Broad Institute
 *
 * Permission is hereby granted, free of charge, to any person obtaining a copy
 * of this software and associated documentation files (the "Software"), to deal
 * in the Software without restriction, including without limitation the rights
 * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
 * copies of the Software, and to permit persons to whom the Software is
 * furnished to do so, subject to the following conditions:
 *
 * The above copyright notice and this permission notice shall be included in
 * all copies or substantial portions of the Software.
 *
 *
 * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
 * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
 * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
 * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
 * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
 * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
 * THE SOFTWARE.
 */

"use strict";

var igv = (function (igv) {

    const MAX_GZIP_BLOCK_SIZE = (1 << 16);

    var queryableFormats = new Set(["bigwig", "bw", "bigbed", "bb", "tdf"]);

    /**
     * feature source for "bed like" files (tab delimited files with 1 feature per line: bed, gff, vcf, etc)
     *
     * @param config
     * @constructor
     */
    igv.FeatureSource = function (config, genome) {

        this.config = config || {};
        this.genome = genome;

        this.sourceType = (config.sourceType === undefined ? "file" : config.sourceType);

        // Default GFF filter -- these feature types will be filtered out
        if (undefined === config.filterTypes) {
            config.filterTypes = ['chromosome', 'gene']
        }

        if (config.features && Array.isArray(config.features)) {
            let features = config.features;
            if (config.mappings) {
                mapProperties(features, config.mappings)
            }
            this.featureCache = new igv.FeatureCache(features, genome);
            this.static = true;
        } else if (config.sourceType === "ga4gh") {
            this.reader = new igv.Ga4ghVariantReader(config, genome);
            this.queryable = true;
        } else if (config.sourceType === "immvar") {
            this.reader = new igv.ImmVarReader(config);
            this.queryable = true;
        } else if (config.type === "eqtl" && config.sourceType === "gtex-ws") {
            this.reader = new igv.GtexReader(config);
            this.queryable = true;
        } else if (config.sourceType === "bigquery") {
            this.reader = new igv.BigQueryFeatureReader(config);
            this.queryable = true;
        } else if (config.sourceType === 'ucscservice') {
            this.reader = new igv.UCSCServiceReader(config.source);
            this.queryable = true;
        } else if (config.sourceType === 'custom' || config.source !== undefined) {    // Second test for backward compatibility
            this.reader = new igv.CustomServiceReader(config.source);
            this.queryable = config.source.queryable !== undefined ? config.source.queryable : true;
        } else if ("civic-ws" === config.sourceType) {
            this.reader = new igv.CivicReader(config);
            this.queryable = false;
        } else {
            this.reader = new igv.FeatureFileReader(config, genome);
            if (config.queryable != undefined) {
                this.queryable = config.queryable
            } else if (queryableFormats.has(config.format)) {
                this.queryable = queryableFormats.has(config.format) || reader.indexed;
            }
            else {
                // Leav undefined -- will defer until we know if reader has an index
            }
        }

    };

    igv.FeatureSource.prototype.supportsWholeGenome = function () {
        return !this.queryable;
    }

    igv.FeatureSource.prototype.getFileHeader = async function () {

        if (!this.header) {
            if (this.reader && typeof this.reader.readHeader === "function") {

                const header = await this.reader.readHeader()
                if (header) {
                    this.header = header;
                    // Non-indexed readers will return features as a side effect (entire file is read).
                    const features = header.features;
                    if (features) {
                        this.ingestFeatures(features);
                    }
                    if (header.format)
                        this.config.format = header.format;
                }
                this.header = header;
            }
            else {
                this.header = {};
            }
        }
        return this.header

    };

    function addFeaturesToDB(featureList) {
        let self = this;

        featureList.forEach(function (feature) {
            if (feature.name) {
                //TODO igv.browser => igv.Globals or igv.FeatureDB
                self.config.browser.featureDB[feature.name.toLowerCase()] = feature;
            }
        });

    }

    /**
     * Required function for all data source objects.  Fetches features for the
     * range requested and passes them on to the success function.  Usually this is
     * a function that renders the features on the canvas
     *
     * @param chr
     * @param bpStart
     * @param bpEnd
     * @param bpPerPixel
     */

    igv.FeatureSource.prototype.getFeatures = async function (chr, bpStart, bpEnd, bpPerPixel, visibilityWindow) {

        const reader = this.reader;
        const genome = this.genome;
        const queryChr = genome ? genome.getChromosomeName(chr) : chr;
        const featureCache = await getFeatureCache.call(this)
        const isQueryable = this.queryable;

        if ("all" === chr.toLowerCase()) {
            if (isQueryable) {   // queryable sources don't support whole genome view
                return [];
            }
            else {
                return this.getWGFeatures(featureCache.getAllFeatures());
            }
        }
        else {
            return this.featureCache.queryFeatures(queryChr, bpStart, bpEnd);
        }

        async function getFeatureCache() {

            let intervalStart = bpStart;
            let intervalEnd = bpEnd;
            let genomicInterval = new igv.GenomicInterval(queryChr, intervalStart, intervalEnd);

            if (this.featureCache &&
                (this.static || this.featureCache.containsRange(genomicInterval) || "all" === chr.toLowerCase())) {

                return this.featureCache;
            }
            else {

                // If a visibility window is defined, potentially expand query interval.
                // This can save re-queries as we zoom out.  Visibility window <= 0 is a special case
                // indicating whole chromosome should be read at once.
                if (visibilityWindow <= 0) {
                    // Whole chromosome
                    intervalStart = 0;
                    intervalEnd = Number.MAX_VALUE;
                }
                else if (visibilityWindow > (bpEnd - bpStart)) {
                    const expansionWindow = Math.min(4.1 * (bpEnd - bpStart), visibilityWindow)
                    intervalStart = Math.max(0, (bpStart + bpEnd - expansionWindow) / 2);
                    intervalEnd = bpStart + expansionWindow;
                }
                genomicInterval = new igv.GenomicInterval(queryChr, intervalStart, intervalEnd);

                let featureList = await reader.readFeatures(queryChr, genomicInterval.start, genomicInterval.end)

                if (this.queryable === undefined) {
                    this.queryable = reader.indexed;
                }

                if (featureList) {
                    this.ingestFeatures(featureList, genomicInterval);
                }
                else {
                    this.featureCache = new igv.FeatureCache();     // Empty cache
                }

                return this.featureCache;

            }
        }
    };

    igv.FeatureSource.prototype.ingestFeatures = function (featureList, genomicInterval) {

        if ("gtf" === this.config.format || "gff3" === this.config.format || "gff" === this.config.format) {
            featureList = (new igv.GFFHelper(this.config)).combineFeatures(featureList);
        }

        // Assign overlapping features to rows
        const maxRows = this.config.maxRows || 500;
        packFeatures(featureList, maxRows);

        // Note - replacing previous cache with new one
        this.featureCache = this.queryable ?
            new igv.FeatureCache(featureList, this.genome, genomicInterval) :
            new igv.FeatureCache(featureList, this.genome);

        // If track is marked "searchable"< cache features by name -- use this with caution, memory intensive
        if (this.config.searchable) {
            addFeaturesToDB.call(this, featureList);
        }
    }

    function packFeatures(features, maxRows) {

        if (features == null || features.length === 0) {
            return;
        }

        // Segregate by chromosome

        var chrFeatureMap = {},
            chrs = [];
        features.forEach(function (feature) {

            var chr = feature.chr,
                flist = chrFeatureMap[chr];

            if (!flist) {
                flist = [];
                chrFeatureMap[chr] = flist;
                chrs.push(chr);
            }

            flist.push(feature);
        });

        // Loop through chrosomosomes and pack features;

        chrs.forEach(function (chr) {

            pack(chrFeatureMap[chr], maxRows);
        });

        // Assigns a row # to each feature.  If the feature does not fit in any row and #rows == maxRows no
        // row number is assigned.
        function pack(featureList, maxRows) {

            const rows = [];
            featureList.sort(function (a, b) {
                return a.start - b.start;
            })
            rows.push(-1000);

            featureList.forEach(function (feature) {

                let r = 0
                const len = Math.min(rows.length, maxRows)
                for (r = 0; r < len; r++) {
                    if (feature.start > rows[r]) {
                        feature.row = r;
                        rows[r] = feature.end;
                        return;
                    }
                }
                feature.row = r;
                rows[r] = feature.end;

            });
        }
    }

    // TODO -- filter by pixel size
    igv.FeatureSource.prototype.getWGFeatures = function (features) {

        const genome = this.genome;

        const wgChromosomeNames = new Set(genome.wgChromosomeNames);

        const wgFeatures = [];

        for (let f of features) {

            let queryChr = genome.getChromosomeName(f.chr);

            if (wgChromosomeNames.has(queryChr)) {

                const wg = Object.create(Object.getPrototypeOf(f));
                Object.assign(wg, f);

                wg.realChr = f.chr;
                wg.realStart = f.start;
                wg.realEnd = f.end;

                wg.chr = "all";
                wg.start = genome.getGenomeCoordinate(f.chr, f.start);
                wg.end = genome.getGenomeCoordinate(f.chr, f.end);

                // Don't draw exons in whole genome view
                if (wg["exons"]) delete wg["exons"]

                wg.popupData = function (genomeLocation) {
                    const clonedObject = Object.assign({}, this)
                    clonedObject.chr = this.realChr
                    clonedObject.start = this.realStart
                    clonedObject.end = this.realEnd
                    delete clonedObject.realChr
                    delete clonedObject.realStart
                    delete clonedObject.realEnd
                    return igv.TrackBase.extractPopupData(clonedObject, genome.id)
                }

                wgFeatures.push(wg);
            }
        }

        wgFeatures.sort(function (a, b) {
            return a.start - b.start;
        });

        return wgFeatures;

    }

    function mapProperties(features, mappings) {
        let mappingKeys = Object.keys(mappings);
        features.forEach(function (f) {
            mappingKeys.forEach(function (key) {
                f[key] = f[mappings[key]];
            });
        });
    }

    return igv;
})
(igv || {});
<|endoftext|>