text: // Copyright (c) 2018 Uber Technologies, Inc.
  //
  // 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.
  
  import moment from 'moment';
  import {ascending, extent, histogram as d3Histogram, ticks} from 'd3-array';
  import keyMirror from 'keymirror';
  
  import {ALL_FIELD_TYPES} from 'constants/default-settings';
  import {maybeToDate, notNullorUndefined} from './data-utils';
  import * as ScaleUtils from './data-scale-utils';
  import {generateHashId} from './utils';
  
  export const TimestampStepMap = [
    {max: 1, step: 0.05},
    {max: 10, step: 0.1},
    {max: 100, step: 1},
    {max: 500, step: 5},
    {max: 1000, step: 10},
    {max: 5000, step: 50},
    {max: Number.POSITIVE_INFINITY, step: 1000}
  ];
  
  export const histogramBins = 30;
  export const enlargedHistogramBins = 100;
  
  const durationSecond = 1000;
  const durationMinute = durationSecond * 60;
  const durationHour = durationMinute * 60;
  const durationDay = durationHour * 24;
  const durationWeek = durationDay * 7;
  const durationYear = durationDay * 365;
  
  export const FILTER_TYPES = keyMirror({
    range: null,
    select: null,
    timeRange: null,
    multiSelect: null
  });
  
  export const PLOT_TYPES = keyMirror({
    histogram: null,
    lineChart: null
  });
  
  const SupportedPlotType = {
    [FILTER_TYPES.timeRange]: {
      default: 'histogram',
      [ALL_FIELD_TYPES.integer]: 'lineChart',
      [ALL_FIELD_TYPES.real]: 'lineChart'
    },
    [FILTER_TYPES.range]: {
      default: 'histogram',
      [ALL_FIELD_TYPES.integer]: 'lineChart',
      [ALL_FIELD_TYPES.real]: 'lineChart'
    }
  };
  
  export const FILTER_COMPONENTS = {
    [FILTER_TYPES.select]: 'SingleSelectFilter',
    [FILTER_TYPES.multiSelect]: 'MultiSelectFilter',
    [FILTER_TYPES.timeRange]: 'TimeRangeFilter',
    [FILTER_TYPES.range]: 'RangeFilter'
  };
  
  export const BASE_SPEED = 600;
  export const TIME_ANIMATION_SPEED = [
    {
      label: '0.5x',
      value: 0.5
    },
    {
      label: '1x',
      value: 1
    },
    {
      label: '2x',
      value: 2
    },
    {
      label: '4x',
      value: 4
    }
  ];
  
  export function getDefaultFilter(dataId) {
    return {
      // link to dataset Id
      dataId,
      // should allow to edit dataId
      freeze: false,
      id: generateHashId(4),
  
      // time range filter specific
      fixedDomain: false,
      enlarged: false,
      isAnimating: false,
      speed: 1,
  
      // field specific
      name: null,
      type: null,
      fieldIdx: null,
      domain: null,
      value: null,
  
      // plot
      plotType: PLOT_TYPES.histogram,
      yAxis: null,
      interval: null
    };
  }
  
  /**
   * Get default filter prop based on field type
   *
   * @param {Object[]} data
   * @param {object} field
   * @returns {object} default filter
   */
  export function getFilterProps(data, field) {
    const filterProp = {
      ...getFieldDomain(data, field),
      fieldType: field.type
    };
  
    switch (field.type) {
      case ALL_FIELD_TYPES.real:
      case ALL_FIELD_TYPES.integer:
        return {
          ...filterProp,
          value: filterProp.domain,
          type: FILTER_TYPES.range,
          typeOptions: [FILTER_TYPES.range]
        };
  
      case ALL_FIELD_TYPES.boolean:
        return {
          ...filterProp,
          type: FILTER_TYPES.select,
          value: true
        };
  
      case ALL_FIELD_TYPES.string:
      case ALL_FIELD_TYPES.date:
        return {
          ...filterProp,
          type: FILTER_TYPES.multiSelect,
          value: []
        };
  
      case ALL_FIELD_TYPES.timestamp:
        return {
          ...filterProp,
          type: FILTER_TYPES.timeRange,
          enlarged: true,
          fixedDomain: true,
          value: filterProp.domain
        };
  
      default:
        return {};
    }
  }
  
  /**
   * Calculate field domain based on field type and data
   *
   * @param {Object[]} data
   * @param {object} field
   * @returns {object} with domain as key
   */
  export function getFieldDomain(data, field) {
    const fieldIdx = field.tableFieldIndex - 1;
    const isTime = field.type === ALL_FIELD_TYPES.timestamp;
    const valueAccessor = maybeToDate.bind(null, isTime, fieldIdx, field.format);
    let domain;
  
    switch (field.type) {
      case ALL_FIELD_TYPES.real:
      case ALL_FIELD_TYPES.integer:
        // calculate domain and step
        return getNumericFieldDomain(data, valueAccessor);
  
      case ALL_FIELD_TYPES.boolean:
        return {domain: [true, false]};
  
      case ALL_FIELD_TYPES.string:
      case ALL_FIELD_TYPES.date:
        domain = ScaleUtils.getOrdinalDomain(data, valueAccessor);
        return {domain};
  
      case ALL_FIELD_TYPES.timestamp:
        return getTimestampFieldDomain(data, valueAccessor);
  
      default:
        return {domain: ScaleUtils.getOrdinalDomain(data, valueAccessor)};
    }
  }
  
  /**
   * Filter data based on an array of filters
   *
   * @param {Object[]} data
   * @param {string} dataId
   * @param {Object[]} filters
   * @returns {Object[]} data
   * @returns {Number[]} filteredIndex
   */
  export function filterData(data, dataId, filters) {
    if (!data || !dataId) {
      // why would there not be any data? are we over doing this?
      return {data: [], filteredIndex: []};
    }
  
    if (!filters.length) {
      return {data, filteredIndex: data.map((d, i) => i)};
    }
  
    const appliedFilters = filters.filter(
      d => d.dataId === dataId && d.fieldIdx > -1 && d.value !== null
    );
  
    const [dynamicDomainFilters, fixedDomainFilters] = appliedFilters.reduce(
      (accu, f) => {
        if (f.dataId === dataId && f.fieldIdx > -1 && f.value !== null) {
          (f.fixedDomain ? accu[1] : accu[0]).push(f);
        }
        return accu;
      },
      [[], []]
    );
    // console.log(dynamicDomainFilters)
    // console.log(fixedDomainFilters)
    // we save a reference of allData index here to access dataToFeature
    // in geojson and hexgonId layer
    // console.time('filterData');
  
    const {filtered, filteredIndex, filteredIndexForDomain} = data.reduce(
      (accu, d, i) => {
        // generate 2 sets of
        // filter data used to calculate layer Domain
        const matchForDomain = dynamicDomainFilters.every(filter =>
          isDataMatchFilter(d, filter, i)
        );
  
        if (matchForDomain) {
          accu.filteredIndexForDomain.push(i);
  
          // filter data for render
          const matchForRender = fixedDomainFilters.every(filter =>
            isDataMatchFilter(d, filter, i)
          );
  
          if (matchForRender) {
            accu.filtered.push(d);
            accu.filteredIndex.push(i);
          }
        }
  
        return accu;
      },
      {filtered: [], filteredIndex: [], filteredIndexForDomain: []}
    );
  
    // console.log('data==', data.length)
    // console.log('filtered==', filtered.length)
    // console.log('filteredIndex==', filteredIndex.length)
    // console.log('filteredIndexForDomain==', filteredIndexForDomain.length)
    //
    // console.timeEnd('filterData');
  
    return {data: filtered, filteredIndex, filteredIndexForDomain};
  }
  
  /**
   * Check if value is in range of filter
   *
   * @param {Object[]} data
   * @param {Object} filter
   * @param {number} i
   * @returns {Boolean} - whether value falls in the range of the filter
   */
  export function isDataMatchFilter(data, filter, i) {
    const val = data[filter.fieldIdx];
    if (!filter.type) {
      return true;
    }
  
    switch (filter.type) {
      case FILTER_TYPES.range:
        return isInRange(val, filter.value);
  
      case FILTER_TYPES.timeRange:
        const timeVal = filter.mappedValue
          ? filter.mappedValue[i]
          : moment.utc(val).valueOf();
        return isInRange(timeVal, filter.value);
  
      case FILTER_TYPES.multiSelect:
        return filter.value.includes(val);
  
      case FILTER_TYPES.select:
        return filter.value === val;
  
      default:
        return true;
    }
  }
  
  /**
   * Call by parsing filters from URL
   * Check if value of filter within filter domain, if not adjust it to match
   * filter domain
   *
   * @param {string[] | string | number | number[]} value
   * @param {Array} filter.domain
   * @param {String} filter.type
   * @returns {*} - adjusted value to match filter or null to remove filter
   */
  
  /* eslint-disable complexity */
  export function adjustValueToFilterDomain(value, {domain, type}) {
    if (!domain || !type) {
      return false;
    }
  
    switch (type) {
      case FILTER_TYPES.range:
      case FILTER_TYPES.timeRange:
        if (!Array.isArray(value) || value.length !== 2) {
          return domain.map(d => d);
        }
  
        return value.map(
          (d, i) =>
            notNullorUndefined(d) && isInRange(d, domain) ? d : domain[i]
        );
  
      case FILTER_TYPES.multiSelect:
        if (!Array.isArray(value)) {
          return [];
        }
        const filteredValue = value.filter(d => domain.includes(d));
        return filteredValue.length ? filteredValue : [];
  
      case FILTER_TYPES.select:
        return domain.includes(value) ? value : true;
  
      default:
        return null;
    }
  }
  /* eslint-enable complexity */
  
  /**
   * Calculate numeric domain and suitable step
   *
   * @param {Object[]} data
   * @param {function} valueAccessor
   * @returns {object} domain and step
   */
  export function getNumericFieldDomain(data, valueAccessor) {
    let domain = [0, 1];
    let step = 0.1;
  
    const mappedValue = Array.isArray(data) ? data.map(valueAccessor) : [];
  
    if (Array.isArray(data) && data.length > 1) {
      domain = ScaleUtils.getLinearDomain(mappedValue);
      const diff = domain[1] - domain[0];
  
      // in case equal domain, [96, 96], which will break quantize scale
      if (!diff) {
        domain[1] = domain[0] + 1;
      }
  
      step = getNumericStepSize(diff) || step;
      domain[0] = formatNumberByStep(domain[0], step, 'floor');
      domain[1] = formatNumberByStep(domain[1], step, 'ceil');
    }
  
    const {histogram, enlargedHistogram} = getHistogram(domain, mappedValue);
  
    return {domain, step, histogram, enlargedHistogram};
  }
  
  function getNumericStepSize(diff) {
    if (diff > 100) {
      return 1;
    } else if (diff < 20 && diff > 3) {
      return 0.01;
    } else if (diff <= 3) {
      return 0.001;
    }
  }
  
  /**
   * Calculate timestamp domain and suitable step
   *
   * @param {Object[]} data
   * @param {function} valueAccessor
   * @returns {object} domain and step
   */
  export function getTimestampFieldDomain(data, valueAccessor) {
    // to avoid converting string format time to epoch
    // every time we compare we store a value mapped to int in filter domain
  
    const mappedValue = Array.isArray(data) ? data.map(valueAccessor) : [];
    const domain = ScaleUtils.getLinearDomain(mappedValue);
    let step = 0.01;
  
    const diff = domain[1] - domain[0];
    const entry = TimestampStepMap.find(f => f.max >= diff);
    if (entry) {
      step = entry.step;
    }
  
    const {histogram, enlargedHistogram} = getHistogram(domain, mappedValue);
  
    return {domain, step, mappedValue, histogram, enlargedHistogram};
  }
  
  export function histogramConstruct(domain, mappedValue, bins) {
    return d3Histogram()
      .thresholds(ticks(domain[0], domain[1], bins))
      .domain(domain)(mappedValue)
      .map(bin => ({
        count: bin.length,
        x0: bin.x0,
        x1: bin.x1
      }));
  }
  /**
   * Calculate histogram from domain and array of values
   *
   * @param {number[]} domain
   * @param {Object[]} mappedValue
   * @returns {Array[]} histogram
   */
  function getHistogram(domain, mappedValue) {
    const histogram = histogramConstruct(domain, mappedValue, histogramBins);
    const enlargedHistogram = histogramConstruct(
      domain,
      mappedValue,
      enlargedHistogramBins
    );
  
    return {histogram, enlargedHistogram};
  }
  
  /**
   * round number based on step
   *
   * @param {number} val
   * @param {number} step
   * @param {string} bound
   * @returns {number} rounded number
   */
  export function formatNumberByStep(val, step, bound) {
    if (bound === 'floor') {
      return Math.floor(val * (1 / step)) / (1 / step);
    }
  
    return Math.ceil(val * (1 / step)) / (1 / step);
  }
  
  export function isInRange(val, domain) {
    if (!Array.isArray(domain)) {
      return false;
    }
  
    return val >= domain[0] && val <= domain[1];
  }
  
  export function getTimeWidgetTitleFormatter(domain) {
    if (!Array.isArray(domain)) {
      return null;
    }
  
    const diff = domain[1] - domain[0];
    return diff > durationYear
      ? 'MM/DD/YY'
      : diff > durationDay
        ? 'MM/DD hha'
        : 'MM/DD hh:mma';
  }
  
  export function getTimeWidgetHintFormatter(domain) {
    if (!Array.isArray(domain)) {
      return null;
    }
  
    const diff = domain[1] - domain[0];
    return diff > durationYear
      ? 'MM/DD/YY'
      : diff > durationWeek
        ? 'MM/DD'
        : diff > durationDay
          ? 'MM/DD hha'
          : diff > durationHour
            ? 'hh:mma'
            : 'hh:mm:ssa';
  }
  
  /**
   * Sanity check on filters to prepare for save
   * @param {String} type - filter type
   * @param {*} value - filter value
   * @returns {boolean} whether filter is value
   */
  export function isValidFilterValue({type, value}) {
    if (!type) {
      return false;
    }
    switch (type) {
      case FILTER_TYPES.select:
        return value === true || value === false;
  
      case FILTER_TYPES.range:
      case FILTER_TYPES.timeRange:
        return Array.isArray(value) && value.every(v => v !== null && !isNaN(v));
  
      case FILTER_TYPES.multiSelect:
        return Array.isArray(value) && Boolean(value.length);
  
      case FILTER_TYPES.input:
        return Boolean(value.length);
  
      default:
        return true;
    }
  }
  
  export function getFilterPlot(filter, allData) {
    if (filter.plotType === PLOT_TYPES.histogram || !filter.yAxis) {
      // histogram should be calculated when create filter
      return {};
    }
  
    const {mappedValue} = filter;
    const {yAxis} = filter;
  
    // return lineChart
    const series = allData
      .map((d, i) => ({
        x: mappedValue[i],
        y: d[yAxis.tableFieldIndex - 1]
      }))
      .filter(({x, y}) => Number.isFinite(x) && Number.isFinite(y))
      .sort((a, b) => ascending(a.x, b.x));
  
    const yDomain = extent(series, d => d.y);
    const xDomain = [series[0].x, series[series.length - 1].x];
  
    return {lineChart: {series, yDomain, xDomain}, yAxis};
  }
  
  export function getDefaultFilterPlotType(filter) {
    const filterPlotTypes = SupportedPlotType[filter.type];
    if (!filterPlotTypes) {
      return null;
    }
  
    if (!filter.yAxis) {
      return filterPlotTypes.default;
    }
  
    return filterPlotTypes[filter.yAxis.type] || null;
  }
  