<commit_before>/*
 * freepano - WebGL panorama viewer
 *
 * Copyright (c) 2014 FOXEL SA - http://foxel.ch
 * Please read <http://foxel.ch/license> for more information.
 *
 *
 * Author(s):
 *
 *      Kevin Velickovic <k.velickovic@foxel.ch>
 *      Luc Deschenaux <l.deschenaux@foxel.ch>
 *
 *
 * This file is part of the FOXEL project <http://foxel.ch>.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 *
 * Additional Terms:
 *
 *      You are required to preserve legal notices and author attributions in
 *      that material or in the Appropriate Legal Notices displayed by works
 *      containing it.
 *
 *      You are required to attribute the work as explained in the "Usage and
 *      Attribution" section of <http://foxel.ch/license>.
 */

// Plugin constructor
function Map(options)
{
    // merge specified options with default options and extend this instance
    $.extend(true,this,Map.prototype.defaults,options);

    // Initialize plugin
    this.init();
}

// Extend plugin prototype
$.extend(true, Map.prototype, {

    // default values
    defaults: {
        // active
        active: false
    },

    show: function map_show(map) {

        // dom
        var pano = map.panorama;
        var container = $(pano.container);

        // Create map container
        var mapContainer = $('
',{
            id: 'mapContainer'
        });

        // Append map
        container.append(mapContainer);

        // Create leaflet map object
        var map = L.map('mapContainer').setView([51.505, -0.09], 0);

        // add an OpenStreetMap tile layer
        L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap contributors'
        }).addTo(map);

        // Compute icon sizes and anchors
        var iconSize     = [22.5, 36.25];
        var shadowSize   = [40.75, 36.25];
        var iconAnchor   = [iconSize[0] / 2, 36.25];
        var shadowAnchor = [iconSize[0] / 2, 36.25];

        // Create marker icon
        var markerIcon = L.icon({
            iconUrl:     'img/marker_icon.png', // Icon image file path
            shadowUrl:   'img/marker_shadow.png', // Shadow image file path

            iconSize:     iconSize, // size of the icon
            shadowSize:   shadowSize, // size of the shadow

            iconAnchor:   iconAnchor, // point of the icon which will correspond to marker's location
            shadowAnchor: shadowAnchor,  // the same for the shadow

            popupAnchor:  [0, 0] // point from which the popup should open relative to the iconAnchor
        });

        // Create highlighted marker icon
        var markerIcon_Highlighted = L.icon({
            iconUrl:     'img/marker_icon_highlight.png', // Icon image file path
            shadowUrl:   'img/marker_shadow.png', // Shadow image file path

            iconSize:     iconSize, // size of the icon
            shadowSize:   shadowSize, // size of the shadow

            iconAnchor:   iconAnchor, // point of the icon which will correspond to marker's location
            shadowAnchor: shadowAnchor,  // the same for the shadow

            popupAnchor:  [0, 0] // point from which the popup should open relative to the iconAnchor
        });

        var markers = [];

        // Iterate over panoramas and create markers
        $.each(pano.list.images, function( index, value ) {

            // Determine if the marker is highlighted
            var icon = markerIcon;

            if ( pano.list.currentImage == index )
                icon = markerIcon_Highlighted;

            // Create marker
            var marker = L.marker([value.coords.lat, value.coords.lon], {icon: icon})
            .on('click', function(e) {

                // Reset all markers to default icon
                $.each(markers, function( index, value ) {
                    value.setIcon(markerIcon);
                });

                // Set highlighted icon for marker
                marker.setIcon(markerIcon_Highlighted);

                // Check if panorama has changed, then change it
                if(e.target.panorama.index != pano.list.currentImage)
                    pano.list.show(e.target.panorama.index);
            });

            // Extend marker with panorama object
            $.extend( marker, {
                panorama: {
                    index: index,
                    value: value
                }
            });

            // Add marker to array
            markers.push( marker )

            // Add marker to map
            marker.addTo(map)

        });

        // Create makers featureGroup
        var markersGroup = new L.featureGroup(markers);

        // Fit map to markers
        map.fitBounds(markersGroup.getBounds());

    },

    hide: function map_hide(map) {
        $("#mapContainer").remove();
    },

    init: function map_init() {
        var map = this;

        if (map.active)
            map.show(map);

        // watch touch move properties
        watch(map,['active'], function() {
            if (map.active)
                map.show(map);
            else
                map.hide(map);
        });

        map.callback();
    }

});

// register freepano.map plugin

$.extend(Map.prototype,{
    // save pointer to Panorama.prototype.init in Map.prototype
    panorama_init: Panorama.prototype.init
});

$.extend(Panorama.prototype,{

  // hook Map.prototype.init to Panorama.prototype.init
  init: function panorama_init() {

    var panorama=this;

    // skip Map instantiation if map preferences undefined in panorama
    if (panorama.map!==undefined) {

      // or if map is already instantiated
      if (!(panorama.map instanceof Map)) {

        // intantiate map
        panorama.map=new Map($.extend(true,{

          // pass panorama instance pointer to map instance
          panorama: panorama,

          // to be run when map is instantiated
          callback: function() {

            // chain with old panorama.prototype.init on callback
            Map.prototype.panorama_init.call(panorama);

          }

        },panorama.map));

      }

    } else {

      // chain with old panorama.prototype.init
      Map.prototype.panorama_init.call(panorama);

    }
  }
});

<commit_msg>Fix currentImage on initialization of the map<commit_after>/*
 * freepano - WebGL panorama viewer
 *
 * Copyright (c) 2014 FOXEL SA - http://foxel.ch
 * Please read <http://foxel.ch/license> for more information.
 *
 *
 * Author(s):
 *
 *      Kevin Velickovic <k.velickovic@foxel.ch>
 *      Luc Deschenaux <l.deschenaux@foxel.ch>
 *
 *
 * This file is part of the FOXEL project <http://foxel.ch>.
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
 *
 * Additional Terms:
 *
 *      You are required to preserve legal notices and author attributions in
 *      that material or in the Appropriate Legal Notices displayed by works
 *      containing it.
 *
 *      You are required to attribute the work as explained in the "Usage and
 *      Attribution" section of <http://foxel.ch/license>.
 */

// Plugin constructor
function Map(options)
{
    // merge specified options with default options and extend this instance
    $.extend(true,this,Map.prototype.defaults,options);

    // Initialize plugin
    this.init();
}

