color-elements
Version:
A set of web components for working with color. A Color.js project.
440 lines (358 loc) • 12.6 kB
JavaScript
import "../color-scale/color-scale.js";
import "../channel-picker/channel-picker.js";
import ColorElement from "../common/color-element.js";
const Self = class ColorChart extends ColorElement {
static tagName = "color-chart";
static url = import.meta.url;
static styles = "./color-chart.css";
static globalStyles = "./color-chart-global.css";
static shadowTemplate = `
<slot name="color-channel">
<channel-picker id="channel_picker" part="color-channel"></channel-picker>
</slot>
<div id="chart-container">
<div id="chart">
<slot></slot>
</div>
</div>
<div class="axis" id="y_axis">
<div class="label" part="y label"></div>
<div class="ticks" part="y ticks"></div>
</div>
<div class="axis" id="x_axis">
<div class="label" part="x label"></div>
<div class="ticks" part="x ticks"></div>
</div>`;
static dependencies = new Set(["color-scale"]);
constructor () {
super();
this._el = {
slot: this.shadowRoot.querySelector("slot:not([name])"),
channel_picker: this.shadowRoot.getElementById("channel_picker"),
chart: this.shadowRoot.getElementById("chart"),
xTicks: this.shadowRoot.querySelector("#x_axis .ticks"),
yTicks: this.shadowRoot.querySelector("#y_axis .ticks"),
xLabel: this.shadowRoot.querySelector("#x_axis .label"),
yLabel: this.shadowRoot.querySelector("#y_axis .label"),
};
this._slots = {
color_channel: this.shadowRoot.querySelector("slot[name=color-channel]"),
};
}
connectedCallback () {
super.connectedCallback();
this._el.chart.addEventListener("colorschange", this, {capture: true});
this._slots.color_channel.addEventListener("input", this);
}
disconnectedCallback () {
this._el.chart.removeEventListener("colorschange", this, {capture: true});
this._slots.color_channel.removeEventListener("input", this);
}
handleEvent (evt) {
let source = evt.target;
if (source.tagName === "COLOR-SCALE" && evt.name === "computedColors") {
this.render(evt);
}
if (this._el.channel_picker === source || this._slots.color_channel.assignedElements().includes(source)) {
this.y = source.value;
}
}
series = new WeakMap();
render (evt) {
let colorScales = this.querySelectorAll("color-scale");
if (colorScales.length === 0) {
return;
}
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
for (let colorScale of colorScales) {
let scale = this.series.get(colorScale);
if (!scale || !evt || evt.target === colorScale || evt.target.nodeName !== "COLOR-SCALE") {
scale = this.renderScale(colorScale);
}
if (!scale) {
continue;
}
minX = Math.min(scale.x.min, minX);
maxX = Math.max(scale.x.max, maxX);
minY = Math.min(scale.y.min, minY);
maxY = Math.max(scale.y.max, maxY);
}
if (isFinite(minX) && isFinite(maxX)) {
let xAxis = getAxis(minX, maxX, 10);
this._el.chart.style.setProperty("--min-x", xAxis.min);
this._el.chart.style.setProperty("--max-x", xAxis.max);
this._el.chart.style.setProperty("--steps-x", xAxis.steps);
this._el.xTicks.innerHTML = Array(xAxis.steps).fill().map((_, i) => "<div part='x tick'>" + +(xAxis.min + i * xAxis.step).toPrecision(15) + "</div>").join("\n");
}
minY = this.yMin === "auto" || this.yMinAsNumber === undefined || Number.isNaN(this.yMinAsNumber) ? minY : this.yMinAsNumber;
maxY = this.yMax === "auto" || this.yMaxAsNumber === undefined || Number.isNaN(this.yMaxAsNumber) ? maxY : this.yMaxAsNumber;
if (isFinite(minY) && isFinite(maxY)) {
let yAxis = getAxis(minY, maxY, 10);
this._el.chart.style.setProperty("--min-y", yAxis.min);
this._el.chart.style.setProperty("--max-y", yAxis.max);
this._el.chart.style.setProperty("--steps-y", yAxis.steps);
this._el.yTicks.innerHTML = Array(yAxis.steps).fill().map((_, i) => "<div part='y tick'>" + +(yAxis.min + i * yAxis.step).toPrecision(15) + "</div>").reverse().join("\n");
this._el.yLabel.textContent = this.space.name + " " + this.yResolved.name;
}
}
renderScale (colorScale) {
if (!colorScale.computedColors) {
// Not yet initialized
return;
}
let ret = {
element: colorScale,
swatches: new WeakMap(),
x: {min: Infinity, max: -Infinity, values: new WeakMap() },
y: {min: Infinity, max: -Infinity, values: new WeakMap()},
colors: colorScale.computedColors?.slice() ?? [],
};
colorScale.style.setProperty("--color-count", ret.colors.length);
let yAll = ret.colors.map(({color}) => color.to(this.space).get(this.y));
let yMin = this.yMin === "auto" || this.yMinAsNumber === undefined || Number.isNaN(this.yMinAsNumber) ? -Infinity : this.yMinAsNumber;
let yMax = this.yMax === "auto" || this.yMaxAsNumber === undefined || Number.isNaN(this.yMaxAsNumber) ? Infinity : this.yMaxAsNumber;
if (this.yResolved.type === "angle") {
// First, normalize
yAll = normalizeAngles(yAll);
}
let index = 0;
for (let {name, color} of ret.colors) {
let swatch = colorScale._el.swatches.children[index];
ret.colors[index] = color = color.to(this.space);
ret.swatches.set(color, swatch);
// It's not always possible to use the last number in the color label as the X-coord;
// for example, the number “9” can't be interpreted as the X-coord in the “#90caf9” label.
// It might cause bugs with color order (see https://github.com/color-js/elements/issues/103).
// We expect the valid X-coord to be the only number in the color label (e.g., 50)
// or separated from the previous text with a space (e.g., Red 50 or Red / 50).
let x = name.match(/(?:^|\s)-?\d*\.?\d+$/)?.[0];
if (x !== undefined) {
// Transform `Label / X-coord` to `Label`
// (there should be at least one space before and after the slash so the number is treated as an X-coord)
let label = name.slice(0, -x.length).trim();
if (label.endsWith("/")) {
name = label.slice(0, -1).trim();
}
swatch.label = name;
x = Number(x);
}
else {
x = index;
}
let y = yAll[index];
ret.x.values.set(color, x);
ret.y.values.set(color, y);
let outOfRange = (isFinite(yMin) && y < yMin) || (isFinite(yMax) && y > yMax);
if (!outOfRange) {
// Only swatches that are in range participate in the min/max calculation
ret.x.min = Math.min(ret.x.min, x);
ret.x.max = Math.max(ret.x.max, x);
ret.y.min = Math.min(ret.y.min, y);
ret.y.max = Math.max(ret.y.max, y);
}
swatch.style.setProperty("--x", x);
swatch.style.setProperty("--y", y);
swatch.style.setProperty("--index", index);
if (HTMLElement.prototype.hasOwnProperty("popover") && !swatch._el.wrapper.hasAttribute("popover")) {
// The Popover API is supported
let popover = swatch._el.wrapper;
popover.setAttribute("popover", "");
// We need these for the popover to be correctly activated and positioned,
// otherwise, it won't be on the top layer
swatch.addEventListener("pointerenter", evt => {
// Position the popover relative to the parent swatch
// (instead of the center of the viewport by default)
let rect = swatch.getBoundingClientRect();
popover.style.setProperty("--_popover-left", rect.left + rect.width / 2 + "px");
popover.style.setProperty("--_popover-top", rect.top - rect.height / 2 + "px");
popover.showPopover();
});
swatch.addEventListener("pointerleave", evt => popover.hidePopover());
}
index++;
}
// Sort colors by X (ascending)
ret.colors.sort((a, b) => ret.x.values.get(a) - ret.x.values.get(b));
let prevColor;
for (let color of ret.colors) {
let swatch = ret.swatches.get(color);
if (prevColor !== undefined) {
prevColor.style.setProperty("--next-color", swatch.style.getPropertyValue("--color"));
prevColor.style.setProperty("--next-x", ret.x.values.get(color));
prevColor.style.setProperty("--next-y", ret.y.values.get(color));
}
prevColor = swatch;
}
if (prevColor !== undefined) {
// When we update colors, and we have fewer colors than before,
// we need to make sure the last swatch is not connected to the non-existent next swatch
["--next-color", "--next-x", "--next-y"].forEach(prop => prevColor.style.removeProperty(prop));
}
this.series.set(colorScale, ret);
return ret;
}
propChangedCallback (evt) {
let {name, prop, detail: change} = evt;
if (["yResolved", "yMinAsNumber", "yMaxAsNumber"].includes(name)) {
// Re-render swatches
this.render(evt);
}
if (name === "info") {
for (let colorScale of this.children) {
colorScale.info = this.info;
}
}
}
static props = {
y: {
default: "oklch.l",
convert (value) {
// Try setting the value to the channel picker. The picker will handle possible erroneous values.
this._el.channel_picker.value = value;
// If the value is not set, that means it's invalid.
// In that case, we are falling back to the picker's current value.
if (this._el.channel_picker.value !== value) {
return this._el.channel_picker.value;
}
return value;
},
},
yResolved: {
get () {
return Self.Color.Space.resolveCoord(this.y, "oklch");
},
// rawProp: "coord",
},
yMin: {
default: "auto",
changed (change) {
let { value } = change;
if (value === "auto") {
// `this.yMinAsNumber` will become `undefined` (i.e., get a new value), and the chart will be re-rendered
this._el.chart.style.removeProperty("--min-y");
}
},
reflect: {
from: "ymin",
},
},
yMinAsNumber: {
get () {
if (this.yMin === "coord") {
let range = this.yResolved.refRange ?? this.yResolved.range ?? [0, 100];
return range[0];
}
else if (this.yMin === "auto") {
let minY = this._el.chart.style.getPropertyValue("--min-y");
if (minY !== "") {
return Number(minY);
}
// Intermediate state (the chart is not rendered yet)
return;
}
return Number(this.yMin);
},
set (value) {
value = Number(value);
if (Number.isNaN(value)) {
this.yMin = "auto";
}
else {
this.yMin = value.toString();
}
},
},
yMax: {
default: "auto",
changed (change) {
let { value } = change;
if (value === "auto") {
// `this.yMaxAsNumber` will become `undefined` (i.e., get a new value), and the chart will be re-rendered
this._el.chart.style.removeProperty("--max-y");
}
},
reflect: {
from: "ymax",
},
},
yMaxAsNumber: {
get () {
if (this.yMax === "coord") {
let range = this.yResolved.refRange ?? this.yResolved.range ?? [0, 100];
return range[1];
}
else if (this.yMax === "auto") {
let maxY = this._el.chart.style.getPropertyValue("--max-y");
if (maxY !== "") {
return Number(maxY);
}
// Intermediate state (the chart is not rendered yet)
return;
}
return Number(this.yMax);
},
set (value) {
value = Number(value);
if (Number.isNaN(value)) {
this.yMax = "auto";
}
else {
this.yMax = value.toString();
}
},
},
space: {
default: "oklch",
get () {
return this.yResolved.space;
},
},
info: {},
};
};
Self.define();
export default Self;
function getAxis (min, max, initialSteps) {
let range = max - min;
let step = range / initialSteps;
let magnitude = Math.floor(Math.log10(step));
let base = Math.pow(10, magnitude);
let candidates = [base, base * 2, base * 5, base * 10];
for (let i = 0; i < candidates.length; i++) {
if (candidates[i] > step) {
step = candidates[i];
break;
}
}
let start = Math.floor(min / step) * step;
let end = Math.ceil(max / step) * step;
let steps = Math.round((end - start) / step);
let ret = {min: start, max: end, step, steps};
for (let prop in ret) {
ret[prop] = +ret[prop].toPrecision(15);
}
return ret;
}
function normalizeAngles (angles) {
// First, normalize
angles = angles.map(h => ((h % 360) + 360) % 360);
// Remove top and bottom 25% and find average
let averageHue = angles.toSorted((a, b) => a - b).slice(angles.length / 4, -angles.length / 4).reduce((a, b) => a + b, 0) / angles.length;
for (let i = 0; i < angles.length; i++) {
let h = angles[i];
let prevHue = angles[i - 1];
let delta = h - prevHue;
if (Math.abs(delta) > 180) {
let equivalent = [h + 360, h - 360];
// Offset hue to minimize difference in the direction that brings it closer to the average
if (Math.abs(equivalent[0] - averageHue) <= Math.abs(equivalent[1] - averageHue)) {
angles[i] = equivalent[0];
}
else {
angles[i] = equivalent[1];
}
}
}
return angles;
}