Gapminder/vizabi

View on GitHub
src/helpers/d3.colorPicker.js

Summary

Maintainability
D
2 days
Test Coverage
import { isTouchDevice } from "base/utils";

const css = {
  INVISIBLE: "vzb-invisible",
  COLOR_POINTER: "vzb-colorpicker-pointer",
  COLOR_BUTTON: "vzb-colorpicker-cell",
  COLOR_DEFAULT: "vzb-colorpicker-default",
  COLOR_SAMPLE: "vzb-colorpicker-sample",
  COLOR_PICKER: "vzb-colorpicker-svg",
  COLOR_CIRCLE: "vzb-colorpicker-circle",
  COLOR_CIRCLES: "vzb-colorpicker-circles",
  COLOR_SEGMENT: "vzb-colorpicker-segment",
  COLOR_BACKGR: "vzb-colorpicker-background"
};

export default class ColorPicker {
  constructor(container) {
    this._container = container;
    this._wrapper = this._container.select("." + css.COLOR_PICKER);

    this._init();
  }

  _init() {
    this._initVariables();
    this._initCircles();
    this._style();
    this.resize(this._svg);
  }

  _initVariables() {
    // radius of the central hole in color wheel: px
    this._maxWidth = 280;
    this._maxHeight = 323;

    this._colorOld = "#000";
    this._colorDef = "#000";
    this._colorWhite = "#f8f8f8";
    this._colorBlack = "#444";

    // margins in % of container's width and height
    this._margin = {
      top: 0.1,
      bottom: 0.1,
      left: 0.1,
      right: 0.1
    };

    this._initSvg();

    const {
      width: svgWidth,
      height: svgHeight
    } = getComputedStyle(this._svg.node());
    this._width = this.constructor.px2num(svgWidth);
    this._height = this.constructor.px2num(svgHeight);
    this._maxRadius = this._width / 2 * (1 - this._margin.left - this._margin.right);

    // tuning defaults
    this._nCellsH = 30;         // number of cells by hues (angular)
    this._minH = 0;             // which hue do we start from: 0 to 1 instead of 0 to 360
    this._nCellsL = 8;          // number of cells by lightness (radial)
    this._minL = 0.4;           // which lightness to start from: 0 to 1. Recommended .3...0.5
    this._satConstant = 0.8;    // constant saturation for color wheel: 0 to 1. Recommended .7...0.8
    this._outerL = 0.3;         // exceptional lightness of the outer circle: 0 to 1
    this._firstAngleSat = 0;    // exceptional saturation at first angular segment. Set 0 to have shades of grey
    this._minRadius = 12;
    this._arc = d3.arc();


    this._pie = d3.pie().sort(null).value(d => 1);
    this._colorPointer = null;
    this._showColorPicker = false;
    this._sampleRect = null;
    this._sampleText = null;
    this._callback = value => console.info(`Color picker callback example. Setting color to ${value}`);
    this._colorData = this._generateColorData();
  }

  _generateColorData() {
    const {
      _minL,
      _minH,
      _nCellsL,
      _nCellsH,
      _firstAngleSat,
      _satConstant,
      _outerL,
      _colorWhite,
      _colorBlack
    } = this;

    const result = [];
    // loop across circles
    for (let l = 0; l < _nCellsL; l++) {
      const lightness = _minL + (1 - _minL) / _nCellsL * l;
      // new circle of cells
      result.push([]);
      // loop across angles
      for (let h = 0; h <= _nCellsH; h++) {
        const hue = 360 * (_minH + (1 - _minH) / _nCellsH * h);
        // new cell
        result[l].push({
          fill: d3.hsl(
            hue,
            h === 0 ? _firstAngleSat : _satConstant,
            l === 0 ? _outerL : lightness
          ).hex(),

          stroke: l === 0 ? _colorWhite : _colorBlack
        });
      }
    }

    return result;
  }