// Extend plugin prototype
$.extend(true, Map.prototype, {

    // default values
    defaults: {
        // active
        active: false
    },

    show: function map_show(map) {

        // dom
        var pano = map.panorama;
        var container = $(pano.container);

        // Create map container
        var mapContainer = $('
',{
            id: 'mapContainer'
        });

        // Append map
        container.append(mapContainer);

        // Create leaflet map object
        var map = L.map('mapContainer').setView([51.505, -0.09], 0);

        // add an OpenStreetMap tile layer
        L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png', {
            attribution: '© OpenStreetMap contributors'
        }).addTo(map);

        // Compute icon sizes and anchors
        var iconSize     = [22.5, 36.25];
        var shadowSize   = [40.75, 36.25];
        var iconAnchor   = [iconSize[0] / 2, 36.25];
        var shadowAnchor = [iconSize[0] / 2, 36.25];

        // Create marker icon
        var markerIcon = L.icon({
            iconUrl:     'img/marker_icon.png', // Icon image file path
            shadowUrl:   'img/marker_shadow.png', // Shadow image file path

            iconSize:     iconSize, // size of the icon
            shadowSize:   shadowSize, // size of the shadow

            iconAnchor:   iconAnchor, // point of the icon which will correspond to marker's location
            shadowAnchor: shadowAnchor,  // the same for the shadow

            popupAnchor:  [0, 0] // point from which the popup should open relative to the iconAnchor
        });

        // Create highlighted marker icon
        var markerIcon_Highlighted = L.icon({
            iconUrl:     'img/marker_icon_highlight.png', // Icon image file path
            shadowUrl:   'img/marker_shadow.png', // Shadow image file path

            iconSize:     iconSize, // size of the icon
            shadowSize:   shadowSize, // size of the shadow

            iconAnchor:   iconAnchor, // point of the icon which will correspond to marker's location
            shadowAnchor: shadowAnchor,  // the same for the shadow

            popupAnchor:  [0, 0] // point from which the popup should open relative to the iconAnchor
        });

        var markers = [];

        // Iterate over panoramas and create markers
        $.each(pano.list.images, function( index, value ) {

            // Icon
            var icon = markerIcon;

            // Use first image if currentImage is not defined
            if (pano.list.currentImage === undefined)
                pano.list.currentImage = Object.keys(pano.list.images)[0];

            // Determine if the marker is highlighted
            if ( pano.list.currentImage == index )
                icon = markerIcon_Highlighted;

            // Create marker
            var marker = L.marker([value.coords.lat, value.coords.lon], {icon: icon})
            .on('click', function(e) {

                // Reset all markers to default icon
                $.each(markers, function( index, value ) {
                    value.setIcon(markerIcon);
                });

                // Set highlighted icon for marker
                marker.setIcon(markerIcon_Highlighted);

                // Check if panorama has changed, then change it
                if(e.target.panorama.index != pano.list.currentImage)
                    pano.list.show(e.target.panorama.index);
            });

            // Extend marker with panorama object
            $.extend( marker, {
                panorama: {
                    index: index,
                    value: value
                }
            });

            // Add marker to array
            markers.push( marker )

            // Add marker to map
            marker.addTo(map)

        });

        // Create makers featureGroup
        var markersGroup = new L.featureGroup(markers);

        // Fit map to markers
        map.fitBounds(markersGroup.getBounds());

    },

    hide: function map_hide(map) {
        $("#mapContainer").remove();
    },

    init: function map_init() {
        var map = this;

        if (map.active)
            map.show(map);

        // watch touch move properties
        watch(map,['active'], function() {
            if (map.active)
                map.show(map);
            else
                map.hide(map);
        });

        map.callback();
    }

});

// register freepano.map plugin

$.extend(Map.prototype,{
    // save pointer to Panorama.prototype.init in Map.prototype
    panorama_init: Panorama.prototype.init
});

$.extend(Panorama.prototype,{

  // hook Map.prototype.init to Panorama.prototype.init
  init: function panorama_init() {

    var panorama=this;

    // skip Map instantiation if map preferences undefined in panorama
    if (panorama.map!==undefined) {

      // or if map is already instantiated
      if (!(panorama.map instanceof Map)) {

        // intantiate map
        panorama.map=new Map($.extend(true,{

          // pass panorama instance pointer to map instance
          panorama: panorama,

          // to be run when map is instantiated
          callback: function() {

            // chain with old panorama.prototype.init on callback
            Map.prototype.panorama_init.call(panorama);

          }

        },panorama.map));

      }

    } else {

      // chain with old panorama.prototype.init
      Map.prototype.panorama_init.call(panorama);

    }
  }
});

<|endoftext|>


<commit_before>class MediaLibraryVideo {
  constructor ({data}) {
    this.data = data
  }

  static get toolbox () {
    return {
      title: 'Video',
      icon: '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"/></svg>'
    }
  }

  /**
   * CSS classes
   *
   * @returns {{wrapper: string, wrapperLabel: string, input: string, label: string, settingsWrapper: string, settingsButton: string, settingsButtonActive: string}}
   */
  get CSS () {
    return {
      wrapper: 'ce-wrapper',
      videoWrapper: ['ce-video-wrapper', 'd-none'],
      videoWrapperEditButton: ['btn', 'btn-primary', 'btn-sm', 'btn-icon-only', 'ce-btn-edit']
    }
  }

  selectFromMediaLibrary () {
    window.open(window.location.origin + jsData.MediaLibrary.browseActionVideos)

    window.onmessage = (event) => {
      if (event.data && typeof event.data === 'object' && 'media-url' in event.data) {
        this.data.videoId = event.data['media-url'].split('v=')[1]
        this.data.id = event.data.id
        this.iframe.src = 'https://www.youtube-nocookie.com/embed/' + this.data.videoId
        this.videoWrapper.classList.remove('d-none')
      }
    }
  }

  render () {
    this.wrapper = this._make('div', this.CSS.wrapper)

    this.videoWrapper = this._make('div', this.CSS.videoWrapper)
    this.iframeWrapper = this._make('div', ['embed-responsive', 'embed-responsive-16by9'])
    this.iframe = this._make('iframe', ['embed-responsive-item'], {},
      {
        'frameborder': '0',
        'allow': 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
      }
    )

    this.editButton = this._make('button', this.CSS.videoWrapperEditButton, {
      type: 'button'
    })
    this.editButtonIcon = this._make('i', ['fas', 'fa-pencil-alt'])

    this.editButton.appendChild(this.editButtonIcon)
    this.iframeWrapper.appendChild(this.iframe)
    this.videoWrapper.appendChild(this.iframeWrapper)
    this.videoWrapper.appendChild(this.editButton)
    this.wrapper.appendChild(this.videoWrapper)

    $(this.editButton).on('click', $.proxy(this.selectFromMediaLibrary, this))

    if (this.data.src !== undefined) {
      this.iframe.src = this.data.src
      this.videoWrapper.classList.remove('d-none')
    } else {
      this.iframe.src = '#'
      this.videoWrapper.classList.add('d-none')
      this.selectFromMediaLibrary()
    }

    return this.wrapper
  }

  save (blockContent) {
    return {
      'id': this.data.id,
      'videoId': this.data.videoId
    }
  }

  /**
   * Helper for making Elements with attributes
   *
   * @param  {string} tagName           - new Element tag name
   * @param  {Array|string} classNames  - list or name of CSS classname(s)
   * @param  {object} attributes        - any attributes
   * @returns {Element}
   */
  _make (tagName, classNames = null, attributes = {}, customAttributes = {}) {
    const el = document.createElement(tagName)

    if (Array.isArray(classNames)) {
      el.classList.add(...classNames)
    } else if (classNames) {
      el.classList.add(classNames)
    }

    for (const attrName in attributes) {
      el[attrName] = attributes[attrName]
    }

    for (const attrName in customAttributes) {
      el.setAttribute(attrName, customAttributes[attrName])
    }

    return el
  }
}

