airbnb/caravel

View on GitHub
superset-frontend/packages/superset-ui-core/src/color/CategoricalColorScale.ts

Summary

Maintainability
A
2 hrs
Test Coverage
/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing,
 * software distributed under the License is distributed on an
 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied.  See the License for the
 * specific language governing permissions and limitations
 * under the License.
 */

import { scaleOrdinal, ScaleOrdinal } from 'd3-scale';
import { ExtensibleFunction } from '../models';
import { ColorsInitLookup, ColorsLookup } from './types';
import stringifyAndTrim from './stringifyAndTrim';
import getLabelsColorMap from './LabelsColorMapSingleton';
import { getAnalogousColors } from './utils';
import { FeatureFlag, isFeatureEnabled } from '../utils';

// Use type augmentation to correct the fact that
// an instance of CategoricalScale is also a function
interface CategoricalColorScale {
  (x: { toString(): string }, y?: number, w?: string): string;
}

class CategoricalColorScale extends ExtensibleFunction {
  originColors: string[];

  colors: string[];

  scale: ScaleOrdinal<{ toString(): string }, string>;

  forcedColors: ColorsLookup;

  labelsColorMapInstance: ReturnType<typeof getLabelsColorMap>;

  chartLabelsColorMap: Map<string, string>;

  multiple: number;

  /**
   * Constructor
   * @param {*} colors an array of colors
   * @param {*} forcedColors optional parameter that comes from parent
   * (usually CategoricalColorNamespace)
   */
  constructor(colors: string[], forcedColors: ColorsInitLookup = {}) {
    super((value: string, sliceId?: number, colorScheme?: string) =>
      this.getColor(value, sliceId, colorScheme),
    );
    // holds original color scheme colors
    this.originColors = colors;
    // holds the extended color range (includes analagous colors)
    this.colors = colors;
    // holds the values of this specific slice (label+color)
    this.chartLabelsColorMap = new Map();
    // shared color map instance (when context is shared, i.e. dashboard)
    this.labelsColorMapInstance = getLabelsColorMap();
    // holds the multiple value for analogous colors range
    this.multiple = 0;

    this.scale = scaleOrdinal<{ toString(): string }, string>();
    this.scale.range(colors);

    // reserve fixed colors in parent map based on their index in the scale
    Object.entries(forcedColors).forEach(([key, value]) => {
      if (typeof value === 'number') {
        // eslint-disable-next-line no-param-reassign
        forcedColors[key] = colors[value % colors.length];
      }
    });

    // forced colors from parent (usually CategoricalColorNamespace)
    // currently used in dashboards to set custom label colors
    this.forcedColors = forcedColors as ColorsLookup;
  }

  /**
   * Increment the color range with analogous colors
   */
  incrementColorRange() {
    const multiple = Math.floor(
      this.domain().length / this.originColors.length,
    );
    // the domain has grown larger than the original range
    // increments the range with analogous colors
    if (multiple > this.multiple) {
      this.multiple = multiple;
      const newRange = getAnalogousColors(this.originColors, multiple);
      const extendedColors = this.originColors.concat(newRange);

      this.range(extendedColors);
      this.colors = extendedColors;
    }
  }

  /**
   * Get the color for a given value
   *
   * @param value the value of a label to get the color for
   * @param sliceId the ID of the current chart
   * @param colorScheme the original color scheme of the chart
   * @returns the color or the next available color
   */
  getColor(value?: string, sliceId?: number, colorScheme?: string): string {
    const cleanedValue = stringifyAndTrim(value);
    // priority: forced color (i.e. custom label colors) > shared color > scale color
    const forcedColor = this.forcedColors?.[cleanedValue];
    const isExistingLabel = this.chartLabelsColorMap.has(cleanedValue);
    let color = forcedColor || this.scale(cleanedValue);

    // a forced color will always be used independently of the usage count
    if (!forcedColor && !isExistingLabel) {
      if (isFeatureEnabled(FeatureFlag.UseAnalagousColors)) {
        this.incrementColorRange();
      }
      if (
        // feature flag to be deprecated (will become standard behaviour)
        isFeatureEnabled(FeatureFlag.AvoidColorsCollision) &&
        this.isColorUsed(color)
      ) {
        // fallback to least used color
        color = this.getNextAvailableColor(color);
      }
    }

    // keep track of values in this slice
    this.chartLabelsColorMap.set(cleanedValue, color);

    // store the value+color in the LabelsColorMapSingleton
    if (sliceId) {
      this.labelsColorMapInstance.addSlice(
        cleanedValue,
        color,
        sliceId,
        colorScheme,
      );
    }
    return color;
  }