  _initSvg() {
    this._wrapper = this._container.append("div")
      .style("position", "absolute")
      .style("top", "0")
      .style("left", "0")
      .style("width", "100%")
      .style("max-width", this._maxWidth + "px")
      .style("height", "100%")
      .style("max-height", this._maxHeight + "px")
      .style("z-index", 9999)
      .attr("class", css.COLOR_PICKER + " vzb-dialog-shadow")
      .classed(css.INVISIBLE, !this._showColorPicker)
      .on("mouseout", () => this._cellHover(this._colorOld));

    this._svg = this._wrapper.append("svg")
      .style("width", "100%")
      .style("height", "100%");
  }

  _initCircles() {
    const {
      _svg,
      _maxHeight,
      _width,
      _height,
      _margin,
      _colorData,
      _nCellsL,
      _minRadius,
      _maxRadius,
      _colorWhite,
      _colorBlack,
    } = this;

    _svg.append("rect")
      .attr("width", _width)
      .attr("height", _maxHeight)
      .attr("class", css.COLOR_BACKGR)
      .on("mouseover", () => this._cellHover(this._colorOld))
      .on("click", () => {
        d3.event.stopPropagation();
        this._changeColor(this._colorOld);
        this.show(false);
      });

    const tx = _maxRadius + _width * _margin.left;
    const ty = _maxRadius + _height * _margin.top;
    const circles = _svg.append("g")
      .attr("class", css.COLOR_CIRCLES)
      .attr("transform", `translate(${tx}, ${ty})`);

    _svg.append("rect")
      .attr("class", css.COLOR_SAMPLE)
      .attr("width", _width / 2)
      .attr("height", _height * _margin.top / 2);

    this._sampleRect = _svg.append("rect")
      .attr("class", css.COLOR_SAMPLE)
      .attr("width", _width / 2)
      .attr("x", _width / 2)
      .attr("height", _height * _margin.top / 2);

    _svg.append("text")
      .attr("x", _width * _margin.left)
      .attr("y", _height * _margin.top / 2)
      .attr("dy", "1.3em")
      .attr("class", css.COLOR_SAMPLE)
      .style("text-anchor", "start");

    this._sampleText = _svg.append("text")
      .attr("x", _width * (1 - _margin.right))
      .attr("y", _height * _margin.top / 2)
      .attr("dy", "1.3em")
      .attr("class", css.COLOR_SAMPLE)
      .style("text-anchor", "end");

    _svg.append("text")
      .attr("x", _width * 0.1)
      .attr("y", _height * (1 - _margin.bottom))
      .attr("dy", "1.2em")
      .attr("class", "vzb-default-label")
      .style("text-anchor", "start")
      .text("default");

    _svg.append("circle")
      .attr("class", css.COLOR_DEFAULT + " " + css.COLOR_BUTTON)
      .attr("r", _width * _margin.left / 2)
      .attr("cx", _width * _margin.left * 1.5)
      .attr("cy", _height * (1 - _margin.bottom * 1.5))
      .on("mouseover", function() {
        d3.select(this).style("stroke", _colorBlack);
        self._cellHover(self._colorDef);
      })
      .on("mouseout", function() {
        d3.select(this).style("stroke", "none");
      });

    const self = this;
    circles.selectAll("." + css.COLOR_CIRCLE)
      .data(_colorData).enter().append("g")
      .attr("class", css.COLOR_CIRCLE)
      .each(function(circleData, index) {
        self._arc
          .outerRadius(_minRadius + (_maxRadius - _minRadius) / _nCellsL * (_nCellsL - index))
          .innerRadius(_minRadius + (_maxRadius - _minRadius) / _nCellsL * (_nCellsL - index - 1));

        const segment = d3.select(this).selectAll("." + css.COLOR_SEGMENT)
          .data(self._pie(circleData)).enter().append("g")
          .attr("class", css.COLOR_SEGMENT);

        segment.append("path")
          .attr("class", css.COLOR_BUTTON)
          .attr("d", self._arc)
          .style("fill", d => d.data.fill)
          .style("stroke", d => d.data.fill)
          .on("mouseover", function(d) {
            self._cellHover(d.data.fill, this);
          })
          .on("mouseout", () => self._cellUnhover());
      });

    circles.append("circle")
      .datum({ data: { fill: _colorWhite, stroke: _colorBlack } })
      .attr("r", _minRadius)
      .attr("fill", _colorWhite)
      .attr("class", css.COLOR_BUTTON)
      .on("mouseover", function() {
        d3.select(this).style("stroke", _colorBlack);
        self._cellHover(_colorWhite);
      })
      .on("mouseout", function() {
        d3.select(this).style("stroke", "none");
      });

    this._colorPointer = circles.append("path")
      .attr("class", css.COLOR_POINTER + " " + css.INVISIBLE);

    _svg.selectAll("." + css.COLOR_BUTTON)
      .on("click", d => {
        d3.event.stopPropagation();
        this._changeColor(d ? d.data.fill : this._colorDef, true);
        this.show(false);
      });
  }

