color-elements
Version:
A set of web components for working with color. A Color.js project.
627 lines (531 loc) • 15.4 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 = import("./color-chart-shadow.css", { with: { type: "css" } }).catch(
() => new URL("./color-chart-shadow.css", import.meta.url),
);
static globalStyles = import("./color-chart-global.css", { with: { type: "css" } }).catch(
() => new URL("./color-chart-global.css", import.meta.url),
);
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") {
// TODO check if changed
this.render(evt);
}
if (
this._el.channel_picker === source ||
this._slots.color_channel.assignedElements().includes(source)
) {
this.y = source.value;
}
}
series = new WeakMap();
bounds = { x: { min: Infinity, max: -Infinity }, y: { min: Infinity, max: -Infinity } };
// Follow the provided min/max values, if any
get force () {
const ctx = this;
return {
x: {
get min () {
return !isNaN(ctx.xMin);
},
get max () {
return !isNaN(ctx.xMax);
},
},
y: {
get min () {
return !isNaN(ctx.yMin);
},
get max () {
return !isNaN(ctx.yMax);
},
},
};
}
render (evt) {
this.renderScales(evt);
this.renderAxis("x");
this.renderAxis("y");
}
/**
* (Re)render one of the axes
* @param {string} axis "x" or "y"
*/
renderAxis (axis) {
axis = axis.toLowerCase();
let min = this[`${axis}MinAsNumber`];
if (isNaN(min) || !isFinite(min)) {
// auto, undefined, etc
min = this.bounds[axis].min;
}
let max = this[`${axis}MaxAsNumber`];
if (isNaN(max) || !isFinite(max)) {
// auto, undefined, etc
max = this.bounds[axis].max;
}
if (isFinite(min) && isFinite(max)) {
const axisData = getAxis({
min,
max,
initialSteps: 10,
force: this.force[axis],
});
this._el.chart.style.setProperty(`--min-${axis}`, axisData.min);
this._el.chart.style.setProperty(`--max-${axis}`, axisData.max);
this._el.chart.style.setProperty(`--steps-${axis}`, axisData.steps);
const tickElements = Array(axisData.steps)
.fill()
.map(
(_, i) =>
`<div part='${axis} tick'>${+(axisData.min + i * axisData.step).toPrecision(15)}</div>`,
);
let ticksEl = this._el[`${axis}Ticks`];
ticksEl.innerHTML =
axis === "y" ? tickElements.toReversed().join("\n") : tickElements.join("\n");
}
let resolved = this[`${axis}Resolved`];
let labelEl = this._el[`${axis}Label`];
// Set axis label if we have a custom coordinate
labelEl.textContent = resolved ? this.space.name + " " + resolved.name : "";
}
/** (Re)render all scales */
renderScales (evt) {
// Reset every render: bounds depend on the current axes, so reusing them
// across axis/space changes leaks the previously charted channel's range.
this.bounds = {
x: { min: Infinity, max: -Infinity },
y: { min: Infinity, max: -Infinity },
};
for (let colorScale of this.querySelectorAll("color-scale")) {
let scale = this.series.get(colorScale);
if (
!scale ||
!evt ||
evt.target === colorScale ||
evt.target.nodeName !== "COLOR-SCALE"
) {
scale = this.renderScale(colorScale);
}
// Both fresh and cached scales contribute, since bounds were just reset.
if (scale) {
if (scale.x.min < this.bounds.x.min) {
this.bounds.x.min = scale.x.min;
}
if (scale.x.max > this.bounds.x.max) {
this.bounds.x.max = scale.x.max;
}
if (scale.y.min < this.bounds.y.min) {
this.bounds.y.min = scale.y.min;
}
if (scale.y.max > this.bounds.y.max) {
this.bounds.y.max = scale.y.max;
}
}
}
}
/** (Re)render one scale */
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);
// Helper function to calculate coordinates for an axis
const calculateAxisCoords = axis => {
const resolved = this[`${axis}Resolved`];
if (!resolved) {
return null;
}
const coords = ret.colors.map(({ color }) => color.to(this.space).get(this[axis]));
// Only clip to an explicitly set range. For "auto", the AsNumber getters
// return `this.bounds` — which we're still computing — so clipping would
// make bounds depend on scale order (later scales clipped to earlier ones).
const min = this[`${axis}Min`] === "auto" ? -Infinity : this[`${axis}MinAsNumber`];
const max = this[`${axis}Max`] === "auto" ? Infinity : this[`${axis}MaxAsNumber`];
if (resolved.type === "angle") {
// First, normalize
return { coords: normalizeAngles(coords), min, max };
}
return { coords, min, max };
};
const yAxis = calculateAxisCoords("y");
const xAxis = calculateAxisCoords("x");
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);
let x;
if (xAxis) {
// Use the calculated X coordinate from the specified coordinate
x = xAxis.coords[index];
}
else {
// 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).
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 = yAxis.coords[index];
ret.x.values.set(color, x);
ret.y.values.set(color, y);
const yOutOfRange =
(isFinite(yAxis.min) && y < yAxis.min) || (isFinite(yAxis.max) && y > yAxis.max);
const xOutOfRange = xAxis
? (isFinite(xAxis.min) && x < xAxis.min) || (isFinite(xAxis.max) && x > xAxis.max)
: false;
const outOfRange = yOutOfRange || xOutOfRange;
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);
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;
}
updated ({ changed }) {
for (let name of changed.keys()) {
if (/^[xy](?:Resolved|(?:Min|Max)AsNumber)$/.test(name)) {
this.render();
break;
}
}
if (changed.has("info")) {
for (let colorScale of this.children) {
colorScale.info = this.info;
}
}
}
static props = {
y: {
default: "oklch.l",
changed (change) {
this.bounds.y = { min: Infinity, max: -Infinity };
this._el.channel_picker.value = this.y;
},
convert (value) {
// Validates here, not in changed(), because re-entrant sets cause infinite recursion
try {
Self.Color.Space.resolveCoord(value, "oklch");
}
catch {
return this._el.channel_picker.value;
}
return value;
},
},
yResolved: {
get () {
return Self.Color.Space.resolveCoord(this.y, "oklch");
},
},
yMin: {
default: "auto",
changed (change) {
let { value } = change;
if (value === "auto") {
this.bounds.y.min = Infinity;
}
},
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") {
return this.bounds.y.min;
}
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.bounds.y.max = -Infinity;
}
},
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") {
return this.bounds.y.max;
}
return Number(this.yMax);
},
set (value) {
value = Number(value);
if (Number.isNaN(value)) {
this.yMax = "auto";
}
else {
this.yMax = value.toString();
}
},
},
x: {
default: null,
changed (change) {
this.bounds.x = { min: Infinity, max: -Infinity };
},
},
xResolved: {
get () {
return this.x ? Self.Color.Space.resolveCoord(this.x, "oklch") : null;
},
},
xMin: {
default: "auto",
changed (change) {
let { value } = change;
if (value === "auto") {
this.bounds.x.min = Infinity;
}
},
reflect: {
from: "xmin",
},
},
xMinAsNumber: {
get () {
let force = this.force;
if (this.x && this.xMin === "coord") {
let range = this.xResolved?.refRange ?? this.xResolved?.range ?? [0, 100];
return range[0];
}
else if ((!this.x && !force.x.min) || this.xMin === "auto") {
return this.bounds.x.min;
}
return Number(this.xMin);
},
set (value) {
value = Number(value);
if (Number.isNaN(value)) {
this.xMin = "auto";
}
else {
this.xMin = value.toString();
}
},
},
xMax: {
default: "auto",
changed (change) {
let { value } = change;
if (value === "auto") {
this.bounds.x.max = -Infinity;
}
},
reflect: {
from: "xmax",
},
},
xMaxAsNumber: {
get () {
let force = this.force;
if (this.x && this.xMax === "coord") {
let range = this.xResolved?.refRange ?? this.xResolved?.range ?? [0, 100];
return range[1];
}
else if ((!this.x && !force.x.max) || this.xMax === "auto") {
return this.bounds.x.max;
}
return Number(this.xMax);
},
set (value) {
value = Number(value);
if (Number.isNaN(value)) {
this.xMax = "auto";
}
else {
this.xMax = value.toString();
}
},
},
space: {
default: "oklch",
get () {
return this.yResolved.space;
},
},
info: {},
};
};
Self.define();
export default Self;
function getAxis ({ min, max, initialSteps, force = { min: false, max: false } }) {
if (min === max) {
// Constant channel: pad so range > 0. Otherwise step is 0, the tick count
// is NaN, and `Array(NaN)` throws RangeError: Invalid array length.
let pad = Math.abs(min) || 1;
min -= pad;
max += pad;
}
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;
}
}
if (force === true) {
force = { min: true, max: true };
}
let start = force.min ? min : Math.floor(min / step) * step;
let end = force.max ? max : 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;
}