├── .gitignore ├── LICENSE ├── README.md ├── build └── index.html ├── images ├── demo.gif └── palette_configurations.jpg ├── package-lock.json ├── package.json └── src ├── controls ├── layout.js └── pointerControl.js ├── index.js ├── models └── model.js ├── utils ├── color.js ├── geom.js ├── maths.js ├── signal.js └── webgl.js └── views ├── colorPreview.js ├── deletedColors.js ├── handles.js └── mesh.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/app.js* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 grgrdvrt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Poulette, the color palette 2 | 3 | Poulette is a proof of concept for a color mixer interface. 4 | [Play with the demo](https://www.grgrdvrt.com/poulette-demo) 5 | 6 | ![demo](images/demo.gif) 7 | 8 | 9 | ## Features 10 | - select an existing color by clicking on a point 11 | - click and drag on the palette to create a new color 12 | - organize the palette by dragging the points around 13 | - remove an existing color by dragging it outside of the component 14 | - deleted colors appear in a list an can be re-introduced. 15 | 16 | The the colors can be arranged in more familiar configurations. 17 | ![palettes configurations](images/palette_configurations.jpg) 18 | 19 | ## Code 20 | The wonderful [esbuild](https://esbuild.github.io/) is used for bundling the demo but any es6-compatible bundler should work. 21 | 22 | I chose to not package the code as a ready-to-use component as it is difficult to satisfy every react, angular, vue, svelte... but I'd be very happy to see adaptations for these frameworks. 23 | 24 | Note that this code has not been tested on a wide range of devices and browsers. Don't hesitate to report the issues you may find. 25 | 26 | -------------------------------------------------------------------------------- /build/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Poulette Demo 4 | 91 | 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /images/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grgrdvrt/poulette/cb0d8208544062321bfc41cdf8eaa71c617b39ad/images/demo.gif -------------------------------------------------------------------------------- /images/palette_configurations.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grgrdvrt/poulette/cb0d8208544062321bfc41cdf8eaa71c617b39ad/images/palette_configurations.jpg -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Poulette", 3 | "version": "1.0.0", 4 | "lockfileVersion": 1, 5 | "requires": true, 6 | "dependencies": { 7 | "esbuild": { 8 | "version": "0.9.4", 9 | "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.9.4.tgz", 10 | "integrity": "sha512-bF6laCiYE5+iAfZsX+v6Lwvi5QbvKN3tThxDIR2WLyLYzTzNn0ijdpqkvTVsafmRZjic2Nq1nkSf5RSWySDTjA==", 11 | "dev": true 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Poulette", 3 | "version": "0.1.0", 4 | "description": "An innovative color palette", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "esbuild src/index.js --outfile=build/app.js --bundle --sourcemap --watch", 8 | "build": "esbuild src/index.js --outfile=build/app.js --bundle --minify", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "author": "grgrdvrt", 12 | "license": "MIT", 13 | "dependencies": {}, 14 | "devDependencies": { 15 | "esbuild": "^0.9.4" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/controls/layout.js: -------------------------------------------------------------------------------- 1 | const minVel = 0.01; 2 | const pointsRepulsion = 0.05; 3 | const fr = 0.7; 4 | const minDist = 75; 5 | const marginSizeRatio = 0.05; 6 | const marginRepulsion = 0.05; 7 | 8 | export default class Layout { 9 | constructor(model){ 10 | this.model = model; 11 | 12 | this.margins = { 13 | left:marginSizeRatio * this.model.width, 14 | right:(1 - marginSizeRatio) * this.model.width, 15 | top:marginSizeRatio * this.model.height, 16 | bottom:(1 - marginSizeRatio) * this.model.height, 17 | }; 18 | 19 | this.points = this.model.points.map(pt => { 20 | return { 21 | vel:{x:0, y:0}, 22 | model:pt, 23 | }; 24 | }); 25 | 26 | this.model.pointAdded.add(this.addPoint, this); 27 | this.model.pointRemoved.add(this.removePoint, this); 28 | } 29 | 30 | addPoint(point){ 31 | this.points.push({model:point, vel:{x:0, y:0}}); 32 | } 33 | 34 | removePoint(point){ 35 | this.points = this.points.filter(pt => pt.model != point); 36 | } 37 | 38 | update(excludedPoints){ 39 | this.points.forEach((a, i) => { 40 | for(let j = i + 1; j < this.points.length; j++){ 41 | const b = this.points[j]; 42 | 43 | const dx = a.model.x - b.model.x; 44 | const dy = a.model.y - b.model.y; 45 | const dist = Math.hypot(dx, dy); 46 | if(dist < minDist){ 47 | const diff = minDist - dist; 48 | const r = pointsRepulsion * 0.5 * diff / dist; 49 | a.vel.x += dx * r; 50 | a.vel.y += dy * r; 51 | b.vel.x -= dx * r; 52 | b.vel.y -= dy * r; 53 | } 54 | } 55 | let dx = 0, dy = 0; 56 | if(a.model.x < this.margins.left){ 57 | dx = this.margins.left - a.model.x; 58 | } 59 | else if(a.model.x > this.margins.right){ 60 | dx = this.margins.right - a.model.x; 61 | } 62 | if(a.model.y < this.margins.top){ 63 | dy = this.margins.top - a.model.y; 64 | } 65 | else if(a.model.y > this.margins.bottom){ 66 | dy = this.margins.bottom - a.model.y; 67 | } 68 | a.vel.x += dx * marginRepulsion; 69 | a.vel.y += dy * marginRepulsion; 70 | }); 71 | excludedPoints.forEach(pt => { 72 | if(pt){ 73 | this.points.filter(p => p.model === pt.model).forEach(p => { 74 | p.vel.x = p.vel.y = 0; 75 | }); 76 | } 77 | }); 78 | this.points.forEach(point => { 79 | const {vel, model} = point; 80 | vel.x *= fr; 81 | vel.y *= fr; 82 | if(Math.hypot(vel.x, vel.y) < minVel){ 83 | vel.x = vel.y = 0; 84 | } 85 | model.x += vel.x; 86 | model.y += vel.y; 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/controls/pointerControl.js: -------------------------------------------------------------------------------- 1 | import { 2 | getColorInMesh 3 | } from "../utils/color"; 4 | 5 | const minDragDist = 5; 6 | 7 | export default class PointerControl { 8 | constructor(model, handles, mesh){ 9 | this.model = model; 10 | this.handles = handles; 11 | this.mesh = mesh; 12 | 13 | this.initialDraggingPosition = null; 14 | this.draggingHandle = null; 15 | this.selectionHandle = null; 16 | 17 | this.isTouchDown = false; 18 | this.pointerPosition = { 19 | x:undefined, 20 | y:undefined, 21 | }; 22 | 23 | this.handles.dom.addEventListener("mousedown", this.onHandleDown); 24 | this.mesh.dom.addEventListener("mousedown", this.onMeshDown); 25 | 26 | this.handles.dom.addEventListener("touchstart", this.onHandleDown); 27 | this.mesh.dom.addEventListener("touchstart", this.onMeshDown); 28 | } 29 | 30 | updatePointerPosition(e){ 31 | if(e.type === "touchend"){ 32 | return; 33 | } 34 | const rect = this.mesh.dom.getBoundingClientRect(); 35 | this.pointerPosition.x = ((e.touches?.[0] || e)?.pageX - rect.x) || 0; 36 | this.pointerPosition.y = ((e.touches?.[0] || e)?.pageY - rect.y) || 0; 37 | } 38 | 39 | onHandleDown = e => { 40 | this.updatePointerPosition(e); 41 | this.draggingHandle = this.handles.getHandleByDom(e.target); 42 | if(this.draggingHandle){ 43 | this.initialDraggingPosition = {...this.pointerPosition}; 44 | document.addEventListener("mouseup", this.onStopDragHandle); 45 | document.addEventListener("mousemove", this.onDragHandle); 46 | 47 | document.addEventListener("touchend", this.onStopDragHandle); 48 | document.addEventListener("touchmove", this.onDragHandle); 49 | } 50 | } 51 | 52 | onMeshDown = e => { 53 | this.updatePointerPosition(e); 54 | const color = getColorInMesh( 55 | this.model.triangles, 56 | this.pointerPosition.x, 57 | this.pointerPosition.y 58 | ); 59 | if(color){ 60 | this.selectionHandle = { 61 | dom:this.handles.createHandleDom(color), 62 | model:{...this.pointerPosition, color} 63 | }; 64 | 65 | this.handles.dom.appendChild(this.selectionHandle.dom); 66 | this.handles.setHandleDomPosition(this.selectionHandle.dom, this.pointerPosition); 67 | this.model.selectPoint(this.selectionHandle.model); 68 | 69 | document.addEventListener("mouseup", this.onStopDragSelection); 70 | document.addEventListener("mousemove", this.onDragSelection); 71 | 72 | document.addEventListener("touchend", this.onStopDragSelection); 73 | document.addEventListener("touchmove", this.onDragSelection); 74 | } 75 | } 76 | 77 | onDragHandle = e => { 78 | this.updatePointerPosition(e); 79 | this.draggingHandle.model.x = this.pointerPosition.x; 80 | this.draggingHandle.model.y = this.pointerPosition.y; 81 | } 82 | 83 | onStopDragHandle = e => { 84 | this.updatePointerPosition(e); 85 | if(this.model.isPointInArea(this.pointerPosition)){ 86 | if(Math.hypot( 87 | this.pointerPosition.x - this.initialDraggingPosition.x, 88 | this.pointerPosition.y - this.initialDraggingPosition.y, 89 | ) < minDragDist){ 90 | this.model.selectPoint(this.draggingHandle.model); 91 | } 92 | } 93 | else{ 94 | this.model.remove(this.draggingHandle.model); 95 | } 96 | this.draggingHandle = null; 97 | document.removeEventListener("mouseup", this.onStopDragHandle); 98 | document.removeEventListener("mousemove", this.onDragHandle); 99 | 100 | document.removeEventListener("touchend", this.onStopDragHandle); 101 | document.removeEventListener("touchmove", this.onDragHandle); 102 | } 103 | 104 | onDragSelection = e => { 105 | this.updatePointerPosition(e); 106 | const color = getColorInMesh( 107 | this.model.triangles, 108 | this.pointerPosition.x, 109 | this.pointerPosition.y 110 | ); 111 | if(color){ 112 | this.selectionHandle.model.color = color; 113 | this.handles.setHandleDomColor(this.selectionHandle.dom, color); 114 | this.model.selectColor(color); 115 | } 116 | this.handles.setHandleDomPosition(this.selectionHandle.dom, this.pointerPosition); 117 | } 118 | 119 | onStopDragSelection = e => { 120 | this.updatePointerPosition(e); 121 | if(this.model.isPointInArea(this.pointerPosition)){ 122 | const point = this.model.add(this.selectionHandle.model.color, this.pointerPosition); 123 | this.model.selectPoint(point); 124 | } 125 | this.handles.dom.removeChild(this.selectionHandle.dom); 126 | this.selectionHandle = null; 127 | 128 | document.removeEventListener("mouseup", this.onStopDragSelection); 129 | document.removeEventListener("mousemove", this.onDragSelection); 130 | 131 | document.removeEventListener("touchend", this.onStopDragSelection); 132 | document.removeEventListener("touchmove", this.onDragSelection); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Model from "./models/model"; 2 | 3 | import ColorPreview from "./views/colorPreview"; 4 | import Mesh from "./views/mesh"; 5 | import Handles from "./views/handles"; 6 | import DeletedColors from "./views/deletedColors"; 7 | 8 | import Layout from "./controls/layout"; 9 | import PointerControl from "./controls/pointerControl"; 10 | import { 11 | colorToHTML, 12 | hexToRgb, 13 | } from "./utils/color"; 14 | 15 | 16 | class Main { 17 | constructor(){ 18 | this.model = new Model(400, 400); 19 | const initialColors = [ 20 | 0xffffff,//white 21 | 0x000000,//black 22 | 0xe32b2b,//red 23 | 0xffab2e,//orange 24 | 0xffe500,//yellow 25 | 0x85ff5c,//green 26 | 0x57c7ff,//blue 27 | 0xff99dd,//pink 28 | ].map(hexToRgb); 29 | 30 | this.initPoints(initialColors); 31 | this.initDom(); 32 | document.body.appendChild(this.dom); 33 | 34 | this.layout = new Layout(this.model); 35 | this.pointerControl = new PointerControl(this.model, this.handles, this.mesh); 36 | } 37 | 38 | initDom(){ 39 | this.dom = document.createElement("div"); 40 | this.dom.classList.add("mainContainer"); 41 | this.dom.style.height = this.model.height + "px"; 42 | 43 | this.paletteContainer = document.createElement("div"); 44 | this.dom.appendChild(this.paletteContainer); 45 | this.paletteContainer.classList.add("paletteContainer"); 46 | Object.assign(this.paletteContainer.style, { 47 | width:this.model.width + "px", 48 | height:this.model.height + "px", 49 | }); 50 | 51 | this.preview = new ColorPreview(this.model); 52 | this.paletteContainer.appendChild(this.preview.dom); 53 | 54 | this.mesh = new Mesh(this.model); 55 | this.paletteContainer.appendChild(this.mesh.dom); 56 | 57 | this.handles = new Handles(this.model); 58 | this.paletteContainer.appendChild(this.handles.dom); 59 | 60 | this.deletedColors = new DeletedColors(this.model); 61 | this.dom.appendChild(this.deletedColors.dom); 62 | } 63 | 64 | initPoints(colors){ 65 | colors.forEach(color => { 66 | this.model.add( 67 | color, 68 | { 69 | x:Math.random() + 0.5 * this.model.width, 70 | y:Math.random() + 0.5 * this.model.height, 71 | } 72 | ); 73 | }); 74 | } 75 | 76 | update(){ 77 | this.layout.update([this.pointerControl.draggingPoint]); 78 | this.model.updateTriangles(); 79 | this.handles.update(); 80 | this.mesh.update(); 81 | requestAnimationFrame(this.update.bind(this)); 82 | } 83 | } 84 | 85 | 86 | const main = new Main(); 87 | main.update(); 88 | -------------------------------------------------------------------------------- /src/models/model.js: -------------------------------------------------------------------------------- 1 | import Signal from "../utils/signal"; 2 | import { 3 | getProjectedPoint, 4 | triangulate, 5 | } from "../utils/geom"; 6 | import {hexToRgb} from "../utils/color"; 7 | 8 | export default class Model{ 9 | constructor(width, height){ 10 | this.width = width; 11 | this.height = height; 12 | 13 | this.pointRemoved = new Signal(); 14 | this.pointAdded = new Signal(); 15 | this.pointSelected = new Signal(); 16 | this.colorSelected = new Signal(); 17 | 18 | this.points = []; 19 | } 20 | 21 | add(color, position){ 22 | const point = { 23 | color, 24 | x:position.x, 25 | y:position.y 26 | }; 27 | this.points.push(point); 28 | this.updateTriangles(); 29 | this.pointAdded.dispatch(point); 30 | return point; 31 | } 32 | 33 | remove(point){ 34 | const id = this.points.indexOf(point); 35 | if(id !== -1){ 36 | this.points.splice(id, 1); 37 | this.updateTriangles(); 38 | this.pointRemoved.dispatch(point); 39 | } 40 | return point; 41 | } 42 | 43 | updateTriangles(){ 44 | this.triangles = triangulate(this.points); 45 | } 46 | 47 | selectColor(color){ 48 | this.colorSelected.dispatch(color); 49 | } 50 | 51 | selectPoint(point){ 52 | this.pointSelected.dispatch(point); 53 | } 54 | 55 | retrieveColor(color){ 56 | const point = this.add( 57 | color, 58 | { 59 | x:Math.random() - 0.5 + 0.5 * this.width, 60 | y:Math.random() - 0.5 + 0.5 * this.height 61 | } 62 | ); 63 | this.selectPoint(point); 64 | } 65 | 66 | 67 | isPointInArea(position){ 68 | return position.x >= 0 69 | && position.y >= 0 70 | && position.x <= this.width 71 | && position.y <= this.height; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/utils/color.js: -------------------------------------------------------------------------------- 1 | import {lerp, sign} from "../utils/maths"; 2 | 3 | import { 4 | isPointInTriangle, 5 | barycentricCoordinates, 6 | getProjectedPoint, 7 | } from "../utils/geom"; 8 | 9 | const gamma = 2.2; 10 | export function rgbToLinear(rgb){ 11 | return { 12 | r:rgb.r ** gamma, 13 | g:rgb.g ** gamma, 14 | b:rgb.b ** gamma, 15 | }; 16 | } 17 | 18 | const iGamma = 1/2.2; 19 | export function rgbToGamma(rgb){ 20 | return { 21 | r:rgb.r ** iGamma, 22 | g:rgb.g ** iGamma, 23 | b:rgb.b ** iGamma, 24 | }; 25 | } 26 | 27 | export function getColorInMesh(triangles, x, y){ 28 | const pt = {x, y}; 29 | const tri = triangles.find(tri => { 30 | return isPointInTriangle(pt, ...tri); 31 | }); 32 | 33 | return tri 34 | ? getColorInTriangle(tri, pt) 35 | : getColorOutsideTriangles(triangles, pt); 36 | } 37 | 38 | export function getColorOutsideTriangles(triangles, pt){ 39 | const segments = []; 40 | triangles.forEach(([a, b, c]) => { 41 | segments.push([a, b], [a, c], [b, c]); 42 | }); 43 | 44 | const {v0, v1, u, px, py} = segments.reduce((best, [v0, v1]) => { 45 | const {x, y, u} = getProjectedPoint(v0, v1, pt); 46 | const dist = Math.hypot(pt.x - x, pt.y - y); 47 | return dist < best.dist ? {dist, u, v0, v1, px:x, py:y} : best; 48 | }, {dist:Number.POSITIVE_INFINITY, u:0, v0:null, v1:null}); 49 | 50 | return interpolateColors(v0.color, v1.color, u); 51 | } 52 | 53 | export function interpolateColors(c1, c2, t){ 54 | const al = rgbToLinear(c1); 55 | const bl = rgbToLinear(c2); 56 | return rgbToGamma({ 57 | r:lerp(al.r, bl.r, t), 58 | g:lerp(al.g, bl.g, t), 59 | b:lerp(al.b, bl.b, t), 60 | }); 61 | } 62 | 63 | export function getColorInTriangle(tri, pt){ 64 | const [w1, w2, w3] = barycentricCoordinates(pt, ...tri); 65 | 66 | const al = rgbToLinear(tri[0].color); 67 | const bl = rgbToLinear(tri[1].color); 68 | const cl = rgbToLinear(tri[2].color); 69 | 70 | return rgbToGamma({ 71 | r:al.r * w1 + bl.r * w2 + cl.r * w3, 72 | g:al.g * w1 + bl.g * w2 + cl.g * w3, 73 | b:al.b * w1 + bl.b * w2 + cl.b * w3, 74 | }); 75 | } 76 | 77 | export function colorToHTML(color){ 78 | const r = Math.round(color.r * 255); 79 | const g = Math.round(color.g * 255); 80 | const b = Math.round(color.b * 255); 81 | return `rgb(${r}, ${g}, ${b})`; 82 | } 83 | 84 | export function hexToRgb(color){ 85 | return { 86 | r:(color >> 16) / 255, 87 | g:(color >> 8 & 0xff) / 255, 88 | b:(color & 0xff) / 255, 89 | }; 90 | } 91 | -------------------------------------------------------------------------------- /src/utils/geom.js: -------------------------------------------------------------------------------- 1 | import { 2 | clamp, 3 | sign, 4 | } from "./maths"; 5 | const EPSILON = 1.0e-6; 6 | 7 | 8 | export function isPointInTriangle (pt, v1, v2, v3) 9 | { 10 | const d1 = sign(pt, v1, v2); 11 | const d2 = sign(pt, v2, v3); 12 | const d3 = sign(pt, v3, v1); 13 | 14 | const has_neg = (d1 < 0) || (d2 < 0) || (d3 < 0); 15 | const has_pos = (d1 > 0) || (d2 > 0) || (d3 > 0); 16 | 17 | return !(has_neg && has_pos); 18 | } 19 | 20 | export function barycentricCoordinates(pt, v1, v2, v3){ 21 | const r = (v2.y - v3.y) * (v1.x - v3.x) + (v3.x - v2.x) * (v1.y - v3.y); 22 | const w1 = ((v2.y - v3.y) * (pt.x - v3.x) + (v3.x - v2.x) * (pt.y - v3.y)) / r; 23 | const w2 = ((v3.y - v1.y) * (pt.x - v3.x) + (v1.x - v3.x) * (pt.y - v3.y)) / r; 24 | const w3 = 1 - w1 - w2; 25 | return [w1, w2, w3]; 26 | } 27 | 28 | export function computeCircumcircle(v0, v1, v2) { 29 | // From: http://www.exaflop.org/docs/cgafaq/cga1.html 30 | 31 | const A = v1.x - v0.x; 32 | const B = v1.y - v0.y; 33 | const C = v2.x - v0.x; 34 | const D = v2.y - v0.y; 35 | 36 | const E = A*(v0.x + v1.x) + B*(v0.y + v1.y); 37 | const F = C*(v0.x + v2.x) + D*(v0.y + v2.y); 38 | 39 | const G = 2.0*(A*(v2.y - v1.y)-B*(v2.x - v1.x)); 40 | 41 | let dx, dy; 42 | let center; 43 | 44 | if(Math.abs(G) < EPSILON) { 45 | 46 | const minx = Math.min(v0.x, v1.x, v2.x); 47 | const miny = Math.min(v0.y, v1.y, v2.y); 48 | const maxx = Math.max(v0.x, v1.x, v2.x); 49 | const maxy = Math.max(v0.y, v1.y, v2.y); 50 | 51 | center = { 52 | x:( minx + maxx) / 2, 53 | y:( miny + maxy ) / 2 54 | }; 55 | 56 | dx = center.x - minx; 57 | dy = center.y - miny; 58 | } 59 | else { 60 | const cx = (D*E - B*F) / G; 61 | const cy = (A*F - C*E) / G; 62 | 63 | center = {x:cx, y:cy}; 64 | 65 | dx = center.x - v0.x; 66 | dy = center.y - v0.y; 67 | } 68 | 69 | const radiusSquared = dx * dx + dy * dy; 70 | return { 71 | center, 72 | radiusSquared, 73 | }; 74 | } 75 | 76 | export function isInCircle(circle, v) { 77 | const dx = circle.center.x - v.x; 78 | const dy = circle.center.y - v.y; 79 | return (dx * dx + dy * dy <= circle.radiusSquared); 80 | } 81 | 82 | //https://stackoverflow.com/questions/10301001/perpendicular-on-a-line-segment-from-a-given-point 83 | export function getProjectedPoint(a, b, pt){ 84 | const dx = b.x - a.x; 85 | const dy = b.y - a.y; 86 | const dist2 = dx * dx + dy * dy; 87 | const u = clamp(0, 1, ((pt.x - a.x) * dx + (pt.y - a.y) * dy) / dist2); 88 | const x = a.x + u * dx; 89 | const y = a.y + u * dy; 90 | return {x, y, u}; 91 | } 92 | 93 | 94 | export function triangulate(vertices) { 95 | const triangles = []; 96 | const circles = []; 97 | 98 | //bounding triangle 99 | let xMin, yMin, xMax, yMax; 100 | xMin = yMin = Number.POSITIVE_INFINITY; 101 | xMax = yMax = Number.NEGATIVE_INFINITY; 102 | vertices.forEach(({x, y}) => { 103 | xMin = Math.min(x, xMin); 104 | yMin = Math.min(y, yMin); 105 | xMax = Math.max(x, xMax); 106 | yMax = Math.max(y, yMax); 107 | }); 108 | 109 | const dx = (xMax - xMin) * 10; 110 | const dy = (yMax - yMin) * 10; 111 | 112 | const st = [ 113 | {x:xMin - dx, y:yMin - dy*3}, 114 | {x:xMin - dx, y:yMax + dy}, 115 | {x:xMax + dx*3, y:yMax + dy}, 116 | ]; 117 | 118 | triangles.push(st); 119 | circles.push(computeCircumcircle(...st)); 120 | 121 | //incremental triangulation 122 | vertices.forEach(vertex => { 123 | const edges = []; 124 | 125 | triangles.forEach((tri, i) => { 126 | if(isInCircle(circles[i], vertex)) { 127 | edges.push( 128 | [tri[0], tri[1]], 129 | [tri[1], tri[2]], 130 | [tri[2], tri[0]], 131 | ); 132 | delete triangles[i]; 133 | delete circles[i]; 134 | } 135 | }); 136 | 137 | edges.forEach(([a1, b1], i) => { 138 | if(edges.find(([a2, b2], j) => { 139 | return i !== j 140 | && (a1 === a2 && b1 === b2) 141 | || (a1 === b2 && b1 === a2); 142 | })){return;} 143 | const tri = [a1, b1, vertex]; 144 | triangles.push(tri); 145 | circles.push(computeCircumcircle(...tri)); 146 | }); 147 | }); 148 | 149 | //remove bounding triangle 150 | return triangles.filter(tri => { 151 | return tri 152 | && !st.includes(tri[0]) 153 | && !st.includes(tri[1]) 154 | && !st.includes(tri[2]); 155 | }); 156 | } 157 | -------------------------------------------------------------------------------- /src/utils/maths.js: -------------------------------------------------------------------------------- 1 | export function lerp(a, b, t){ 2 | return a + t * (b - a); 3 | } 4 | 5 | export function clamp(min, max, v){ 6 | return Math.max(min, Math.min(v, max)); 7 | } 8 | 9 | export function sign (p1, p2, p3) { 10 | return (p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y); 11 | } 12 | -------------------------------------------------------------------------------- /src/utils/signal.js: -------------------------------------------------------------------------------- 1 | class Listener { 2 | constructor(signal, callback, scope, args) { 3 | this.callback = callback; 4 | this.scope = scope; 5 | this.args = args; 6 | this.once = false; 7 | this.executed = false;//ensure a "once" signal is not executed twice 8 | } 9 | 10 | exec(args) { 11 | this.callback.apply(this.scope, [...args, ...this.args]); 12 | } 13 | } 14 | 15 | export default class Signal { 16 | constructor() { 17 | this.listeners = []; 18 | } 19 | 20 | /* 21 | add the listener only if there isn't one for callback + scope 22 | args are not considered because we rarely listen to the same signal with different params 23 | */ 24 | add(callback, scope, ...args) { 25 | if(callback === undefined){ 26 | throw new Error("no callback specified"); 27 | } 28 | 29 | //check existing, doesn't consider arguments 30 | const n = this.listeners.length; 31 | for(let i = 0; i < n; i++){ 32 | const listener = this.listeners[i]; 33 | if(listener.callback === callback && listener.scope === scope){ 34 | return listener; 35 | } 36 | } 37 | 38 | const listener = new Listener(this, callback, scope, Array.from(args)); 39 | this.listeners.push(listener); 40 | return listener; 41 | } 42 | 43 | addOnce(callback, scope, ...args) { 44 | const listener = this.add(callback, scope, ...args); 45 | listener.once = true; 46 | return listener; 47 | } 48 | 49 | remove(callback, scope) { 50 | const n = this.listeners.length; 51 | for(let i = 0; i < n; i++) { 52 | const listener = this.listeners[i]; 53 | if(listener.callback == callback && listener.scope == scope) { 54 | this.listeners.splice(i, 1); 55 | return; 56 | } 57 | } 58 | } 59 | 60 | listenEvt(target, evtName){ 61 | const bind = this.dispatch.bind(this); 62 | target.addEventListener(evtName, bind); 63 | return {target, evtName, func:bind}; 64 | } 65 | 66 | unlistenEvt(bind){ 67 | bind.target.removeEventListener(bind.evtName, bind.func); 68 | } 69 | 70 | dispatch() { 71 | const args = Array.prototype.slice.call(arguments); 72 | for(let i = 0, ii = this.listeners.length; i < ii; i++){ 73 | const listener = this.listeners[i]; 74 | //undefined allows deletion of "onces" 75 | if(listener === undefined){ 76 | continue; 77 | } 78 | if(listener.once) { 79 | this.listeners[i] = undefined; 80 | } 81 | listener.exec(args); 82 | } 83 | 84 | //splice works better with reversed loops 85 | let i = this.listeners.length; 86 | while(i--){ 87 | if(this.listeners[i] === undefined){ 88 | this.listeners.splice(i, 1); 89 | } 90 | } 91 | } 92 | 93 | dispose() { 94 | this.listeners = []; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/utils/webgl.js: -------------------------------------------------------------------------------- 1 | export function createShader(gl, type, source) { 2 | const shader = gl.createShader(type); 3 | gl.shaderSource(shader, source); 4 | gl.compileShader(shader); 5 | if(gl.getShaderParameter(shader, gl.COMPILE_STATUS)){ 6 | return shader; 7 | } 8 | else{ 9 | console.error(gl.getShaderInfoLog(shader)); 10 | gl.deleteShader(shader); 11 | return null; 12 | } 13 | } 14 | 15 | export function createProgram(gl, vertexShader, fragmentShader) { 16 | const program = gl.createProgram(); 17 | gl.attachShader(program, vertexShader); 18 | gl.attachShader(program, fragmentShader); 19 | gl.linkProgram(program); 20 | if(gl.getProgramParameter(program, gl.LINK_STATUS)){ 21 | return program; 22 | } 23 | else{ 24 | console.error(gl.getProgramInfoLog(program)); 25 | gl.deleteProgram(program); 26 | return null; 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /src/views/colorPreview.js: -------------------------------------------------------------------------------- 1 | import { colorToHTML } from "../utils/color"; 2 | 3 | export default class ColorPreview{ 4 | constructor(model){ 5 | this.model = model; 6 | this.model.pointSelected.add(this.onPointSelected, this); 7 | this.model.colorSelected.add(this.onColorSelected, this); 8 | this.initDom(); 9 | } 10 | 11 | initDom(){ 12 | this.dom = document.createElement("div"); 13 | this.dom.classList.add("colorPreview"); 14 | Object.assign( 15 | this.dom.style, 16 | { 17 | width:this.model.width + "px", 18 | height:this.model.height + "px", 19 | } 20 | ); 21 | 22 | this.transitionElement = document.createElement("div"); 23 | this.transitionElement.classList.add("colorPreviewTransition"); 24 | const radius = Math.hypot(this.model.width, this.model.height); 25 | Object.assign( 26 | this.transitionElement.style, 27 | { 28 | width: 2 * radius + "px", 29 | height: 2 * radius + "px", 30 | borderRadius:radius + "px", 31 | } 32 | ); 33 | this.dom.appendChild(this.transitionElement); 34 | this.transitionElement.addEventListener("transitionend", () => { 35 | this.transitionElement.classList.remove("finalState"); 36 | this.dom.style.backgroundColor = colorToHTML(this.color); 37 | }); 38 | } 39 | 40 | onPointSelected(point){ 41 | this.setColor(point.color, point); 42 | } 43 | 44 | onColorSelected(color){ 45 | this.setColor(color); 46 | } 47 | 48 | setColor(color, position){ 49 | this.color = color; 50 | if(position){ 51 | Object.assign( 52 | this.transitionElement.style, 53 | { 54 | top:position.y + "px", 55 | left:position.x + "px", 56 | backgroundColor:colorToHTML(color), 57 | } 58 | ); 59 | this.transitionElement.classList.remove("finalState"); 60 | this.transitionElement.offsetWidth; 61 | this.transitionElement.classList.add("finalState"); 62 | } 63 | else{ 64 | this.dom.style.backgroundColor = colorToHTML(color); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/views/deletedColors.js: -------------------------------------------------------------------------------- 1 | import Signal from "../utils/signal"; 2 | import { colorToHTML } from "../utils/color"; 3 | 4 | export default class DeletedColors{ 5 | constructor(model){ 6 | this.model = model; 7 | this.items = new Map(); 8 | this.model.pointRemoved.add(this.addColor, this); 9 | this.initDom(); 10 | } 11 | 12 | initDom(){ 13 | this.dom = document.createElement("div"); 14 | this.dom.classList.add("deletedColors"); 15 | this.dom.addEventListener("click", this.onColorClicked); 16 | } 17 | 18 | addColor(point){ 19 | const item = document.createElement("button"); 20 | item.classList.add("deletedColorItem"); 21 | item.style.backgroundColor = colorToHTML(point.color); 22 | this.items.set(item, point.color); 23 | this.dom.appendChild(item); 24 | } 25 | 26 | onColorClicked = e => { 27 | if(this.items.has(e.target)){ 28 | const item = e.target; 29 | const color = this.items.get(item); 30 | item.remove(); 31 | this.items.delete(item); 32 | this.model.retrieveColor(color); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/views/handles.js: -------------------------------------------------------------------------------- 1 | import {colorToHTML} from "../utils/color"; 2 | 3 | export default class Handles { 4 | constructor(model){ 5 | this.model = model; 6 | this.model.pointAdded.add(this.addHandle, this); 7 | this.model.pointRemoved.add(this.removeHandle, this); 8 | 9 | this.initDom(); 10 | } 11 | 12 | initDom(){ 13 | this.dom = document.createElement("div"); 14 | this.dom.classList.add("handlesContainer"); 15 | 16 | this.handles = this.model.points.map(point => { 17 | return { 18 | model:point, 19 | dom:this.createHandleDom(point.color) 20 | }; 21 | }); 22 | this.handles.forEach(handle => this.dom.appendChild(handle.dom)); 23 | } 24 | 25 | createHandleDom(color){ 26 | const dom = document.createElement("div"); 27 | dom.classList.add("handle"); 28 | this.setHandleDomColor(dom, color); 29 | return dom; 30 | } 31 | 32 | getHandleByDom(dom){ 33 | return this.handles.find(handle => handle.dom === dom); 34 | } 35 | 36 | addHandle(point){ 37 | const handle = { 38 | model:point, 39 | dom:this.createHandleDom(point.color) 40 | }; 41 | this.handles.push(handle); 42 | this.dom.appendChild(handle.dom); 43 | } 44 | 45 | removeHandle(point){ 46 | const id = this.handles.findIndex(handle => handle.model === point); 47 | if(id !== -1){ 48 | const handle = this.handles[id]; 49 | this.dom.removeChild(handle.dom); 50 | this.handles.splice(id, 1); 51 | } 52 | } 53 | 54 | setHandleDomColor(handleDom, color){ 55 | handleDom.style.backgroundColor = colorToHTML(color); 56 | } 57 | 58 | setHandleDomPosition(handleDom, position){ 59 | Object.assign(handleDom.style, { 60 | top:position.y + "px", 61 | left:position.x + "px", 62 | }); 63 | } 64 | 65 | update(){ 66 | this.handles.forEach(handle => 67 | this.setHandleDomPosition(handle.dom, handle.model) 68 | ); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/views/mesh.js: -------------------------------------------------------------------------------- 1 | import { 2 | createShader, 3 | createProgram 4 | } from "../utils/webgl"; 5 | 6 | const vertexShaderSource = ` 7 | precision mediump float; 8 | 9 | attribute vec2 a_position; 10 | attribute vec3 a_color; 11 | 12 | varying vec3 v_color; 13 | const float gamma = 2.2; 14 | 15 | vec3 toLinear(vec3 v) { 16 | return pow(v, vec3(gamma)); 17 | } 18 | 19 | 20 | void main() { 21 | gl_Position = vec4(a_position, 0, 1); 22 | // v_color = a_color; 23 | v_color = toLinear(a_color); 24 | } 25 | `; 26 | 27 | const fragmentShaderSource = ` 28 | precision mediump float; 29 | varying vec3 v_color; 30 | const float gamma = 2.2; 31 | 32 | vec3 toGamma(vec3 v) { 33 | return pow(v, vec3(1.0 / gamma)); 34 | } 35 | 36 | void main() { 37 | gl_FragColor = vec4(toGamma(v_color), 1); 38 | // gl_FragColor = vec4(v_color, 1); 39 | } 40 | `; 41 | 42 | 43 | export default class Mesh{ 44 | constructor(model){ 45 | this.model = model; 46 | 47 | this.initDom(); 48 | this.gl = this.initGL(); 49 | } 50 | 51 | initDom(){ 52 | this.canvas = document.createElement("canvas"); 53 | this.canvas.width = this.model.width; 54 | this.canvas.height = this.model.height; 55 | this.dom = this.canvas; 56 | this.dom.classList.add("meshCanvas"); 57 | } 58 | 59 | initGL(){ 60 | const gl = this.canvas.getContext("webgl"); 61 | gl.viewport(0, 0, this.model.width, this.model.height); 62 | 63 | const program = createProgram( 64 | gl, 65 | createShader(gl, gl.VERTEX_SHADER, vertexShaderSource), 66 | createShader(gl, gl.FRAGMENT_SHADER, fragmentShaderSource), 67 | ); 68 | gl.useProgram(program); 69 | 70 | 71 | this.initPositionAttribute(gl, program); 72 | this.initColorAttribute(gl, program); 73 | this.indexBuffer = gl.createBuffer(); 74 | return gl; 75 | } 76 | 77 | initPositionAttribute(gl, program){ 78 | const positionAttributeLocation = gl.getAttribLocation(program, "a_position"); 79 | this.positionBuffer = gl.createBuffer(); 80 | gl.enableVertexAttribArray(positionAttributeLocation); 81 | gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer); 82 | gl.vertexAttribPointer( 83 | positionAttributeLocation, 2, gl.FLOAT, false, 0, 0 84 | ); 85 | } 86 | 87 | initColorAttribute(gl, program){ 88 | const colorAttributeLocation = gl.getAttribLocation(program, "a_color"); 89 | this.colorBuffer = gl.createBuffer(); 90 | gl.enableVertexAttribArray(colorAttributeLocation); 91 | gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); 92 | gl.vertexAttribPointer( 93 | colorAttributeLocation, 3, gl.FLOAT, false, 0, 0 94 | ); 95 | } 96 | 97 | update(){ 98 | const gl = this.gl; 99 | 100 | const nPoints = this.model.points.length; 101 | const positions = new Float32Array(2 * nPoints); 102 | const colors = new Float32Array(3 * nPoints); 103 | this.model.points.forEach((pt, i) => { 104 | 105 | positions[2 * i] = 2 * pt.x / this.model.width - 1; 106 | positions[2 * i + 1] = 1 - 2 * pt.y / this.model.height; 107 | 108 | colors[3 * i] = pt.color.r; 109 | colors[3 * i + 1] = pt.color.g; 110 | colors[3 * i + 2] = pt.color.b; 111 | }); 112 | 113 | const indices = []; 114 | this.model.triangles.forEach(t => { 115 | indices.push(...t.map(p => this.model.points.indexOf(p))); 116 | }); 117 | 118 | gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer); 119 | gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STREAM_DRAW); 120 | 121 | gl.bindBuffer(gl.ARRAY_BUFFER, this.positionBuffer); 122 | gl.bufferData(gl.ARRAY_BUFFER, positions, gl.STREAM_DRAW); 123 | 124 | gl.clearColor(0, 0, 0, 0); 125 | gl.clear(gl.COLOR_BUFFER_BIT); 126 | 127 | gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, this.indexBuffer); 128 | gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array(indices), gl.STATIC_DRAW); 129 | gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_SHORT,0); 130 | } 131 | } 132 | 133 | --------------------------------------------------------------------------------