tinted
Version:
An interactive color wheel for the browser
701 lines (605 loc) • 25 kB
JavaScript
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