├── .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 | 
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 | 
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 |
--------------------------------------------------------------------------------