UNPKG

tinted

Version:

An interactive color wheel for the browser

701 lines (605 loc) 25 kB
import { select, dispatch, drag, event } from 'd3'; import chroma from 'chroma-js'; function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function _arrayWithHoles(arr) { if (Array.isArray(arr)) return arr; } function _iterableToArrayLimit(arr, i) { if (typeof Symbol === "undefined" || !(Symbol.iterator in Object(arr))) return; var _arr = []; var _n = true; var _d = false; var _e = undefined; try { for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { _arr.push(_s.value); if (i && _arr.length === i) break; } } catch (err) { _d = true; _e = err; } finally { try { if (!_n && _i["return"] != null) _i["return"](); } finally { if (_d) throw _e; } } return _arr; } function _arrayLikeToArray(arr, len) { if (len == null || len > arr.length) len = arr.length; for (var i = 0, arr2 = new Array(len); i < len; i++) { arr2[i] = arr[i]; } return arr2; } function _unsupportedIterableToArray(o, minLen) { if (!o) return; if (typeof o === "string") return _arrayLikeToArray(o, minLen); var n = Object.prototype.toString.call(o).slice(8, -1); if (n === "Object" && o.constructor) n = o.constructor.name; if (n === "Map" || n === "Set") return Array.from(o); if (n === "Arguments" || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)) return _arrayLikeToArray(o, minLen); } function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } function _slicedToArray(arr, i) { return _arrayWithHoles(arr) || _iterableToArrayLimit(arr, i) || _unsupportedIterableToArray(arr, i) || _nonIterableRest(); } function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } // They convert between scientific hue to the color wheel's "artistic" hue. function artisticToScientificSmooth(hue) { return hue < 60 ? hue * (35 / 60) : hue < 122 ? mapRange(hue, [60, 122], [35, 60]) : hue < 165 ? mapRange(hue, [122, 165], [60, 120]) : hue < 218 ? mapRange(hue, [165, 218], [120, 180]) : hue < 275 ? mapRange(hue, [218, 275], [180, 240]) : hue < 330 ? mapRange(hue, [275, 330], [240, 300]) : mapRange(hue, [330, 360], [300, 360]); } function scientificToArtisticSmooth(hue) { return hue < 35 ? hue * (60 / 35) : hue < 60 ? mapRange(hue, [35, 60], [60, 122]) : hue < 120 ? mapRange(hue, [60, 120], [122, 165]) : hue < 180 ? mapRange(hue, [120, 180], [165, 218]) : hue < 240 ? mapRange(hue, [180, 240], [218, 275]) : hue < 300 ? mapRange(hue, [240, 300], [275, 330]) : mapRange(hue, [300, 360], [330, 360]); } /** * Map a value from one range to the equivalent value in an other range * For example, mapRange(5, [0, 10], [0, 100]) => 50 */ function mapRange(value, fromRange, toRange) { var _fromRange = _slicedToArray(fromRange, 2), fromLower = _fromRange[0], fromUpper = _fromRange[1]; var _toRange = _slicedToArray(toRange, 2), toLower = _toRange[0], toUpper = _toRange[1]; return toLower + (value - fromLower) * ((toUpper - toLower) / (fromUpper - fromLower)); } /** * Get a hex string from hue and sat components, * with 100% brightness. */ function hexFromHS(h, s) { return chroma({ h: h, s: s, v: 1 }).hex(); } /** * Used to determine the distance from the root marker. * (The first DOM node with marker class) * Domain: [0, 1, 2, 3, 4, ... ] * Range: [0, 1, -1, 2, -2, ... ] */ function markerDistance(i) { return Math.ceil(i / 2) * Math.pow(-1, i + 1); } /** * Returns a step function with the given base. * e.g. with base = 3, returns a function with this domain/range: * Domain: [0, 1, 2, 3, 4, 5, ...] * Range: [0, 0, 0, 1, 1, 1, ...] */ function stepFn(base) { return function (x) { return Math.floor(x / base); }; } /** * Given an SVG point (x, y), returns the closest point * to (x, y) still in the circle. */ function clampToCircle(x, y, radius) { var p = svgToCartesian(x, y, radius); if (Math.sqrt(p.x * p.x + p.y * p.y) <= radius) { return { x: x, y: y }; } var theta = Math.atan2(p.y, p.x); var x_ = radius * Math.cos(theta); var y_ = radius * Math.sin(theta); return cartesianToSVG(x_, y_, radius); } /** Get a coordinate pair from hue and saturation components */ function getSVGPositionFromHS(h, s, radius) { var hue = scientificToArtisticSmooth(h); var theta = hue * (Math.PI / 180); var y = Math.sin(theta) * radius * s; var x = Math.cos(theta) * radius * s; return cartesianToSVG(x, y, radius); } /** Inverse of `getSVGPositionFromHS` */ function getHSFromSVGPosition(x, y, radius) { var p = svgToCartesian(x, y, radius); var theta = Math.atan2(p.y, p.x); var artisticHue = (theta * (180 / Math.PI) + 360) % 360; var scientificHue = artisticToScientificSmooth(artisticHue); var s = Math.min(Math.sqrt(p.x * p.x + p.y * p.y) / radius, 1); return { h: scientificHue, s: s }; } function svgToCartesian(x, y, radius) { return { x: x - radius, y: radius - y }; } function cartesianToSVG(x, y, radius) { return { x: x + radius, y: radius - y }; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } var ColorWheelMarkerDatum = function ColorWheelMarkerDatum(color, name, show) { _classCallCheck(this, ColorWheelMarkerDatum); var _chroma$hsv = chroma(color).hsv(), _chroma$hsv2 = _slicedToArray(_chroma$hsv, 3), h = _chroma$hsv2[0], s = _chroma$hsv2[1], v = _chroma$hsv2[2]; this.color = { h: h, s: s, v: v }; this.name = name; this.show = show; }; /** * These modes define a relationship between the colors on a * color wheel, based on "science". */ var colorModes = { CUSTOM: 'custom', ANALOGOUS: 'analogous', COMPLEMENTARY: 'complementary', TRIAD: 'triad', TETRAD: 'tetrad', MONOCHROMATIC: 'monochromatic', SHADES: 'shades' }; var TintedWheel = /*#__PURE__*/function () { function TintedWheel(options) { _classCallCheck(this, TintedWheel); this.options = _objectSpread(_objectSpread({}, TintedWheel.DEFAULT_OPTIONS), options); this._init(); } _createClass(TintedWheel, [{ key: "_init", value: function _init() { var _this$options = this.options, initMode = _this$options.initMode, container = _this$options.container, defaultSlice = _this$options.defaultSlice; this._ensureModeExists(initMode); this.currentMode = initMode; this.container = select(container); this.slice = defaultSlice; this._render(); this._bindEvents(); } }, { key: "_render", value: function _render() { var _this$options2 = this.options, radius = _this$options2.radius, margin = _this$options2.margin, colorWheelImage = _this$options2.colorWheelImage; var diameter = radius * 2; this.container.attr('viewBox', [-1 * margin, -1 * margin, diameter + 2 * margin, diameter + 2 * margin].join(' ')); this.container.append('circle').attr('fill', 'black').attr('r', radius).attr('cx', radius).attr('cy', radius); this.container.append('image').attr('width', diameter).attr('height', diameter).attr('href', colorWheelImage); var markerTrails = this.container.append('g'); var markers = this.container.append('g'); this.$ = { markers: markers, markerTrails: markerTrails }; } }, { key: "_bindEvents", value: function _bindEvents() { var _this = this; var radius = this.options.radius; // Create a dispatch with 4 custom events this.dispatch = dispatch( // Initial data was successfully bound. 'bind-data', // Markers datum has changed, so redraw as necessary, etc. 'markers-updated', // "updateEnd" means the state of the ColorWheel has been finished updating. 'update-end', // The mode was changed 'mode-changed'); this.dispatch.on('markers-updated.wheel', function (data) { var markers = _this._getMarkers().attr('visibility', function (d) { return d.show ? 'visible' : 'hidden'; }).attr('transform', function (d) { var position = getSVGPositionFromHS(d.color.h, d.color.s, radius); return "translate(".concat(position.x, ", ").concat(position.y, ")"); }); markers.select('circle').attr('fill', function (d) { return hexFromHS(d.color.h, d.color.s); }); _this.container.selectAll(_this.createSelector('marker-trail')).attr('x2', function (d) { return getSVGPositionFromHS(d.color.h, d.color.s, radius).x; }).attr('y2', function (d) { return getSVGPositionFromHS(d.color.h, d.color.s, radius).y; }).attr('visibility', function (d) { return d.show ? 'visible' : 'hidden'; }); }); this.dispatch.on('mode-changed.wheel', function () { _this.container.attr('data-mode', _this.currentMode); }); } }, { key: "bindData", value: function bindData(userData) { var initRoot = this.options.initRoot; var colorData; // Data can be passed as a whole number, // or an array of ColorWheelMarkerDatum. if (Array.isArray(userData)) { colorData = userData.map(function (color) { return new ColorWheelMarkerDatum(color, null, true); }); this.setMode(colorModes.CUSTOM); } else { // We weren't given any data so create our own. var numColors = typeof userData === 'number' ? userData : 5; colorData = []; for (var i = 0; i < numColors; ++i) { var datum = new ColorWheelMarkerDatum(initRoot, null, true); colorData.push(datum); } } this._createMarkerDOMElements(colorData); this.setHarmony(); this.dispatch.call('bind-data', this, colorData); this.dispatch.call('markers-updated', this, colorData); this.dispatch.call('update-end', this); } }, { key: "_createMarkerDOMElements", value: function _createMarkerDOMElements(data) { var _this$options3 = this.options, radius = _this$options3.radius, markerWidth = _this$options3.markerWidth, markerOutlineWidth = _this$options3.markerOutlineWidth; var markerTrails = this.$.markerTrails.selectAll(this.createSelector('marker-trail')).data(data); markerTrails.enter().append('line').attr('class', this.cx('marker-trail')).attr('x1', radius).attr('y1', radius).attr('stroke-opacity', 0.75).attr('stroke-width', 1); markerTrails.exit().remove(); // Set the data for each marker? var markers = this.$.markers.selectAll(this.createSelector('marker')).data(data); var g = markers.enter().append('g').attr('class', this.cx('marker')).attr('visibility', 'visible').call(this._createDragBehavior()); g.append('circle').attr('r', markerWidth / 2).attr('stroke-width', markerOutlineWidth).attr('stroke-opacity', 0.9).attr('cursor', 'move'); g.append('text').text(function (d) { return d.name; }).attr('x', markerWidth / 2 + 8).attr('y', markerWidth / 4 - 5).attr('fill', 'white').attr('font-size', '13px'); markers.exit().remove(); } }, { key: "_createDragBehavior", value: function _createDragBehavior() { var _this2 = this; var self = this; return drag().on('drag', function (d) { var markerEl = this; var radius = self.options.radius; var pos = clampToCircle(event.x, event.y, radius); var hs = getHSFromSVGPosition(pos.x, pos.y, radius); d.color.h = hs.h; d.color.s = hs.s; var p = svgToCartesian(event.x, event.y, radius); var dragHue = (Math.atan2(p.y, p.x) * 180 / Math.PI + 720) % 360; var startingHue = parseFloat(select(markerEl).attr('data-startingHue')); var theta1 = (360 + startingHue - dragHue) % 360; var theta2 = (360 + dragHue - startingHue) % 360; self._updateHarmony(this, theta1 < theta2 ? -1 * theta1 : theta2); }).on('start', function () { _this2._getVisibleMarkers().attr('data-startingHue', function (d) { return scientificToArtisticSmooth(d.color.h); }); }).on('end', function () { if (_this2.currentMode !== colorModes.ANALOGOUS) { _this2.dispatch.call('update-end', _this2); return; } var visibleMarkers = _this2._getVisibleMarkers().attr('data-startingHue', null); var visibleMarkerData = visibleMarkers.data(); var rootMarkerData = visibleMarkerData[0]; var rootHue = rootMarkerData.color.h; var rootTheta = scientificToArtisticSmooth(rootHue); if (visibleMarkerData.length > 1) { var neighborData = visibleMarkerData[1]; var neighborHue = neighborData.color.h; var neighborTheta = scientificToArtisticSmooth(neighborHue); _this2.slice = (360 + neighborTheta - rootTheta) % 360; } _this2.dispatch.call('update-end', _this2); }); } }, { key: "setHarmony", value: function setHarmony() { var _this3 = this; var root = this._getRootMarker(); var offsetFactor = 0.08; this._getMarkers().classed('root', false); if (root.empty()) return; var rootHue = scientificToArtisticSmooth(root.datum().color.h); var markers = this._getVisibleMarkers(); switch (this.currentMode) { case colorModes.ANALOGOUS: root.classed('root', true); markers.each(function (d, index) { var newHue = (rootHue + markerDistance(index) * _this3.slice + 720) % 360; d.color.h = artisticToScientificSmooth(newHue); d.color.s = 1; d.color.v = 1; }); break; case colorModes.MONOCHROMATIC: markers.each(function (d, i) { d.color.h = artisticToScientificSmooth(rootHue); d.color.s = 1 - (0.15 * i + Math.random() * 0.1); d.color.v = 0.75 + 0.25 * Math.random(); }); break; case colorModes.SHADES: markers.each(function (d) { d.color.h = artisticToScientificSmooth(rootHue); d.color.s = 1; d.color.v = 0.25 + 0.75 * Math.random(); }); break; case colorModes.COMPLEMENTARY: markers.each(function (d, index) { var newHue = (rootHue + index % 2 * 180 + 720) % 360; d.color.h = artisticToScientificSmooth(newHue); d.color.s = 1 - offsetFactor * stepFn(2)(index); d.color.v = 1; }); break; case colorModes.TRIAD: markers.each(function (d, index) { var newHue = (rootHue + index % 3 * 120 + 720) % 360; d.color.h = artisticToScientificSmooth(newHue); d.color.s = 1 - offsetFactor * stepFn(3)(index); d.color.v = 1; }); break; case colorModes.TETRAD: markers.each(function (d, index) { var newHue = (rootHue + index % 4 * 90 + 720) % 360; d.color.h = artisticToScientificSmooth(newHue); d.color.s = 1 - offsetFactor * stepFn(4)(index); d.color.v = 1; }); break; } this.dispatch.call('markers-updated', this, markers.data()); } }, { key: "_updateHarmony", value: function _updateHarmony(target, theta) { var cursor = target; var counter = 0; // Find out how far the dragging marker is from the root marker. while (cursor = cursor.previousSibling) { if (cursor.getAttribute('visibility') === 'hidden') continue; counter++; } var targetDistance = markerDistance(counter); var markers = this._getVisibleMarkers(); switch (this.currentMode) { case colorModes.ANALOGOUS: markers.each(function (currentDatum, index) { var markerEl = this; if (markerEl === target) return; var $marker = select(markerEl); var startingHue = parseFloat($marker.attr('data-startingHue')); var slices = targetDistance === 0 ? 1 : markerDistance(index) / targetDistance; var adjustedHue = (startingHue + slices * theta + 720) % 360; currentDatum.color.h = artisticToScientificSmooth(adjustedHue); }); break; case colorModes.SHADES: case colorModes.MONOCHROMATIC: case colorModes.COMPLEMENTARY: case colorModes.TRIAD: case colorModes.TETRAD: markers.each(function (d) { if (this.currentMode === colorModes.SHADES) d.color.s = 1; var markerEl = this; var $marker = select(markerEl); var startingHue = parseFloat($marker.attr('data-startingHue')); var adjustedHue = (startingHue + theta + 720) % 360; d.color.h = artisticToScientificSmooth(adjustedHue); }); break; } this.dispatch.call('markers-updated', this, markers.data()); } }, { key: "setMode", value: function setMode(mode) { this._ensureModeExists(mode); this.currentMode = mode; this.setHarmony(); this.dispatch.call('mode-changed', this); this.dispatch.call('update-end', this); } }, { key: "_ensureModeExists", value: function _ensureModeExists(mode) { if (!Object.values(colorModes).includes(mode)) { throw new Error('Invalid mode specified: ' + mode); } } }, { key: "_getMarkers", value: function _getMarkers() { return this.container.selectAll(this.createSelector('marker')); } }, { key: "_getVisibleMarkers", value: function _getVisibleMarkers() { return this.container.selectAll(this.createSelector('marker') + '[visibility=visible]'); } }, { key: "_getRootMarker", value: function _getRootMarker() { return this.container.select(this.createSelector('marker') + '[visibility=visible]'); } }, { key: "createSelector", value: function createSelector(className) { return '.' + this.cx(className); } // Utility for building internal class name strings }, { key: "cx", value: function cx(className) { return this.options.baseClassName + '-' + className; } }, { key: "isInMode", value: function isInMode(mode) { return this.currentMode === colorModes[mode]; } }]); return TintedWheel; }(); _defineProperty(TintedWheel, "MODES", colorModes); _defineProperty(TintedWheel, "DEFAULT_OPTIONS", { container: document.body, radius: 100, markerWidth: 25, markerOutlineWidth: 1, margin: 25 / 2 + 1, defaultSlice: 20, initRoot: 'red', initMode: colorModes.ANALOGOUS, baseClassName: 'tinted', colorWheelImage: 'https://zposten.github.io/tinted/demo/wheel.png' }); function ownKeys$1(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread$1(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys$1(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys$1(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } var TintedPalette = /*#__PURE__*/function () { function TintedPalette(options) { _classCallCheck(this, TintedPalette); this.options = _objectSpread$1(_objectSpread$1({}, TintedPalette.DEFAULT_OPTIONS), options); this.classNames = { palette: this.cx('palette'), paletteColor: this.cx('palette__color'), swatch: this.cx('palette__color__swatch'), slider: this.cx('palette__color__slider'), colorText: this.cx('palette__color__color-text') }; this.colorWheel = this.options.colorWheel; this.$container = select(this.options.container); // Ensure the container has the correct class this.$container.attr('class', this.classNames.palette); } _createClass(TintedPalette, [{ key: "render", value: function render(data) { var colorWheel = this.colorWheel; var paletteColors = this.$container.selectAll('.' + this.classNames.paletteColor).data(data); var newPaletteColors = paletteColors.enter().append('div').attr('class', this.classNames.paletteColor); // Add color swatches newPaletteColors.append('div').attr('class', this.classNames.swatch); // Add sliders newPaletteColors.append('input').attr('type', 'range').attr('class', this.classNames.slider).on('input', function (d) { var sliderEl = this; d.color.v = parseInt(sliderEl.value) / 100; colorWheel.dispatch.call('markers-updated'); }).on('change', function () { return colorWheel.dispatch.call('update-end'); }); // Add textual color hex codes newPaletteColors.append('input').attr('type', 'text').attr('class', this.classNames.colorText).on('focus', function () { var valueEl = this; // Like jQuery's .one(), attach a listener that only executes once. // This way the user can use the cursor normally after the initial selection. select(valueEl).on('mouseup', function () { event.preventDefault(); // Detach the listener select(valueEl).on('mouseup', null); }); valueEl.select(); }); // Remove color swatches that no longer correspond to a datum paletteColors.exit().remove(); } }, { key: "onColorValuesChanged", value: function onColorValuesChanged() { var currentMode = this.colorWheel.currentMode; this.$container.selectAll('.' + this.classNames.paletteColor).each(function (d, i) { var swatchEl = this; var order = currentMode === colorModes.TRIAD ? i % 3 : markerDistance(i); swatchEl.style.order = order; swatchEl.style.webkitOrder = order; }); this.$container.selectAll('.' + this.classNames.swatch).style('background-color', function (d) { return chroma(d.color).hex(); }); this.$container.selectAll('.' + this.classNames.slider).attr('value', function (d) { return parseInt(d.color.v * 100); }); this.$container.selectAll('.' + this.classNames.colorText).attr('value', function (d) { return chroma(d.color).hex(); }); } }, { key: "cx", value: function cx(className) { return this.options.baseClassName + '-' + className; } }]); return TintedPalette; }(); _defineProperty(TintedPalette, "DEFAULT_OPTIONS", { container: document.body, baseClassName: 'tinted' }); export { ColorWheelMarkerDatum, TintedPalette, TintedWheel, colorModes }; //# sourceMappingURL=index.js.map