export default MediaLibraryVideo
<commit_msg>Add video icon<commit_after>class MediaLibraryVideo {
  constructor ({data}) {
    this.data = data
  }

  static get toolbox () {
    return {
      title: 'Video',
      icon: '<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" height="15" width="21.33" viewBox="0 0 546.1 384" style="enable-background:new 0 0 546.1 384;" xml:space="preserve"><path d="M534.7,60.1c-6.3-23.7-24.8-42.3-48.3-48.6C443.8,0,273.1,0,273.1,0S102.3,0,59.7,11.5c-23.5,6.3-42,24.9-48.3,48.6C0,102.9,0,192.4,0,192.4s0,89.4,11.4,132.3c6.3,23.6,24.8,41.5,48.3,47.8C102.3,384,273.1,384,273.1,384s170.8,0,213.4-11.5c23.5-6.3,42-24.2,48.3-47.8c11.4-42.9,11.4-132.3,11.4-132.3S546.1,102.9,534.7,60.1z M217.2,273.6V111.2L360,192.4L217.2,273.6L217.2,273.6z"/></svg>'
    }
  }

  /**
   * CSS classes
   *
   * @returns {{wrapper: string, wrapperLabel: string, input: string, label: string, settingsWrapper: string, settingsButton: string, settingsButtonActive: string}}
   */
  get CSS () {
    return {
      wrapper: 'ce-wrapper',
      videoWrapper: ['ce-video-wrapper', 'd-none'],
      videoWrapperEditButton: ['btn', 'btn-primary', 'btn-sm', 'btn-icon-only', 'ce-btn-edit']
    }
  }

  selectFromMediaLibrary () {
    window.open(window.location.origin + jsData.MediaLibrary.browseActionVideos)

    window.onmessage = (event) => {
      if (event.data && typeof event.data === 'object' && 'media-url' in event.data) {
        this.data.videoId = event.data['media-url'].split('v=')[1]
        this.data.id = event.data.id
        this.iframe.src = 'https://www.youtube-nocookie.com/embed/' + this.data.videoId
        this.videoWrapper.classList.remove('d-none')
      }
    }
  }

  render () {
    this.wrapper = this._make('div', this.CSS.wrapper)

    this.videoWrapper = this._make('div', this.CSS.videoWrapper)
    this.iframeWrapper = this._make('div', ['embed-responsive', 'embed-responsive-16by9'])
    this.iframe = this._make('iframe', ['embed-responsive-item'], {},
      {
        'frameborder': '0',
        'allow': 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture'
      }
    )

    this.editButton = this._make('button', this.CSS.videoWrapperEditButton, {
      type: 'button'
    })
    this.editButtonIcon = this._make('i', ['fas', 'fa-pencil-alt'])

    this.editButton.appendChild(this.editButtonIcon)
    this.iframeWrapper.appendChild(this.iframe)
    this.videoWrapper.appendChild(this.iframeWrapper)
    this.videoWrapper.appendChild(this.editButton)
    this.wrapper.appendChild(this.videoWrapper)

    $(this.editButton).on('click', $.proxy(this.selectFromMediaLibrary, this))

    if (this.data.src !== undefined) {
      this.iframe.src = this.data.src
      this.videoWrapper.classList.remove('d-none')
    } else {
      this.iframe.src = '#'
      this.videoWrapper.classList.add('d-none')
      this.selectFromMediaLibrary()
    }

    return this.wrapper
  }

  save (blockContent) {
    return {
      'id': this.data.id,
      'videoId': this.data.videoId
    }
  }

  /**
   * Helper for making Elements with attributes
   *
   * @param  {string} tagName           - new Element tag name
   * @param  {Array|string} classNames  - list or name of CSS classname(s)
   * @param  {object} attributes        - any attributes
   * @returns {Element}
   */
  _make (tagName, classNames = null, attributes = {}, customAttributes = {}) {
    const el = document.createElement(tagName)

    if (Array.isArray(classNames)) {
      el.classList.add(...classNames)
    } else if (classNames) {
      el.classList.add(classNames)
    }

    for (const attrName in attributes) {
      el[attrName] = attributes[attrName]
    }

    for (const attrName in customAttributes) {
      el.setAttribute(attrName, customAttributes[attrName])
    }

    return el
  }
}

export default MediaLibraryVideo
<|endoftext|>


<commit_before>/*global require: false, module: false */
'use strict';
var _ = require('underscore');
var multi = require('../multi');
var fieldFetch = require('../fieldFetch');
var Rx = require('rx');
var {remapSamples, remapCodes, floatToCoded, concatValuesByFieldPosition,
		concatMutation, computeMean} = require('./fieldData');
var samplesFrom = require('../samplesFrom');
var {nullField} = require('./fieldSpec');

// Strategies for joining field metadata with composite cohorts.

var nonNullFS = fss => fss.filter(fs => fs.fetchType !== 'null');

// normalize by default if all datasets normalize by default.
function getNormalization(fieldSpecs) {
	return _.every(nonNullFS(fieldSpecs), d => d.defaultNormalization);
}

// Join column labels.
function getColumnLabel(fieldSpecs) {
	return _.uniq(_.pluck(nonNullFS(fieldSpecs), 'columnLabel')).join(' / ');
}

// Use default color from first dataset.
function getColorClass(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'colorClass')));

	// If all types are the same, preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// If any are clinical, color as clinical
	if (_.contains(types, 'clinical')) {
		return 'clinical';
	}

	// color as default genomic
	return 'default';
}

var noNullType = ts => ts.filter(t => t !== 'null');

// XXX Need to handle incompatible assemblies in mutation.
function getValueType(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'valueType')));

	// If all types are the same, we can preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// If coded, cast to coded.
	if (_.contains(types, 'coded')) {
		return 'coded';
	}

	// Any other combination, float
	return 'float';
}

// probes, genes, geneProbes, clinical, mutation.
// We shouldn't see geneProbes unless they're all the same.
function getFieldType(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'fieldType')));

	// If all types are the same, we can preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// drop genomic info if mixing with clinincal
	if (_.contains(types, 'clinical')) {
		return 'clinical';
	}

	// treat disparate field types as probes.
	return 'probes';
}

function longest(arrs) {
	return _.max(arrs, arr => arr.length);
}

function hasUniqProbemap(fieldSpecs, datasets) {
	var nnFS = nonNullFS(fieldSpecs);
	return _.uniq(_.map(nnFS, fs => datasets[fs.dsID].probemap)).length  === 1;
}

// Preserve geneProbes matrix only if there's a single field and all datasets
// are geneProbes, and all have the same probemap.
function resetProbesMatrix(len, fieldSpecs, uniqProbemap) {
	var nnFS = nonNullFS(fieldSpecs);
	return (len > 1 || !_.every(nnFS, fs => fs.fieldType === 'geneProbes') || !uniqProbemap) ?
		 _.map(fieldSpecs, fs =>
				_.assoc(fs, 'fieldType', fs.fieldType === 'geneProbes' ? 'genes' : fs.fieldType)) :
		fieldSpecs;
}

// merge, dropping nulls.
var m = (...objs) => _.pick(_.merge(...objs), v => v != null);

var findFirstProp = (fieldSpecs, prop)  =>
	_.get(_.find(fieldSpecs, fs => _.has(fs, prop)), prop);

var getAssembly = (fieldType, fieldSpecs) =>
	fieldType === 'mutation' ? findFirstProp(fieldSpecs, 'assembly') : null;

var getFeature = (fieldType, fieldSpecs) =>
	fieldType === 'mutation' ? findFirstProp(fieldSpecs, 'sFeature') : null;

var getFieldLabel = fieldSpecs => findFirstProp(fieldSpecs, 'fieldLabel');

var fillNullFields = fieldSpecs => _.map(fieldSpecs, fs => fs || nullField);

// Create a composite fieldSpec from a list of fieldSpecs. This uses
// a number of heuristics to determine the 'best' combined view over
// the fieldSpecs, for some definition of 'best'.
//
// For genes/geneProbes, we might have geneProbes fields on different
// probemaps, in which case we want to coerce to 'genes', and prevent the
// user from picking 'geneProbes'. We reset the fieldType here, and set the
// 'noGeneDetail' flag to inform the UI that we can't support a 'geneProbes' view.

