solrkit
Version:
 
536 lines (466 loc) • 18.3 kB
JavaScript
// Color Wheel with D3
// http://github.com/benknight/kuler-d3
// Benjamin Knight
// MIT License
(function (root, factory) {
// AMD/requirejs: Define the module
if (typeof define === 'function' && define.amd) {
define(['tinycolor', 'd3'], factory);
} else {
// Expose to browser window
root.ColorWheel = factory(root.tinycolor, root.d3);
}
}(this, function (tinycolor, d3) {
'use strict';
const ColorWheelMarkerDatum = function ColorWheelMarkerDatum(color, name, show) {
this.color = tinycolor(color).toHsv();
this.name = name;
this.show = show;
};
const ColorWheel = function ColorWheel (options) {
const self = this;
// --- Settings ---
this.options = {
container: document.body,
radius: 175,
margin: 0, // space around the edge of the wheel
markerWidth: 5,
defaultSlice: 20,
initRoot: 'red',
initMode: ColorWheel.modes.ANALOGOUS,
baseClassName: 'colorwheel',
};
// Merge default options with options param. (Similar to jQuery.extend)
if (typeof options === 'object') {
for (let option in options) {
if (option == 'initMode') {
ColorWheel.checkIfModeExists(options[option]);
}
this.options[option] = options[option];
}
}
let diameter = this.options.radius * 2;
this.currentMode = this.options.initMode;
this.container = d3.select(this.options.container);
this.slice = this.options.defaultSlice;
// --- Nodes ---
this.$ = {};
this.$.wheel = this.container.append('svg').attr({
'class': this.options.baseClassName,
width: diameter,
height: diameter,
viewBox: [
-1 * this.options.margin,
-1 * this.options.margin,
diameter + 2 * this.options.margin,
diameter + 2 * this.options.margin
].join(' ')
});
this.$.wheel.append('circle').attr({
fill: 'black',
r: this.options.radius,
cx: this.options.radius,
cy: this.options.radius,
transform: 'translate(4, 4)'
});
this.$.wheel.append('image').attr({
width: diameter,
height: diameter,
'xlink:href': 'http://benknight.github.io/kuler-d3/wheel.png'
});
this.$.markerTrails = this.$.wheel.append('g');
this.$.markers = this.$.wheel.append('g');
// --- Events ---
this.dispatch = d3.dispatch(
// Markers datum has changed, so redraw as necessary, etc.
'markersUpdated',
// "updateEnd" means the state of the ColorWheel has been finished updating.
'updateEnd',
// Initial data was successfully bound.
'bindData',
// The mode was changed
'modeChanged'
);
this.dispatch.on('bindData.default', function () {
self.setHarmony();
});
this.dispatch.on('markersUpdated.default', function () {
self.getMarkers().attr({
transform: function (d) {
const hue = ColorWheel.scientificToArtisticSmooth(d.color.h);
const p = self.getSVGPositionFromHS(d.color.h, d.color.s);
return ['translate(' + [p.x, p.y].join() + ')'].join(' ');
},
visibility: function (d) {
return d.show ? 'visible' : 'hidden';
}
}).select('circle').attr({
fill: function (d) {
return ColorWheel.hexFromHS(d.color.h, d.color.s);
}
});
self.container.selectAll(self.selector('marker-trail')).attr({
'x2': function (d) {
const p = self.getSVGPositionFromHS(d.color.h, d.color.s);
return p.x;
},
'y2': function (d) {
const p = self.getSVGPositionFromHS(d.color.h, d.color.s);
return p.y;
},
visibility: function (d) {
return d.show ? 'visible' : 'hidden';
}
});
});
this.dispatch.on('modeChanged.default', function () {
self.container.attr('data-mode', self.currentMode);
});
};
ColorWheel.prototype.bindData = function (newData) {
const self = this;
let data;
if (newData.constructor === Array) {
data = newData;
this.setMode(ColorWheel.modes.CUSTOM);
} else {
// We weren't given any data so create our own.
const numColors = (typeof newData === 'number') ? newData : 5;
data = Array.apply(null, {length: numColors}).map(function () {
return new ColorWheelMarkerDatum(self.options.initRoot, null, true);
});
}
const markerTrails = this.$.markerTrails.selectAll(this.selector('marker-trail')).data(data);
markerTrails.enter().append('line').attr({
'class': this.cx('marker-trail'),
'x1': this.options.radius,
'y1': this.options.radius,
'stroke': 'white',
'stroke-opacity': 0.75,
'stroke-width': 3,
'stroke-dasharray': '10, 6'
});
markerTrails.exit().remove();
const markers = this.$.markers.selectAll(this.selector('marker')).data(data);
markers.enter()
.append('g')
.attr({
'class': this.cx('marker'),
'visibility': 'visible'
})
.append('circle')
.attr({
'r': this.options.markerWidth / 2,
'stroke': 'white',
'stroke-width': 2,
'stroke-opacity': 0.9,
'cursor': 'move'
});
markers.exit().remove();
markers.append('text').text(function (d) { return d.name; }).attr({
x: (this.options.markerWidth / 2) + 8,
y: (this.options.markerWidth / 4) - 5,
fill: 'white',
'font-size': '13px',
});
markers.call(this.getDragBehavior());
this.dispatch.bindData(data);
this.dispatch.markersUpdated();
this.dispatch.updateEnd();
};
ColorWheel.prototype.getDragBehavior = function () {
const self = this;
return d3.behavior.drag()
.on('drag', function (d) {
let pos, hs, p, dragHue, startingHue, theta1, theta2;
pos = self.pointOnCircle(d3.event.x, d3.event.y);
hs = self.getHSFromSVGPosition(pos.x, pos.y);
d.color.h = hs.h;
d.color.s = hs.s;
p = self.svgToCartesian(d3.event.x, d3.event.y);
dragHue = ((Math.atan2(p.y, p.x) * 180 / Math.PI) + 720) % 360;
startingHue = parseFloat(d3.select(this).attr('data-startingHue'));
theta1 = (360 + startingHue - dragHue) % 360;
theta2 = (360 + dragHue - startingHue) % 360;
self.updateHarmony(this, theta1 < theta2 ? -1 * theta1 : theta2);
})
.on('dragstart', function () {
self.getVisibleMarkers().attr('data-startingHue', function (d) {
return ColorWheel.scientificToArtisticSmooth(d.color.h);
});
})
.on('dragend', function () {
let visibleMarkers = self.getVisibleMarkers();
visibleMarkers.attr('data-startingHue', null);
if (self.currentMode === ColorWheel.modes.ANALOGOUS) {
const rootTheta = ColorWheel.scientificToArtisticSmooth(d3.select(visibleMarkers[0][0]).datum().color.h);
if (visibleMarkers[0].length > 1) {
const neighborTheta = ColorWheel.scientificToArtisticSmooth(d3.select(visibleMarkers[0][1]).datum().color.h);
self.slice = (360 + neighborTheta - rootTheta) % 360;
}
}
self.dispatch.updateEnd();
});
};
ColorWheel.prototype.getMarkers = function () {
return this.container.selectAll(this.selector('marker'));
},
ColorWheel.prototype.getVisibleMarkers = function () {
return this.container.selectAll(this.selector('marker') + '[visibility=visible]');
},
ColorWheel.prototype.getRootMarker = function () {
return this.container.select(this.selector('marker') + '[visibility=visible]');
},
ColorWheel.prototype.setHarmony = function () {
const self = this;
const root = this.getRootMarker();
const offsetFactor = 0.08;
this.getMarkers().classed('root', false);
if (! root.empty()) {
const rootHue = ColorWheel.scientificToArtisticSmooth(root.datum().color.h);
switch (this.currentMode) {
case ColorWheel.modes.ANALOGOUS:
root.classed('root', true);
this.getVisibleMarkers().each(function (d, i) {
const newHue = (rootHue + (ColorWheel.markerDistance(i) * self.slice) + 720) % 360;
d.color.h = ColorWheel.artisticToScientificSmooth(newHue);
d.color.s = 1;
d.color.v = 1;
});
break;
case ColorWheel.modes.MONOCHROMATIC:
case ColorWheel.modes.SHADES:
this.getVisibleMarkers().each(function (d, i) {
d.color.h = ColorWheel.artisticToScientificSmooth(rootHue);
if (self.currentMode == ColorWheel.modes.SHADES) {
d.color.s = 1;
d.color.v = 0.25 + 0.75 * Math.random();
} else {
d.color.s = 1 - (0.15 * i + Math.random() * 0.1);
d.color.v = 0.75 + 0.25 * Math.random();
}
});
break;
case ColorWheel.modes.COMPLEMENTARY:
this.getVisibleMarkers().each(function (d, i) {
const newHue = (rootHue + ((i % 2) * 180) + 720) % 360;
d.color.h = ColorWheel.artisticToScientificSmooth(newHue);
d.color.s = 1 - offsetFactor * ColorWheel.stepFn(2)(i);
d.color.v = 1;
});
break;
case ColorWheel.modes.TRIAD:
this.getVisibleMarkers().each(function (d, i) {
const newHue = (rootHue + ((i % 3) * 120) + 720) % 360;
d.color.h = ColorWheel.artisticToScientificSmooth(newHue);
d.color.s = 1 - offsetFactor * ColorWheel.stepFn(3)(i);
d.color.v = 1;
});
break;
case ColorWheel.modes.TETRAD:
this.getVisibleMarkers().each(function (d, i) {
const newHue = (rootHue + ((i % 4) * 90) + 720) % 360;
d.color.h = ColorWheel.artisticToScientificSmooth(newHue);
d.color.s = 1 - offsetFactor * ColorWheel.stepFn(4)(i);
d.color.v = 1;
});
break;
}
this.dispatch.markersUpdated();
}
};
ColorWheel.prototype.updateHarmony = function (target, theta) {
const self = this;
const root = this.getRootMarker();
const rootHue = ColorWheel.scientificToArtisticSmooth(root.datum().color.h);
// Find out how far the dragging marker is from the root marker.
const cursor = target;
const counter = 0;
while (cursor = cursor.previousSibling) {
if (cursor.getAttribute('visibility') !== 'hidden') {
counter++;
}
}
const targetDistance = ColorWheel.markerDistance(counter);
switch (this.currentMode) {
case ColorWheel.modes.ANALOGOUS:
this.getVisibleMarkers().each(function (d, i) {
const startingHue = parseFloat(d3.select(this).attr('data-startingHue'));
const slices = 1;
if (targetDistance !== 0) {
slices = ColorWheel.markerDistance(i) / targetDistance;
}
if (this !== target) {
d.color.h = ColorWheel.artisticToScientificSmooth(
(startingHue + (slices * theta) + 720) % 360
);
}
});
break;
case ColorWheel.modes.MONOCHROMATIC:
case ColorWheel.modes.COMPLEMENTARY:
case ColorWheel.modes.SHADES:
case ColorWheel.modes.TRIAD:
case ColorWheel.modes.TETRAD:
this.getVisibleMarkers().each(function (d) {
const startingHue = parseFloat(d3.select(this).attr('data-startingHue'));
d.color.h = ColorWheel.artisticToScientificSmooth((startingHue + theta + 720) % 360);
if (self.currentMode == ColorWheel.modes.SHADES) {
d.color.s = 1;
}
});
break;
}
self.dispatch.markersUpdated();
};
ColorWheel.prototype.svgToCartesian = function (x, y) {
return {'x': x - this.options.radius, 'y': this.options.radius - y};
};
ColorWheel.prototype.cartesianToSVG = function (x, y) {
return {'x': x + this.options.radius, 'y': this.options.radius - y};
};
// Given an SVG point (x, y), returns the closest point to (x, y) still in the circle.
ColorWheel.prototype.pointOnCircle = function (x, y) {
const p = this.svgToCartesian(x, y);
if (Math.sqrt(p.x * p.x + p.y * p.y) <= this.options.radius) {
return {'x': x, 'y': y};
} else {
const theta = Math.atan2(p.y, p.x);
const x_ = this.options.radius * Math.cos(theta);
const y_ = this.options.radius * Math.sin(theta);
return this.cartesianToSVG(x_, y_);
}
};
// Get a coordinate pair from hue and saturation components.
ColorWheel.prototype.getSVGPositionFromHS = function (h, s) {
const hue = ColorWheel.scientificToArtisticSmooth(h);
const theta = hue * (Math.PI / 180);
const y = Math.sin(theta) * this.options.radius * s;
const x = Math.cos(theta) * this.options.radius * s;
return this.cartesianToSVG(x, y);
};
// Inverse of getSVGPositionFromHS
ColorWheel.prototype.getHSFromSVGPosition = function (x, y) {
const p = this.svgToCartesian(x, y);
const theta = Math.atan2(p.y, p.x);
const artisticHue = (theta * (180 / Math.PI) + 360) % 360;
const scientificHue = ColorWheel.artisticToScientificSmooth(artisticHue);
const s = Math.min(Math.sqrt(p.x*p.x + p.y*p.y) / this.options.radius, 1);
return {h: scientificHue, s: s};
};
ColorWheel.prototype._getColorsAs = function (toFunk) {
return this.getVisibleMarkers().data()
.sort(function (a, b) {
return a.color.h - b.color.h;
})
.map(function (d) {
return tinycolor({h: d.color.h, s: d.color.s, v: d.color.v})[toFunk]();
});
};
ColorWheel.prototype.getColorsAsHEX = function () {
return this._getColorsAs('toHexString');
};
ColorWheel.prototype.getColorsAsRGB = function () {
return this._getColorsAs('toRgbString');
};
ColorWheel.prototype.getColorsAsHSL = function () {
return this._getColorsAs('toHslString');
};
ColorWheel.prototype.getColorsAsHSV = function () {
return this._getColorsAs('toHsvString');
};
ColorWheel.prototype.setMode = function (mode) {
ColorWheel.checkIfModeExists(mode);
this.currentMode = mode;
this.setHarmony();
this.dispatch.updateEnd();
this.dispatch.modeChanged();
};
// Utility for building internal classname strings
ColorWheel.prototype.cx = function (className) {
return this.options.baseClassName + '-' + className;
};
ColorWheel.prototype.selector = function (className) {
return '.' + this.cx(className);
};
// These modes define a relationship between the colors on a color wheel,
// based on "science".
ColorWheel.modes = {
CUSTOM: 'Custom',
ANALOGOUS: 'Analogous',
COMPLEMENTARY: 'Complementary',
TRIAD: 'Triad',
TETRAD: 'Tetrad',
MONOCHROMATIC: 'Monochromatic',
SHADES: 'Shades',
};
// Simple range mapping function
// For example, mapRange(5, 0, 10, 0, 100) = 50
ColorWheel.mapRange = function (value, fromLower, fromUpper, toLower, toUpper) {
return (toLower + (value - fromLower) * ((toUpper - toLower) / (fromUpper - fromLower)));
};
// These two functions are ripped straight from Kuler source.
// They convert between scientific hue to the color wheel's "artistic" hue.
ColorWheel.artisticToScientificSmooth = function (hue) {
return (
hue < 60 ? hue * (35 / 60):
hue < 122 ? this.mapRange(hue, 60, 122, 35, 60):
hue < 165 ? this.mapRange(hue, 122, 165, 60, 120):
hue < 218 ? this.mapRange(hue, 165, 218, 120, 180):
hue < 275 ? this.mapRange(hue, 218, 275, 180, 240):
hue < 330 ? this.mapRange(hue, 275, 330, 240, 300):
this.mapRange(hue, 330, 360, 300, 360));
};
ColorWheel.scientificToArtisticSmooth = function (hue) {
return (
hue < 35 ? hue * (60 / 35):
hue < 60 ? this.mapRange(hue, 35, 60, 60, 122):
hue < 120 ? this.mapRange(hue, 60, 120, 122, 165):
hue < 180 ? this.mapRange(hue, 120, 180, 165, 218):
hue < 240 ? this.mapRange(hue, 180, 240, 218, 275):
hue < 300 ? this.mapRange(hue, 240, 300, 275, 330):
this.mapRange(hue, 300, 360, 330, 360));
};
// Get a hex string from hue and sat components, with 100% brightness.
ColorWheel.hexFromHS = function (h, s) {
return tinycolor({h: h, s: s, v: 1}).toHexString();
};
// 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, ... ]
ColorWheel.markerDistance = function (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, ...]
ColorWheel.stepFn = function (base) {
return function (x) { return Math.floor(x / base); }
};
// Throw an error if someone gives us a bad mode.
ColorWheel.checkIfModeExists = function (mode) {
let modeExists = false;
for (let possibleMode in ColorWheel.modes) {
if (ColorWheel.modes[possibleMode] == mode) {
modeExists = true;
break;
}
}
if (! modeExists) {
throw Error('Invalid mode specified: ' + mode);
}
return true;
};
// For creating custom markers
ColorWheel.createMarker = function (color, name, show) {
return new ColorWheelMarkerDatum(color, name, show);
};
// Provide a plugin interface
ColorWheel.plugins = {};
ColorWheel.extend = function (pluginId, pluginFn) {
this.plugins[pluginId] = pluginFn;
};
return ColorWheel;
}));