  _style() {
    const {
      _svg,
      _colorWhite
    } = this;

    _svg.select("." + css.COLOR_BACKGR)
      .style("fill", "white");

    _svg.select("." + css.COLOR_POINTER)
      .style("stroke-width", 2)
      .style("stroke", _colorWhite)
      .style("pointer-events", "none")
      .style("fill", "none");

    _svg.selectAll("." + css.COLOR_BUTTON)
      .style("cursor", "pointer");

    _svg.selectAll("text")
      .style("pointer-events", "none")
      .style("fill", "#D9D9D9")
      .style("font-size", "0.7em")
      .style("text-transform", "uppercase");

    _svg.selectAll("circle." + css.COLOR_BUTTON)
      .style("stroke-width", 2);

    _svg.selectAll("rect." + css.COLOR_SAMPLE)
      .style("pointer-events", "none");
  }

  _cellHover(value, view) {
    // show color pointer if the view is set (a cell of colorwheel)
    if (view != null)
      this._colorPointer
        .classed(css.INVISIBLE, false)
        .attr("d", d3.select(view).attr("d"))
        .style("stroke", d3.select(view).datum().data.stroke || _colorWhite);

    this._sampleRect.style("fill", value);
    this._sampleText.text(value);

    const isTouch = isTouchDevice();

    this._changeColor(value, isTouch);
    isTouch && this.show(false);
  }

  _changeColor(color, isClick = false) {
    this._callback(color, isClick);
  }

  _cellUnhover() {
    this._colorPointer.classed(css.INVISIBLE, true);
  }

  resize(arg) {
    if (!arguments.length)
      return;

    if (typeof arg !== "undefined") {
      const { _margin } = this;
      const svg = arg;

      const {
        width: svgWidth,
        height: svgHeight,
      } = getComputedStyle(svg.node());
      const width = this.constructor.px2num(svgWidth);
      const height = this.constructor.px2num(svgHeight);

      const maxRadius = width / 2 * (1 - _margin.left - _margin.right);
      const selectedColor = svg.select("." + css.COLOR_DEFAULT);
      const defaultLabel = svg.select(".vzb-default-label");
      const circles = svg.select("." + css.COLOR_CIRCLES);

      const hPos = maxRadius + height * _margin.top;
      const hPosCenter = (1 + _margin.top * 0.5) * height * 0.5;

      const tx = maxRadius + width * _margin.left;
      const ty = hPos > hPosCenter ? hPosCenter : hPos;
      circles.attr("transform", `translate(${tx}, ${ty})`);

      selectedColor.attr("cx", width * _margin.left * 1.5)
        .attr("cy", height * (1 - _margin.bottom * 1.5));

      defaultLabel.attr("x", width * 0.1)
        .attr("y", height * (1 - _margin.bottom));
    }

    return this.fitToScreen();
  }