function combineColSpecs(fieldSpecs, datasets) {
	var fields = longest(_.pluck(fieldSpecs, 'fields')),
		uniqProbemap = hasUniqProbemap(fieldSpecs, datasets),
		resetFieldSpecs = resetProbesMatrix(fields.len, fieldSpecs, uniqProbemap),
		fieldType = getFieldType(resetFieldSpecs);

	return m({
		fields,
		fieldSpecs: resetFieldSpecs,
		fetchType: 'composite',
		valueType: getValueType(resetFieldSpecs),
		fieldType,
		defaultNormalization: getNormalization(resetFieldSpecs),
		fieldLabel: getFieldLabel(resetFieldSpecs),
		columnLabel: getColumnLabel(resetFieldSpecs),
		colorClass: getColorClass(resetFieldSpecs),
		noGeneDetail: !uniqProbemap, // XXX is this wrong? also have to check field len.
		assembly: getAssembly(fieldType, resetFieldSpecs),
		sFeature: getFeature(fieldType, resetFieldSpecs)
	});
}

// The original idea of fieldSpec was for it to be recursive, so
// we could write nested expression (e.g. signatures). However, there's
// some problem in the data modeling wrt cohorts: how does the list of
// samples connect to a particular fieldSpec? So, here we always return
// a 'composite', even for single datasets.
function getColSpec(fieldSpecs, datasets) {
	return combineColSpecs(fillNullFields(fieldSpecs), datasets);
}

// Convert field valueType.
// (column, fieldSpec, samples, data) => newData
var cvtField = multi((column, field) => `${field.valueType}->${column.valueType}`, 4);

// For probes, genes, data is
// req: {values: {[probe]: {sample: value, ...}, ...}}
cvtField.dflt = (column, fieldSpec, samples, data) => data;

// Column must be single-valued prior to trying to convert to coded.
cvtField.add('float->coded',
		(column, fieldSpec, samples, data) => floatToCoded(data));

cvtField.add('null->coded', () => {
	return {
		codes: [],
		req: {
			values: [],
			mean: []
		}
	};
});

cvtField.add('null->float', () => {
	return {
		req: {
			values: [],
			mean: []
		}
	};
});

cvtField.add('null->mutation', () => {
	return {
		req: {
			rows: [],
			samplesInResp: []
		}
	};
});

cvtField.add('mutation->float', (column, field, samples, acc, data) => {
	var bySampleID = _.groupBy(data.rows, 'sampleID');
	return {
		req: {
			values: _.map(samples, id => bySampleID[id].length > 0 ? 1 : 0)
		}
	};
});

// We are computing bySample also in the app selector.
cvtField.add('mutation->coded', (column, field, samples, acc, data) => {
	var bySampleID = _.groupBy(data.rows, 'sampleID');
	return {
		req: {
			codes: ['no mutation', 'has mutation'],
			values: _.map(samples, id => bySampleID[id].length > 0 ? 1 : 0)
		}
	};
});

// We should only join when the probemap is identical, so the probes for the composite field
// is the same as any of the constituent fields.
function setProbes(data, fdata)  {
	var probes = _.getIn(fdata, [0, 'req', 'probes']);
	return probes ? _.assocIn(data, ['req', 'probes'], probes) : data;
}

// We don't want a reducing function for getField 'mutation'.
var getField = multi(column => column.valueType);

// Combining float fields:
// concat all fields by position,
// copy probe list,
// compute mean.
getField.add('float', (column, samplesList, fdata) => {
	var cvtdData = _.mmap(column.fieldSpecs, samplesList, fdata, cvtField(column)),
		field = concatValuesByFieldPosition(samplesList, cvtdData);

	return computeMean(setProbes(field, fdata));
});

// Combining coded fields:
// find union of codes,
// assign new values to codes,
// for each dataset
//     map val -> code -> new val.
getField.add('coded', (column, samplesList, fdata) => {
	var cvtdData = _.mmap(column.fieldSpecs, samplesList, fdata, cvtField(column)),
		allCodes = _.union(..._.pluck(cvtdData, 'codes')),
		mapping = _.object(allCodes, _.range(allCodes.length)),
		remappedWdata = _.map(cvtdData, remapCodes(mapping));
	return _.assoc(concatValuesByFieldPosition(samplesList, remappedWdata),
		'codes', allCodes);
});

// Combining mutation fields.
// Require same refGene.
// Map sampleIDs to their order in cohort samples.
getField.add('mutation', (column, samples, fdata) => {
	var sampleOffsets = _.initial(_.scan(samples, (acc, list) => acc + list.length, 0)),
		sampleMaps = _.map(sampleOffsets, offset => s => s + offset),
		remappedFdata = _.mmap(sampleMaps, fdata, remapSamples);

	return _.assoc(concatMutation(remappedFdata),
		'refGene', findFirstProp(fdata, 'refGene'));
});

function fetchComposite(column, samples) {
	var {fieldSpecs} = column;
	return Rx.Observable.zipArray(fieldSpecs.map((f, i) => fieldFetch(f, [samples[i]])))
		.map(fdata => getField(column, samples, fdata));
}

function fetchNull() {
	return Rx.Observable.return(null);
}

fieldFetch.add('composite', fetchComposite);
fieldFetch.add('null', fetchNull);

function samplesFromComposite(fieldSpec) {
	return Rx.Observable.zipArray(fieldSpec.fieldSpecs.map(fs => samplesFrom(fs)))
		.map(sfs => _.map(sfs, sf => sf[0]));
}

samplesFrom.add('composite', samplesFromComposite);
samplesFrom.add('null', () => []);

module.exports = {
	getColSpec
};
<commit_msg>handle samplesFrom for null fields.<commit_after>/*global require: false, module: false */
'use strict';
var _ = require('underscore');
var multi = require('../multi');
var fieldFetch = require('../fieldFetch');
var Rx = require('rx');
var {remapSamples, remapCodes, floatToCoded, concatValuesByFieldPosition,
		concatMutation, computeMean} = require('./fieldData');
var samplesFrom = require('../samplesFrom');
var {nullField} = require('./fieldSpec');

// Strategies for joining field metadata with composite cohorts.

var nonNullFS = fss => fss.filter(fs => fs.fetchType !== 'null');

// normalize by default if all datasets normalize by default.
function getNormalization(fieldSpecs) {
	return _.every(nonNullFS(fieldSpecs), d => d.defaultNormalization);
}

// Join column labels.
function getColumnLabel(fieldSpecs) {
	return _.uniq(_.pluck(nonNullFS(fieldSpecs), 'columnLabel')).join(' / ');
}

// Use default color from first dataset.
function getColorClass(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'colorClass')));

	// If all types are the same, preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// If any are clinical, color as clinical
	if (_.contains(types, 'clinical')) {
		return 'clinical';
	}

	// color as default genomic
	return 'default';
}

var noNullType = ts => ts.filter(t => t !== 'null');

// XXX Need to handle incompatible assemblies in mutation.
function getValueType(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'valueType')));

	// If all types are the same, we can preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// If coded, cast to coded.
	if (_.contains(types, 'coded')) {
		return 'coded';
	}

	// Any other combination, float
	return 'float';
}

// probes, genes, geneProbes, clinical, mutation.
// We shouldn't see geneProbes unless they're all the same.
function getFieldType(fieldSpecs) {
	var types = _.uniq(noNullType(_.pluck(fieldSpecs, 'fieldType')));

	// If all types are the same, we can preserve the type.
	if (types.length === 1) {
		return types[0];
	}

	// drop genomic info if mixing with clinincal
	if (_.contains(types, 'clinical')) {
		return 'clinical';
	}

	// treat disparate field types as probes.
	return 'probes';
}

function longest(arrs) {
	return _.max(arrs, arr => arr.length);
}

function hasUniqProbemap(fieldSpecs, datasets) {
	var nnFS = nonNullFS(fieldSpecs);
	return _.uniq(_.map(nnFS, fs => datasets[fs.dsID].probemap)).length  === 1;
}