  /**
   * Verify if a color is used in this slice
   *
   * @param color
   * @returns true if the color is used in this slice
   */
  isColorUsed(color: string): boolean {
    return this.getColorUsageCount(color) > 0;
  }

  /**
   * Get the count of the color usage in this slice
   *
   * @param sliceId the ID of the current slice
   * @param color the color to check
   * @returns the count of the color usage in this slice
   */
  getColorUsageCount(currentColor: string): number {
    let count = 0;
    this.chartLabelsColorMap.forEach(color => {
      if (color === currentColor) {
        count += 1;
      }
    });
    return count;
  }

  /**
   * Lower chances of color collision by returning the least used color
   * Checks across colors of current slice within LabelsColorMapSingleton
   *
   * @param currentColor the current color
   * @returns the least used color that is not the excluded color
   */
  getNextAvailableColor(currentColor: string) {
    const colorUsageArray = this.colors.map(color => ({
      color,
      count: this.getColorUsageCount(color),
    }));
    const currentColorCount = this.getColorUsageCount(currentColor);
    const otherColors = colorUsageArray.filter(
      colorEntry => colorEntry.color !== currentColor,
    );
    // all other colors are used as much or more than currentColor
    const hasNoneAvailable = otherColors.every(
      colorEntry => colorEntry.count >= currentColorCount,
    );

    // fallback to currentColor color
    if (!otherColors.length || hasNoneAvailable) {
      return currentColor;
    }

    // Finding the least used color
    const leastUsedColor = otherColors.reduce((min, entry) =>
      entry.count < min.count ? entry : min,
    ).color;

    return leastUsedColor;
  }

  /**
   * Enforce specific color for a given value at the scale level
   * Overrides any existing color and forced color for the given value
   *
   * @param {*} value value
   * @param {*} forcedColor forcedColor
   * @returns {CategoricalColorScale}
   */
  setColor(value: string, forcedColor: string) {
    this.forcedColors[stringifyAndTrim(value)] = forcedColor;
    return this;
  }

  /**
   * Get a mapping of data values to colors
   *
   * @returns an object where the key is the data value and the value is the hex color code
   */
  getColorMap() {
    const colorMap: { [key: string]: string | undefined } = {};
    this.scale.domain().forEach(value => {
      colorMap[value.toString()] = this.scale(value);
    });

    return {
      ...colorMap,
      ...this.forcedColors,
    };
  }

  /**
   * Return an exact copy of this scale.
   * Changes to this scale will not affect the returned scale and vice versa.
   *
   * @returns {CategoricalColorScale} A copy of this scale.
   */
  copy() {
    const copy = new CategoricalColorScale(
      this.scale.range(),
      this.forcedColors,
    );
    copy.forcedColors = { ...this.forcedColors };
    copy.domain(this.domain());
    copy.unknown(this.unknown());
    return copy;
  }

  /**
   * Returns the scale's current domain.
   */
  domain(): { toString(): string }[];

  /**
   * Expands the domain to include the specified array of values.
   */
  domain(newDomain: { toString(): string }[]): this;

  domain(newDomain?: { toString(): string }[]): unknown {
    if (typeof newDomain === 'undefined') {
      return this.scale.domain();
    }

    this.scale.domain(newDomain);
    return this;
  }

  /**
   * Returns the scale's current range.
   */
  range(): string[];

  /**
   * Sets the range of the ordinal scale to the specified array of values.
   *
   * The first element in the domain will be mapped to the first element in range, the second domain value to the second range value, and so on.
   *
   * If there are fewer elements in the range than in the domain, the scale will reuse values from the start of the range.
   *
   * @param newRange Array of range values.
   */
  range(newRange: string[]): this;

  range(newRange?: string[]): unknown {
    if (typeof newRange === 'undefined') {
      return this.scale.range();
    }

    this.colors = newRange;
    this.scale.range(newRange);
    return this;
  }

  /**
   * Returns the current unknown value, which defaults to "implicit".
   */
  unknown(): string | { name: 'implicit' };

  /**
   * Sets the output value of the scale for unknown input values and returns this scale.
   * The implicit value enables implicit domain construction. scaleImplicit can be used as a convenience to set the implicit value.
   *
   * @param value Unknown value to be used or scaleImplicit to set implicit scale generation.
   */
  unknown(value: string | { name: 'implicit' }): this;

  unknown(value?: string | { name: 'implicit' }): unknown {
    if (typeof value === 'undefined') {
      return this.scale.unknown();
    }

    this.scale.unknown(value);
    return this;
  }
}

export default CategoricalColorScale;