poline
Version:
color palette generator mico-lib
324 lines (281 loc) • 10.1 kB
text/typescript
import { Poline, positionFunctions, ColorPoint } from "./index";
// Re-export for convenience when using the picker standalone
export { Poline, positionFunctions };
const namespaceURI = "http://www.w3.org/2000/svg";
const svgscale = 100;
export class PolinePicker extends HTMLElement {
private poline: Poline;
private svg: SVGElement;
private interactive: boolean;
private line: SVGPolylineElement;
private wheel: SVGGElement;
private anchors: SVGGElement;
private points: SVGGElement;
private currentPoint: ColorPoint | null = null;
private allowAddPoints = false;
// Store bound event handlers for cleanup
private boundPointerDown = this.handlePointerDown.bind(this);
private boundPointerMove = this.handlePointerMove.bind(this);
private boundPointerUp = this.handlePointerUp.bind(this);
constructor() {
super();
this.attachShadow({ mode: "open" });
this.interactive = this.hasAttribute("interactive");
this.allowAddPoints = this.hasAttribute("allow-add-points");
}
connectedCallback() {
this.render();
if (this.interactive) {
this.addEventListeners();
}
}
disconnectedCallback() {
// Clean up event listeners when component is removed from DOM
this.removeEventListeners();
}
setPoline(poline: Poline) {
this.poline = poline;
this.updateSVG();
this.updateLightnessBackground();
}
setAllowAddPoints(allow: boolean) {
this.allowAddPoints = allow;
}
addPointAtPosition(x: number, y: number) {
if (!this.poline) return null;
// Convert to normalized coordinates (0-1)
const normalizedX = x / this.svg.clientWidth;
const normalizedY = y / this.svg.clientHeight;
// Use the normalized Y coordinate as the Z (lightness) coordinate
const newPoint = this.poline.addAnchorPoint({
xyz: [normalizedX, normalizedY, normalizedY],
});
this.updateSVG();
this.dispatchPolineChange();
return newPoint;
}
private updateLightnessBackground() {
const picker = this.shadowRoot?.querySelector(".picker") as HTMLElement;
if (picker && this.poline) {
if (this.poline.invertedLightness) {
picker.style.setProperty("--maxL", "#000");
picker.style.setProperty("--minL", "#fff");
} else {
picker.style.setProperty("--maxL", "#fff");
picker.style.setProperty("--minL", "#000");
}
}
}
private render() {
if (!this.shadowRoot) {
return;
}
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
width: 100%;
}
.picker {
position: relative;
width: 100%;
aspect-ratio: 1;
--wheelS: var(--poline-picker-wheel-saturation, .4);
--wheelL: var(--poline-picker-wheel-lightness, .5);
--minL: #000;
--maxL: #fff;
--grad: hsl(0deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 0deg, hsl(60deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 60deg, hsl(120deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 120deg, hsl(180deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 180deg, hsl(240deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 240deg, hsl(300deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 300deg, hsl(360deg calc(var(--wheelS) * 100%) calc(var(--wheelL) * 100%)) 360deg;
}
.picker::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: radial-gradient(closest-side, var(--minL), rgba(255, 255, 255, 0), var(--maxL)),
conic-gradient(from 90deg, var(--grad));
z-index: 1;
}
svg {
position: relative;
z-index: 2;
overflow: visible;
width: 100%;
}
.wheel__line {
stroke: var(--poline-picker-line-color, #000);
stroke-width: calc(0.75 * var(--poline-picker-line-width, 0.2));
fill: none;
}
.wheel__anchor {
cursor: grab;
stroke: var(--poline-picker-line-color, #000);
stroke-width: var(--poline-picker-line-width, 0.2);
fill: var(--poline-picker-bg-color, #fff);
}
.wheel__anchor:hover {
cursor: grabbing;
}
.wheel__point {
stroke: var(--poline-picker-line-color, #000);
stroke-width: calc(0.75 * var(--poline-picker-line-width, 0.2));
pointer-events: none;
}
</style>
`;
this.svg = this.createSVG();
const pickerDiv = document.createElement("div");
pickerDiv.className = "picker";
pickerDiv.appendChild(this.svg);
this.shadowRoot.appendChild(pickerDiv);
this.wheel = this.svg.querySelector(".wheel") as SVGGElement;
this.line = this.svg.querySelector(".wheel__line") as SVGPolylineElement;
this.anchors = this.svg.querySelector(".wheel__anchors") as SVGGElement;
this.points = this.svg.querySelector(".wheel__points") as SVGGElement;
if (this.poline) {
this.updateSVG();
}
}
private createSVG() {
const svg = document.createElementNS(namespaceURI, "svg");
svg.setAttribute("viewBox", `0 0 ${svgscale} ${svgscale}`);
svg.innerHTML = `
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="1" result="blur" />
<feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 18 -7" result="goo" />
<feBlend in="SourceGraphic" in2="goo" />
</filter>
</defs>
<g class="wheel" filter="url(#goo)">
<polyline class="wheel__line" points="" />
<g class="wheel__anchors"></g>
<g class="wheel__points"></g>
</g>
`;
return svg;
}
public updateSVG() {
if (!this.poline || !this.svg) {
return;
}
// 1) Draw line paths
const pathPoints = this.poline.flattenedPoints
.map((p) => {
const cartesian = this.pointToCartesian(p);
if (!cartesian) return "";
const [x, y] = cartesian;
return `${x},${y}`;
})
.filter((point) => point !== "")
.join(" ");
this.line.setAttribute("points", pathPoints);
// Clear existing elements
this.anchors.innerHTML = "";
this.points.innerHTML = "";
// 2) Draw anchor points (white dots at the ends)
this.poline.anchorPoints.forEach((point) => {
const anchor = this.createCircleElement(point, "wheel__anchor", "2");
if (anchor) {
this.anchors.appendChild(anchor);
}
});
// 3) Draw intermediate points (sample dots along the lines) - TOP layer
this.poline.flattenedPoints.forEach((point) => {
const radius = 0.5 + point.color[1];
const circle = this.createCircleElement(point, "wheel__point", radius);
if (circle) {
this.points.appendChild(circle);
}
});
}
private pointToCartesian(point: ColorPoint) {
const half = svgscale / 2;
const x = half + (point.x - 0.5) * svgscale;
const y = half + (point.y - 0.5) * svgscale;
return [x, y];
}
private addEventListeners() {
if (!this.svg) return;
this.svg.addEventListener("pointerdown", this.boundPointerDown);
this.svg.addEventListener("pointermove", this.boundPointerMove);
this.svg.addEventListener("pointerup", this.boundPointerUp);
}
private removeEventListeners() {
if (!this.svg) return;
this.svg.removeEventListener("pointerdown", this.boundPointerDown);
this.svg.removeEventListener("pointermove", this.boundPointerMove);
this.svg.removeEventListener("pointerup", this.boundPointerUp);
}
private handlePointerDown(e: PointerEvent) {
e.stopPropagation();
const { normalizedX, normalizedY } = this.pointerToNormalizedCoordinates(e);
const closestAnchor = this.poline.getClosestAnchorPoint({
xyz: [normalizedX, normalizedY, null],
maxDistance: 0.1,
});
if (closestAnchor) {
this.currentPoint = closestAnchor;
} else if (this.allowAddPoints) {
this.currentPoint = this.poline.addAnchorPoint({
xyz: [normalizedX, normalizedY, normalizedY],
});
this.updateSVG();
this.dispatchPolineChange();
}
}
private handlePointerMove(e: PointerEvent) {
if (this.currentPoint) {
const { normalizedX, normalizedY } =
this.pointerToNormalizedCoordinates(e);
this.poline.updateAnchorPoint({
point: this.currentPoint,
xyz: [normalizedX, normalizedY, this.currentPoint.z],
});
this.updateSVG();
this.dispatchPolineChange();
}
}
private handlePointerUp() {
this.currentPoint = null;
}
private getPointerPosition(e: PointerEvent) {
const rect = this.svg.getBoundingClientRect();
return {
x: e.clientX - rect.left,
y: e.clientY - rect.top,
};
}
private pointerToNormalizedCoordinates(e: PointerEvent) {
const svgRect = this.svg.getBoundingClientRect();
const svgX = ((e.clientX - svgRect.left) / svgRect.width) * svgscale;
const svgY = ((e.clientY - svgRect.top) / svgRect.height) * svgscale;
return {
normalizedX: svgX / svgscale,
normalizedY: svgY / svgscale,
};
}
private createCircleElement(
point: ColorPoint,
className: string,
radius: number | string
): SVGCircleElement | null {
const cartesian = this.pointToCartesian(point);
if (!cartesian) return null;
const [x = 0, y = 0] = cartesian;
const circle = document.createElementNS(namespaceURI, "circle");
circle.setAttribute("class", className);
circle.setAttribute("cx", x.toString());
circle.setAttribute("cy", y.toString());
circle.setAttribute("r", radius.toString());
circle.setAttribute("fill", point.hslCSS);
return circle;
}
private dispatchPolineChange() {
this.dispatchEvent(
new CustomEvent("poline-change", {
detail: { poline: this.poline },
})
);
}
}
customElements.define("poline-picker", PolinePicker);