// Preserve geneProbes matrix only if there's a single field and all datasets
// are geneProbes, and all have the same probemap.
function resetProbesMatrix(len, fieldSpecs, uniqProbemap) {
	var nnFS = nonNullFS(fieldSpecs);
	return (len > 1 || !_.every(nnFS, fs => fs.fieldType === 'geneProbes') || !uniqProbemap) ?
		 _.map(fieldSpecs, fs =>
				_.assoc(fs, 'fieldType', fs.fieldType === 'geneProbes' ? 'genes' : fs.fieldType)) :
		fieldSpecs;
}

// merge, dropping nulls.
var m = (...objs) => _.pick(_.merge(...objs), v => v != null);

var findFirstProp = (fieldSpecs, prop)  =>
	_.get(_.find(fieldSpecs, fs => _.has(fs, prop)), prop);

var getAssembly = (fieldType, fieldSpecs) =>
	fieldType === 'mutation' ? findFirstProp(fieldSpecs, 'assembly') : null;

var getFeature = (fieldType, fieldSpecs) =>
	fieldType === 'mutation' ? findFirstProp(fieldSpecs, 'sFeature') : null;

var getFieldLabel = fieldSpecs => findFirstProp(fieldSpecs, 'fieldLabel');

var fillNullFields = fieldSpecs => _.map(fieldSpecs, fs => fs || nullField);

// Create a composite fieldSpec from a list of fieldSpecs. This uses
// a number of heuristics to determine the 'best' combined view over
// the fieldSpecs, for some definition of 'best'.
//
// For genes/geneProbes, we might have geneProbes fields on different
// probemaps, in which case we want to coerce to 'genes', and prevent the
// user from picking 'geneProbes'. We reset the fieldType here, and set the
// 'noGeneDetail' flag to inform the UI that we can't support a 'geneProbes' view.

function combineColSpecs(fieldSpecs, datasets) {
	var fields = longest(_.pluck(fieldSpecs, 'fields')),
		uniqProbemap = hasUniqProbemap(fieldSpecs, datasets),
		resetFieldSpecs = resetProbesMatrix(fields.len, fieldSpecs, uniqProbemap),
		fieldType = getFieldType(resetFieldSpecs);

	return m({
		fields,
		fieldSpecs: resetFieldSpecs,
		fetchType: 'composite',
		valueType: getValueType(resetFieldSpecs),
		fieldType,
		defaultNormalization: getNormalization(resetFieldSpecs),
		fieldLabel: getFieldLabel(resetFieldSpecs),
		columnLabel: getColumnLabel(resetFieldSpecs),
		colorClass: getColorClass(resetFieldSpecs),
		noGeneDetail: !uniqProbemap, // XXX is this wrong? also have to check field len.
		assembly: getAssembly(fieldType, resetFieldSpecs),
		sFeature: getFeature(fieldType, resetFieldSpecs)
	});
}

// The original idea of fieldSpec was for it to be recursive, so
// we could write nested expression (e.g. signatures). However, there's
// some problem in the data modeling wrt cohorts: how does the list of
// samples connect to a particular fieldSpec? So, here we always return
// a 'composite', even for single datasets.
function getColSpec(fieldSpecs, datasets) {
	return combineColSpecs(fillNullFields(fieldSpecs), datasets);
}

// Convert field valueType.
// (column, fieldSpec, samples, data) => newData
var cvtField = multi((column, field) => `${field.valueType}->${column.valueType}`, 4);

// For probes, genes, data is
// req: {values: {[probe]: {sample: value, ...}, ...}}
cvtField.dflt = (column, fieldSpec, samples, data) => data;

// Column must be single-valued prior to trying to convert to coded.
cvtField.add('float->coded',
		(column, fieldSpec, samples, data) => floatToCoded(data));

cvtField.add('null->coded', () => {
	return {
		codes: [],
		req: {
			values: [],
			mean: []
		}
	};
});

cvtField.add('null->float', () => {
	return {
		req: {
			values: [],
			mean: []
		}
	};
});

cvtField.add('null->mutation', () => {
	return {
		req: {
			rows: [],
			samplesInResp: []
		}
	};
});

cvtField.add('mutation->float', (column, field, samples, acc, data) => {
	var bySampleID = _.groupBy(data.rows, 'sampleID');
	return {
		req: {
			values: _.map(samples, id => bySampleID[id].length > 0 ? 1 : 0)
		}
	};
});

// We are computing bySample also in the app selector.
cvtField.add('mutation->coded', (column, field, samples, acc, data) => {
	var bySampleID = _.groupBy(data.rows, 'sampleID');
	return {
		req: {
			codes: ['no mutation', 'has mutation'],
			values: _.map(samples, id => bySampleID[id].length > 0 ? 1 : 0)
		}
	};
});

// We should only join when the probemap is identical, so the probes for the composite field
// is the same as any of the constituent fields.
function setProbes(data, fdata)  {
	var probes = _.getIn(fdata, [0, 'req', 'probes']);
	return probes ? _.assocIn(data, ['req', 'probes'], probes) : data;
}

// We don't want a reducing function for getField 'mutation'.
var getField = multi(column => column.valueType);

// Combining float fields:
// concat all fields by position,
// copy probe list,
// compute mean.
getField.add('float', (column, samplesList, fdata) => {
	var cvtdData = _.mmap(column.fieldSpecs, samplesList, fdata, cvtField(column)),
		field = concatValuesByFieldPosition(samplesList, cvtdData);

	return computeMean(setProbes(field, fdata));
});

// Combining coded fields:
// find union of codes,
// assign new values to codes,
// for each dataset
//     map val -> code -> new val.
getField.add('coded', (column, samplesList, fdata) => {
	var cvtdData = _.mmap(column.fieldSpecs, samplesList, fdata, cvtField(column)),
		allCodes = _.union(..._.pluck(cvtdData, 'codes')),
		mapping = _.object(allCodes, _.range(allCodes.length)),
		remappedWdata = _.map(cvtdData, remapCodes(mapping));
	return _.assoc(concatValuesByFieldPosition(samplesList, remappedWdata),
		'codes', allCodes);
});

// Combining mutation fields.
// Require same refGene.
// Map sampleIDs to their order in cohort samples.
getField.add('mutation', (column, samples, fdata) => {
	var sampleOffsets = _.initial(_.scan(samples, (acc, list) => acc + list.length, 0)),
		sampleMaps = _.map(sampleOffsets, offset => s => s + offset),
		remappedFdata = _.mmap(sampleMaps, fdata, remapSamples);

	return _.assoc(concatMutation(remappedFdata),
		'refGene', findFirstProp(fdata, 'refGene'));
});

function fetchComposite(column, samples) {
	var {fieldSpecs} = column;
	return Rx.Observable.zipArray(fieldSpecs.map((f, i) => fieldFetch(f, [samples[i]])))
		.map(fdata => getField(column, samples, fdata));
}

function fetchNull() {
	return Rx.Observable.return(null);
}

fieldFetch.add('composite', fetchComposite);
fieldFetch.add('null', fetchNull);

function samplesFromComposite(fieldSpec) {
	return Rx.Observable.zipArray(fieldSpec.fieldSpecs.map(fs => samplesFrom(fs)))
		.map(sfs => _.map(sfs, sf => sf[0]));
}

samplesFrom.add('composite', samplesFromComposite);
samplesFrom.add('null', () => Rx.Observable.return([[]], Rx.Scheduler.timeout));

module.exports = {
	getColSpec
};
<|endoftext|>


<commit_before>/**
 * Process overview data
 */

import { List as list, Map as map, fromJS } from 'immutable';