  fitToScreen(arg) {
    const screen = this._container.node().getBoundingClientRect();
    let xPos, yPos;

    const {
      width: wrapperWidth,
      height: wrapperHeight,
      right: wrapperRight,
      top: wrapperTop
    } = getComputedStyle(this._wrapper.node());
    const width = this.constructor.px2num(wrapperWidth);
    const height = this.constructor.px2num(wrapperHeight);

    if (!arg) {
      xPos = screen.width - this.constructor.px2num(wrapperRight) - width;
      yPos = this.constructor.px2num(wrapperTop);
    } else {
      xPos = arg[0] - screen.left;
      yPos = arg[1] - screen.top;
    }

    const styles = { left: "" };
    if (screen.width * 0.8 <= width) {
      styles.right = (screen.width - width) * 0.5 + "px";
    } else if (xPos + width > screen.width) {
      styles.right = Math.min(screen.width * 0.1, 20) + "px";
    } else {
      styles.right = screen.width - xPos - width + "px";
    }

    if (styles.right) {
      this._wrapper.style("right", styles.right);
    }

    if (screen.height * 0.8 <= height) {
      styles.top = (screen.height - height) * 0.5 + "px";
    } else if (yPos + height * 1.2 > screen.height) {
      styles.top = screen.height * 0.9 - height + "px";
    } else {
      styles.top = yPos + "px";
    }

    if (styles.top) {
      this._wrapper.style("top", styles.top);
    }

    this._wrapper.style("left", styles.left);

    return this;
  }

  show(arg) {
    if (!arguments.length) {
      return this._showColorPicker;
    }

    if (this._svg == null) {
      console.warn("Color picker is missing SVG element. Was init sequence performed?");
    }

    this._showColorPicker = arg == "toggle" ? !this._showColorPicker : arg;

    if (!this._showColorPicker) {
      this._callback = () => void 0;
    }

    this._wrapper.classed(css.INVISIBLE, !this._showColorPicker);
  }

  _getOrSet(property, value) {
    property = "_" + property;

    if (arguments.length > 1) {
      this[property] = value;
      return this;
    }

    return this[property];
  }

  nCellsH() {
    return this._getOrSet("nCellsH", ...arguments);
  }

  minH() {
    return this._getOrSet("minH", ...arguments);
  }

  nCellsL() {
    return this._getOrSet("nCellsL", ...arguments);
  }

  minL() {
    return this._getOrSet("minL", ...arguments);
  }

  outerL() {
    return this._getOrSet("outerL", ...arguments);
  }

  satConstant() {
    return this._getOrSet("satConstant", ...arguments);
  }

  firstAngleSat() {
    return this._getOrSet("firstAngleSat", ...arguments);
  }

  minRadius() {
    return this._getOrSet("minRadius", ...arguments);
  }

  margin() {
    return this._getOrSet("margin", ...arguments);
  }

  callback() {
    return this._getOrSet("callback", ...arguments);
  }

  colorDef(arg) {
    if (!arguments.length)
      return this._colorDef;

    if (typeof arg !== "undefined") {
      this._colorDef = arg;
    }

    if (this._svg == null) {
      console.warn("Color picker is missing SVG element. Was init sequence performed?");
    }

    this._svg.select("." + css.COLOR_DEFAULT).style("fill", this._colorDef);

    return this;
  }

  translate(translator) {
    if (typeof translator === "function") {
      this._svg.select(".vzb-default-label")
        .text(translator("colorpicker/default"));
    }

    return this;
  }

  colorOld(arg) {
    if (!arguments.length) {
      return this._colorOld;
    }

    this._colorOld = arg;

    if (this._svg == null) {
      console.warn("Color picker is missing SVG element. Was init sequence performed?");
    }

    this._svg.select("rect." + css.COLOR_SAMPLE).style("fill", this._colorOld);
    this._svg.select("text." + css.COLOR_SAMPLE).text(this._colorOld);

    return this;
  }

  static px2num(px) {
    return parseFloat(px) || 0;
  }

}