import { AVERAGE_MEDIAN, AVERAGE_EXP, MONTHS_SHORT, OVERVIEW_COLUMNS } from '../misc/const';
import { FUTURE_INVESTMENT_RATE } from '../misc/config';
import { yearMonthDifference } from '../misc/date';
import { getKeyFromYearMonth, getYearMonthFromKey, listAverage, randnBm } from '../misc/data';
import { getOverviewCategoryColor, getOverviewScoreColor } from '../misc/color';

/**
 * Calculate futures from past averages / predictions
 * @param {list} cost: processed cost data
 * @param {list} futureCategories: categories to process
 * @param {integer} futureMonths: number of months going into the future
 * @param {integer} futureKey: key to separate between past/present and future
 * @returns {list} first six columns of data for overview table
 */
function calculateFutures(cost, futureCategories, futureMonths, futureKey) {
    return futureCategories.map(category => {
        const categoryCost = cost.get(category);

        if (category === 'funds') {
            // randomly generate fund income projections
            const oldOffset = categoryCost.size - cost.get('balance').size;

            const currentItems = categoryCost.slice(oldOffset, oldOffset + futureKey);

            const numFutureItems = categoryCost.size - oldOffset - futureKey;
            if (numFutureItems <= 0) {
                return currentItems;
            }

            const latestValue = categoryCost.get(oldOffset + futureKey - 1);

            return currentItems.concat(new Array(numFutureItems).fill(0)
                .reduce(
                    result => result.push(result.last() *
                        (1 + FUTURE_INVESTMENT_RATE / 12 + randnBm() / 100)
                    ),
                    list([latestValue])
                )
                .shift()
                .map(value => Math.round(value))
            );
        }

        // find the average value and make predictions based on that
        const currentItems = categoryCost.slice(0, categoryCost.size - futureMonths);
        if (futureMonths <= 0) {
            return currentItems;
        }

        const average = Math.round(listAverage(
            categoryCost, futureMonths + 1, AVERAGE_EXP
        ));

        return currentItems.concat(list(new Array(futureMonths).fill(average)));
    });
}

/**
 * Calculate the remaining table data, e.g. net income
 * Called after calculateFutures() which predicts future data
 * @param {map} data: processed data
 * @returns {list} all twelve columns of data for overview table
 */
function calculateTableData(data) {
    const cost = data.get('cost');
    const numRows = data.get('numRows');
    const startYear = data.get('startYearMonth')[0];
    const startMonth = data.get('startYearMonth')[1];

    // add month column
    let months = [];
    if (numRows > 0) {
        months = new Array(numRows)
            .fill(0)
            .map((item, key) => {
                const yearMonth = getYearMonthFromKey(key, startYear, startMonth);

                return `${MONTHS_SHORT[yearMonth[1] - 1]}-${yearMonth[0]}`;
            });
    }

    return list.of()
        .push(list(months))
        .push(cost.get('funds'))
        .push(cost.get('bills'))
        .push(cost.get('food'))
        .push(cost.get('general'))
        .push(cost.get('holiday'))
        .push(cost.get('social'))
        .push(cost.get('income'))
        .push(cost.get('spending'))
        .push(cost.get('net'))
        .push(cost.get('predicted'))
        .push(cost.get('balance'))
        .push(cost.get('balanceWithPredicted'));
}

export function rProcessDataOverview(
    costMap, startYearMonth, endYearMonth, currentYearMonth, futureMonths
) {
    const numRows = yearMonthDifference(startYearMonth, endYearMonth) + 1;
    const numCols = 1;

    const yearMonths = new Array(numRows)
        .fill(0)
        .map((item, key) => getYearMonthFromKey(
            key, startYearMonth[0], startYearMonth[1]
        ));

    const yearMonthsList = list(yearMonths);

    // separate funds into old and displayed
    let cost = costMap;
    if (!(cost.has('fundsOld') && cost.get('fundsOld').size)) {
        const funds = cost.get('funds');
        cost = cost.set('funds', funds.slice(-numRows))
            .set('fundsOld', funds.slice(0, funds.size - numRows));
    }

    const futureCategories = list.of('funds', 'food', 'general', 'holiday', 'social');
    const futureKey = yearMonthDifference(startYearMonth, currentYearMonth) + 1;
    const futureData = calculateFutures(cost, futureCategories, futureMonths, futureKey);
    futureCategories.forEach((category, key) => {
        cost = cost.set(category, futureData.get(key));
    });

    // add spending column
    const spending = yearMonthsList.map((month, key) =>
        cost.getIn(['bills', key]) +
        cost.getIn(['food', key]) +
        cost.getIn(['general', key]) +
        cost.getIn(['holiday', key]) +
        cost.getIn(['social', key])
    );

    // add net cash flow column
    const net = yearMonthsList.map((month, key) => {
        // add predicted (random) fund income to the net cash flow
        const fundIncome = key === 0 || key < futureKey
            ? 0
            : cost.getIn(['funds', key]) - cost.getIn(['funds', key - 1]);

        return cost.getIn(['income', key]) - spending.get(key) + fundIncome;
    });

    // add predicted balance
    let lastPredicted = cost.getIn(['balance', 0]);

    const predicted = yearMonthsList.map((month, key) => {
        const havePrevious = key > 0;
        const past = key < futureKey;
        const presentAndHaveLast = key === futureKey && cost.getIn(['balance', key - 1]) > 0;

        if (havePrevious && (past || presentAndHaveLast)) {
            lastPredicted = cost.getIn(['balance', key - 1]) + net.get(key);

            return lastPredicted;
        }

        const newPredicted = lastPredicted + net.get(key);
        lastPredicted = newPredicted;

        return newPredicted;
    });

    const balanceWithPredicted = cost.get('balance').slice(0, futureKey)
        .concat(predicted.slice(-numRows + futureKey));

    cost = cost
        .set('spending', spending)
        .set('net', net)
        .set('predicted', predicted)
        .set('balanceWithPredicted', balanceWithPredicted);

    return map({
        numRows,
        numCols,
        futureKey,
        futureMonths,
        startYearMonth,
        endYearMonth,
        currentYearMonth,
        yearMonths,
        cost
    });
}

/**
 * Process data for insertion into the store
 * @param {object} raw: api JSON data response
 * @returns {Map} immutable data
 */
function rProcessDataOverviewRaw(raw) {
    const currentYearMonth = [raw.currentYear, raw.currentMonth];
    const costMap = fromJS(raw.cost);

    return rProcessDataOverview(costMap, raw.startYearMonth, raw.endYearMonth, currentYearMonth, raw.futureMonths)
        .set('targets', fromJS(raw.targets));
}

/**
 * Get rows for display in the view
 * @param {List} data: processed data
 * @returns {List} rows for the view
 */
export function rGetOverviewRows(data) {
    const currentYear = data.get('currentYearMonth')[0];
    const currentMonth = data.get('currentYearMonth')[1];

    const tableData = calculateTableData(data);

    // get value ranges and medians for calculating colours
    const values = OVERVIEW_COLUMNS.slice(1).map((column, colKey) => tableData.get(colKey + 1));

    const valueRange = values.map(valuesItem => {
        return {
            min: valuesItem.min(),
            max: valuesItem.max()
        };
    });

    const median = values.map(valuesItem => ({
        positive: listAverage(valuesItem.filter(
            item => item >= 0
        ), 0, AVERAGE_MEDIAN),
        negative: listAverage(valuesItem.filter(
            item => item < 0), 0, AVERAGE_MEDIAN
        )
    }));

    const categoryColor = getOverviewCategoryColor();

    // translate the data into table cells for display in the view
    const rows = tableData
        .get(0)
        .map((monthText, key) => {
            const yearMonth = getYearMonthFromKey(
                key, data.get('startYearMonth')[0], data.get('startYearMonth')[1]);

            const past = yearMonth[0] < currentYear ||
                (yearMonth[0] === currentYear && yearMonth[1] < currentMonth);
            const active = yearMonth[0] === currentYear && yearMonth[1] === currentMonth;
            const future = !past && !active;

            let cols = null;

            const cells = list(OVERVIEW_COLUMNS)
                .map((column, colKey) => {
                    const value = tableData.getIn([colKey, key]);
                    let rgb = null;
                    if (colKey > 0 && categoryColor[colKey - 1]) {
                        rgb = getOverviewScoreColor(
                            value,
                            valueRange[colKey - 1],
                            median[colKey - 1],
                            categoryColor[colKey - 1]
                        );
                    }

                    const editable = column[0] === 'balance';

                    if (editable) {
                        // for use with editables
                        cols = list([value]);
                    }

                    return map({
                        column: list(column),
                        value,
                        rgb,
                        editable
                    });
                });

            return map({ cols, cells, past, active, future });
        });

    return rows;
}

/**
 * @function rCalculateOverview
 * @param {Record} reduction: modified reduction
 * @param {string} page: page which is modified
 * @param {YMD} newDate: modified item date
 * @param {YMD} oldDate: original item date
 * @param {integer} newItemCost: modified item cost
 * @param {integer} oldItemCost: original item cost
 * @returns {Record} reduction with re-calculated overview data
 */
export function rCalculateOverview(reduction, { page, newDate, oldDate, newItemCost, oldItemCost }) {
    const startYearMonth = reduction.getIn(['pages', 'overview', 'data', 'startYearMonth']);

    const newKey = getKeyFromYearMonth(newDate.year, newDate.month, startYearMonth[0], startYearMonth[1]);
    const oldKey = getKeyFromYearMonth(oldDate.year, oldDate.month, startYearMonth[0], startYearMonth[1]);

    const oldCost = reduction.getIn(['pages', 'overview', 'data', 'cost']);
    const numRows = oldCost.get(page).size;

    // update the changed rows in the overview page
    let newCost = oldCost;
    if (oldKey === newKey) {
        if (oldKey < numRows) {
            newCost = newCost.setIn(
                [page, oldKey],
                oldCost.getIn([page, oldKey]) + newItemCost - oldItemCost
            );
        }
    }
    else {
        if (oldKey < numRows) {
            newCost = newCost.setIn(
                [page, oldKey],
                oldCost.getIn([page, oldKey]) - oldItemCost
            );
        }
        if (newKey < numRows) {
            newCost = newCost.setIn(
                [page, newKey],
                oldCost.getIn([page, newKey]) + newItemCost
            );
        }
    }

    const endYearMonth = reduction.getIn(['pages', 'overview', 'data', 'endYearMonth']);
    const currentYearMonth = reduction.getIn(['pages', 'overview', 'data', 'currentYearMonth']);
    const futureMonths = reduction.getIn(['pages', 'overview', 'data', 'futureMonths']);

    const newData = rProcessDataOverview(
        newCost, startYearMonth, endYearMonth, currentYearMonth, futureMonths
    );

    return reduction
        .setIn(['pages', 'overview', 'data'], newData)
        .setIn(['pages', 'overview', 'rows'], rGetOverviewRows(newData));
}

/**
 * Called when data is first loaded
 * @param {Record} reduction: app state
 * @param {string} page: page index
 * @param {object} raw: api JSON data
 * @returns {Record} modified reduction
 */
export function processPageDataOverview(reduction, { raw }) {
    const data = rProcessDataOverviewRaw(raw);
    const rows = rGetOverviewRows(data);

    return reduction.setIn(['pages', 'overview'], map({ data, rows }));
}

<commit_msg>Don't overwrite targets when recalculating overview data (TODO: recalculate the targets)<commit_after>/**
 * Process overview data
 */

import { List as list, Map as map, fromJS } from 'immutable';

import { AVERAGE_MEDIAN, AVERAGE_EXP, MONTHS_SHORT, OVERVIEW_COLUMNS } from '../misc/const';
import { FUTURE_INVESTMENT_RATE } from '../misc/config';
import { yearMonthDifference } from '../misc/date';
import { getKeyFromYearMonth, getYearMonthFromKey, listAverage, randnBm } from '../misc/data';
import { getOverviewCategoryColor, getOverviewScoreColor } from '../misc/color';

/**
 * Calculate futures from past averages / predictions
 * @param {list} cost: processed cost data
 * @param {list} futureCategories: categories to process
 * @param {integer} futureMonths: number of months going into the future
 * @param {integer} futureKey: key to separate between past/present and future
 * @returns {list} first six columns of data for overview table
 */
function calculateFutures(cost, futureCategories, futureMonths, futureKey) {
    return futureCategories.map(category => {
        const categoryCost = cost.get(category);

        if (category === 'funds') {
            // randomly generate fund income projections
            const oldOffset = categoryCost.size - cost.get('balance').size;

            const currentItems = categoryCost.slice(oldOffset, oldOffset + futureKey);

            const numFutureItems = categoryCost.size - oldOffset - futureKey;
            if (numFutureItems <= 0) {
                return currentItems;
            }

            const latestValue = categoryCost.get(oldOffset + futureKey - 1);

            return currentItems.concat(new Array(numFutureItems).fill(0)
                .reduce(
                    result => result.push(result.last() *
                        (1 + FUTURE_INVESTMENT_RATE / 12 + randnBm() / 100)
                    ),
                    list([latestValue])
                )
                .shift()
                .map(value => Math.round(value))
            );
        }

        // find the average value and make predictions based on that
        const currentItems = categoryCost.slice(0, categoryCost.size - futureMonths);
        if (futureMonths <= 0) {
            return currentItems;
        }

        const average = Math.round(listAverage(
            categoryCost, futureMonths + 1, AVERAGE_EXP
        ));

        return currentItems.concat(list(new Array(futureMonths).fill(average)));
    });
}

/**
 * Calculate the remaining table data, e.g. net income
 * Called after calculateFutures() which predicts future data
 * @param {map} data: processed data
 * @returns {list} all twelve columns of data for overview table
 */
function calculateTableData(data) {
    const cost = data.get('cost');
    const numRows = data.get('numRows');
    const startYear = data.get('startYearMonth')[0];
    const startMonth = data.get('startYearMonth')[1];

    // add month column
    let months = [];
    if (numRows > 0) {
        months = new Array(numRows)
            .fill(0)
            .map((item, key) => {
                const yearMonth = getYearMonthFromKey(key, startYear, startMonth);

                return `${MONTHS_SHORT[yearMonth[1] - 1]}-${yearMonth[0]}`;
            });
    }

    return list.of()
        .push(list(months))
        .push(cost.get('funds'))
        .push(cost.get('bills'))
        .push(cost.get('food'))
        .push(cost.get('general'))
        .push(cost.get('holiday'))
        .push(cost.get('social'))
        .push(cost.get('income'))
        .push(cost.get('spending'))
        .push(cost.get('net'))
        .push(cost.get('predicted'))
        .push(cost.get('balance'))
        .push(cost.get('balanceWithPredicted'));
}

export function rProcessDataOverview(
    costMap, startYearMonth, endYearMonth, currentYearMonth, futureMonths
) {
    const numRows = yearMonthDifference(startYearMonth, endYearMonth) + 1;
    const numCols = 1;

    const yearMonths = new Array(numRows)
        .fill(0)
        .map((item, key) => getYearMonthFromKey(
            key, startYearMonth[0], startYearMonth[1]
        ));

    const yearMonthsList = list(yearMonths);

    // separate funds into old and displayed
    let cost = costMap;
    if (!(cost.has('fundsOld') && cost.get('fundsOld').size)) {
        const funds = cost.get('funds');
        cost = cost.set('funds', funds.slice(-numRows))
            .set('fundsOld', funds.slice(0, funds.size - numRows));
    }

    const futureCategories = list.of('funds', 'food', 'general', 'holiday', 'social');
    const futureKey = yearMonthDifference(startYearMonth, currentYearMonth) + 1;
    const futureData = calculateFutures(cost, futureCategories, futureMonths, futureKey);
    futureCategories.forEach((category, key) => {
        cost = cost.set(category, futureData.get(key));
    });

    // add spending column
    const spending = yearMonthsList.map((month, key) =>
        cost.getIn(['bills', key]) +
        cost.getIn(['food', key]) +
        cost.getIn(['general', key]) +
        cost.getIn(['holiday', key]) +
        cost.getIn(['social', key])
    );

    // add net cash flow column
    const net = yearMonthsList.map((month, key) => {
        // add predicted (random) fund income to the net cash flow
        const fundIncome = key === 0 || key < futureKey
            ? 0
            : cost.getIn(['funds', key]) - cost.getIn(['funds', key - 1]);

        return cost.getIn(['income', key]) - spending.get(key) + fundIncome;
    });

    // add predicted balance
    let lastPredicted = cost.getIn(['balance', 0]);

    const predicted = yearMonthsList.map((month, key) => {
        const havePrevious = key > 0;
        const past = key < futureKey;
        const presentAndHaveLast = key === futureKey && cost.getIn(['balance', key - 1]) > 0;

        if (havePrevious && (past || presentAndHaveLast)) {
            lastPredicted = cost.getIn(['balance', key - 1]) + net.get(key);

            return lastPredicted;
        }

        const newPredicted = lastPredicted + net.get(key);
        lastPredicted = newPredicted;

        return newPredicted;
    });

    const balanceWithPredicted = cost.get('balance').slice(0, futureKey)
        .concat(predicted.slice(-numRows + futureKey));

    cost = cost
        .set('spending', spending)
        .set('net', net)
        .set('predicted', predicted)
        .set('balanceWithPredicted', balanceWithPredicted);

    return map({
        numRows,
        numCols,
        futureKey,
        futureMonths,
        startYearMonth,
        endYearMonth,
        currentYearMonth,
        yearMonths,
        cost
    });
}

/**
 * Process data for insertion into the store
 * @param {object} raw: api JSON data response
 * @returns {Map} immutable data
 */
function rProcessDataOverviewRaw(raw) {
    const currentYearMonth = [raw.currentYear, raw.currentMonth];
    const costMap = fromJS(raw.cost);

    return rProcessDataOverview(costMap, raw.startYearMonth, raw.endYearMonth, currentYearMonth, raw.futureMonths)
        .set('targets', fromJS(raw.targets));
}

/**
 * Get rows for display in the view
 * @param {List} data: processed data
 * @returns {List} rows for the view
 */
export function rGetOverviewRows(data) {
    const currentYear = data.get('currentYearMonth')[0];
    const currentMonth = data.get('currentYearMonth')[1];

    const tableData = calculateTableData(data);

    // get value ranges and medians for calculating colours
    const values = OVERVIEW_COLUMNS.slice(1).map((column, colKey) => tableData.get(colKey + 1));

    const valueRange = values.map(valuesItem => {
        return {
            min: valuesItem.min(),
            max: valuesItem.max()
        };
    });

    const median = values.map(valuesItem => ({
        positive: listAverage(valuesItem.filter(
            item => item >= 0
        ), 0, AVERAGE_MEDIAN),
        negative: listAverage(valuesItem.filter(
            item => item < 0), 0, AVERAGE_MEDIAN
        )
    }));

    const categoryColor = getOverviewCategoryColor();

    // translate the data into table cells for display in the view
    const rows = tableData
        .get(0)
        .map((monthText, key) => {
            const yearMonth = getYearMonthFromKey(
                key, data.get('startYearMonth')[0], data.get('startYearMonth')[1]);

            const past = yearMonth[0] < currentYear ||
                (yearMonth[0] === currentYear && yearMonth[1] < currentMonth);
            const active = yearMonth[0] === currentYear && yearMonth[1] === currentMonth;
            const future = !past && !active;

            let cols = null;

            const cells = list(OVERVIEW_COLUMNS)
                .map((column, colKey) => {
                    const value = tableData.getIn([colKey, key]);
                    let rgb = null;
                    if (colKey > 0 && categoryColor[colKey - 1]) {
                        rgb = getOverviewScoreColor(
                            value,
                            valueRange[colKey - 1],
                            median[colKey - 1],
                            categoryColor[colKey - 1]
                        );
                    }

                    const editable = column[0] === 'balance';

                    if (editable) {
                        // for use with editables
                        cols = list([value]);
                    }

                    return map({
                        column: list(column),
                        value,
                        rgb,
                        editable
                    });
                });

            return map({ cols, cells, past, active, future });
        });

    return rows;
}

/**
 * @function rCalculateOverview
 * @param {Record} reduction: modified reduction
 * @param {string} page: page which is modified
 * @param {YMD} newDate: modified item date
 * @param {YMD} oldDate: original item date
 * @param {integer} newItemCost: modified item cost
 * @param {integer} oldItemCost: original item cost
 * @returns {Record} reduction with re-calculated overview data
 */
export function rCalculateOverview(reduction, { page, newDate, oldDate, newItemCost, oldItemCost }) {
    const startYearMonth = reduction.getIn(['pages', 'overview', 'data', 'startYearMonth']);

    const newKey = getKeyFromYearMonth(newDate.year, newDate.month, startYearMonth[0], startYearMonth[1]);
    const oldKey = getKeyFromYearMonth(oldDate.year, oldDate.month, startYearMonth[0], startYearMonth[1]);

    const oldCost = reduction.getIn(['pages', 'overview', 'data', 'cost']);
    const numRows = oldCost.get(page).size;

    // update the changed rows in the overview page
    let newCost = oldCost;
    if (oldKey === newKey) {
        if (oldKey < numRows) {
            newCost = newCost.setIn(
                [page, oldKey],
                oldCost.getIn([page, oldKey]) + newItemCost - oldItemCost
            );
        }
    }
    else {
        if (oldKey < numRows) {
            newCost = newCost.setIn(
                [page, oldKey],
                oldCost.getIn([page, oldKey]) - oldItemCost
            );
        }
        if (newKey < numRows) {
            newCost = newCost.setIn(
                [page, newKey],
                oldCost.getIn([page, newKey]) + newItemCost
            );
        }
    }

    const endYearMonth = reduction.getIn(['pages', 'overview', 'data', 'endYearMonth']);
    const currentYearMonth = reduction.getIn(['pages', 'overview', 'data', 'currentYearMonth']);
    const futureMonths = reduction.getIn(['pages', 'overview', 'data', 'futureMonths']);

    const newData = rProcessDataOverview(
        newCost, startYearMonth, endYearMonth, currentYearMonth, futureMonths
    );

    return reduction
        .setIn(['pages', 'overview', 'data'], newData)
        .setIn(['pages', 'overview', 'data', 'targets'],
            reduction.getIn(['pages', 'overview', 'data', 'targets'])
        )
        .setIn(['pages', 'overview', 'rows'], rGetOverviewRows(newData));
}

/**
 * Called when data is first loaded
 * @param {Record} reduction: app state
 * @param {string} page: page index
 * @param {object} raw: api JSON data
 * @returns {Record} modified reduction
 */
export function processPageDataOverview(reduction, { raw }) {
    const data = rProcessDataOverviewRaw(raw);
    const rows = rGetOverviewRows(data);

    return reduction.setIn(['pages', 'overview'], map({ data, rows }));
}

<|endoftext|>