├── .gitignore ├── .npmignore ├── tsconfig.json ├── rollup.config.js ├── README.md ├── package.json ├── LICENSE ├── tslint.json └── src ├── bezier.ts ├── color.ts ├── legra.ts ├── geometry.ts └── legra-core.ts /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | bin 3 | lib 4 | demo -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .vscode 4 | node_modules 5 | src 6 | .gitignore 7 | tsconfig.json 8 | tslint.json 9 | demo -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "module": "es2015", 5 | "moduleResolution": "node", 6 | "resolveJsonModule": true, 7 | "lib": [ 8 | "es2017", 9 | "dom" 10 | ], 11 | "declaration": true, 12 | "outDir": "./bin", 13 | "baseUrl": ".", 14 | "strict": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noImplicitReturns": true, 20 | "noFallthroughCasesInSwitch": true 21 | }, 22 | "include": [ 23 | "src/**/*.ts" 24 | ] 25 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { terser } from "rollup-plugin-terser"; 2 | 3 | const input = 'bin/legra.js'; 4 | 5 | export default [ 6 | { 7 | input, 8 | output: { 9 | file: `lib/legra.iife.js`, 10 | format: 'iife', 11 | name: 'legra' 12 | }, 13 | plugins: [terser()] 14 | }, 15 | { 16 | input, 17 | output: { 18 | file: `lib/legra.umd.js`, 19 | format: 'umd', 20 | name: 'legra' 21 | }, 22 | plugins: [terser()] 23 | }, 24 | { 25 | input, 26 | output: { 27 | file: `lib/legra.js`, 28 | format: 'esm' 29 | }, 30 | plugins: [terser()] 31 | }, 32 | ]; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![legra logo](https://legrajs.com/images/logo.png) 2 | 3 | # LEGRA 4 | 5 | Legra (**Le**go® brick **Gra**phics) is a small (*3.4KB gzipped*) JavaScript library that lets you draw using LEGO® like brick shapes on an HTML `` element. This library defines basic graphics primitives like lines, rectangles, polygons, ellipses, bézier curves, etc. All shapes are drawn either outlined or filled in. 6 | 7 | For documentation and more: https://legrajs.com 8 | 9 | ## License 10 | 11 | LEGO® is a trademark of [The LEGO Group](https://www.lego.com/en-us/aboutus/lego-group/the-lego-brand/). 12 | 13 | LegraJS is available to everyone under the [MIT license](https://github.com/pshihn/legra/blob/master/LICENSE) © [Preet Shihn](https://twitter.com/preetster) 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "legra", 3 | "version": "0.3.0", 4 | "description": "Create graphics using Lego like brick shapes.", 5 | "main": "lib/legra.umd.js", 6 | "module": "lib/legra.js", 7 | "browser": "lib/legra.iife.js", 8 | "types": "bin/legra.d.ts", 9 | "scripts": { 10 | "build": "rm -rf bin && tsc && rollup -c", 11 | "lint": "tslint -p tsconfig.json", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/pshihn/legra.git" 17 | }, 18 | "keywords": [ 19 | "graphics", 20 | "lego", 21 | "lego bricks", 22 | "canvas", 23 | "dataviz" 24 | ], 25 | "author": "Preet Shihn ", 26 | "license": "MIT", 27 | "bugs": { 28 | "url": "https://github.com/pshihn/legra/issues" 29 | }, 30 | "homepage": "https://legrajs.com", 31 | "devDependencies": { 32 | "rollup": "^1.31.0", 33 | "rollup-plugin-terser": "^5.2.0", 34 | "tslint": "^5.20.1", 35 | "typescript": "^3.7.5" 36 | } 37 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Preet Shihn 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 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "arrow-parens": true, 4 | "class-name": true, 5 | "indent": [ 6 | true, 7 | "spaces", 8 | 2 9 | ], 10 | "prefer-const": true, 11 | "no-duplicate-variable": true, 12 | "no-eval": true, 13 | "no-internal-module": true, 14 | "no-trailing-whitespace": false, 15 | "no-var-keyword": true, 16 | "one-line": [ 17 | true, 18 | "check-open-brace", 19 | "check-whitespace" 20 | ], 21 | "quotemark": [ 22 | true, 23 | "single", 24 | "avoid-escape" 25 | ], 26 | "semicolon": [ 27 | true, 28 | "always" 29 | ], 30 | "trailing-comma": [ 31 | true, 32 | "multiline" 33 | ], 34 | "triple-equals": [ 35 | true, 36 | "allow-null-check" 37 | ], 38 | "typedef-whitespace": [ 39 | true, 40 | { 41 | "call-signature": "nospace", 42 | "index-signature": "nospace", 43 | "parameter": "nospace", 44 | "property-declaration": "nospace", 45 | "variable-declaration": "nospace" 46 | } 47 | ], 48 | "variable-name": [ 49 | true, 50 | "ban-keywords" 51 | ], 52 | "whitespace": [ 53 | true, 54 | "check-branch", 55 | "check-decl", 56 | "check-operator", 57 | "check-separator", 58 | "check-type" 59 | ] 60 | } 61 | } -------------------------------------------------------------------------------- /src/bezier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is based on https://github.com/Pomax/bezierjs 3 | * under MIT Licensse 4 | **/ 5 | 6 | import { Point, derive, length, computeBezierPoint } from './geometry.js'; 7 | 8 | export class Bezier { 9 | private points: Point[] = []; 10 | private dpoints: Point[][] = []; 11 | private _lut: Point[] = []; 12 | private order = 3; 13 | 14 | constructor(p1: Point, p2: Point, p3: Point, p4?: Point) { 15 | this.points.push(p1, p2, p3); 16 | if (p4) { 17 | this.points.push(p4); 18 | } 19 | this.order = this.points.length - 1; 20 | this.update(); 21 | } 22 | 23 | private update() { 24 | this._lut = []; 25 | this.dpoints = derive(this.points); 26 | } 27 | 28 | length(): number { 29 | return length(this.derivative.bind(this)); 30 | } 31 | 32 | private derivative(t: number): Point { 33 | const mt = 1 - t; 34 | let a = 0; 35 | let b = 0; 36 | let c = 0; 37 | let p = this.dpoints[0]; 38 | if (this.order === 2) { 39 | p = [p[0], p[1], [0, 0]]; 40 | a = mt; 41 | b = t; 42 | } else if (this.order === 3) { 43 | a = mt * mt; 44 | b = mt * t * 2; 45 | c = t * t; 46 | } 47 | const ret: Point = [ 48 | a * p[0][0] + b * p[1][0] + c * p[2][0], 49 | a * p[0][1] + b * p[1][1] + c * p[2][1] 50 | ]; 51 | return ret; 52 | } 53 | 54 | getLUT(steps = 100): Point[] { 55 | if (!steps) return []; 56 | if (this._lut.length === steps) { 57 | return this._lut; 58 | } 59 | this._lut = []; 60 | steps--; 61 | for (let t = 0; t <= steps; t++) { 62 | this._lut.push(this.compute(t / steps)); 63 | } 64 | return this._lut; 65 | } 66 | 67 | private compute(t: number): Point { 68 | return computeBezierPoint(t, this.points); 69 | } 70 | } -------------------------------------------------------------------------------- /src/color.ts: -------------------------------------------------------------------------------- 1 | export interface Color { 2 | r: number; 3 | g: number; 4 | b: number; 5 | } 6 | 7 | export interface LabColor { 8 | l: number; 9 | a: number; 10 | b: number; 11 | } 12 | 13 | const labMap = new Map(); 14 | 15 | function toLab(c: Color): LabColor { 16 | const rgb = `${c.r}, ${c.g}, ${c.b}`; 17 | if (labMap.has(rgb)) { 18 | return labMap.get(rgb)!; 19 | } 20 | let [r, g, b] = [c.r / 255, c.g / 255, c.b / 255]; 21 | r = (r > 0.04045) ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92; 22 | g = (g > 0.04045) ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92; 23 | b = (b > 0.04045) ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92; 24 | let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047; 25 | let y = (r * 0.2126 + g * 0.7152 + b * 0.0722) / 1.00000; 26 | let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883; 27 | x = (x > 0.008856) ? Math.pow(x, 1 / 3) : (7.787 * x) + 16 / 116; 28 | y = (y > 0.008856) ? Math.pow(y, 1 / 3) : (7.787 * y) + 16 / 116; 29 | z = (z > 0.008856) ? Math.pow(z, 1 / 3) : (7.787 * z) + 16 / 116; 30 | const lab: LabColor = { 31 | l: (116 * y) - 16, 32 | a: 500 * (x - y), 33 | b: 200 * (y - z) 34 | }; 35 | labMap.set(rgb, lab); 36 | return lab; 37 | } 38 | 39 | function colorDifference(a: Color, b: Color) { 40 | const labA = toLab(a); 41 | const labB = toLab(b); 42 | return Math.sqrt( 43 | Math.pow(labB.l - labA.l, 2) + 44 | Math.pow(labB.a - labA.a, 2) + 45 | Math.pow(labB.b - labA.b, 2) 46 | ); 47 | } 48 | 49 | // function colorDifference2(a: Color, b: Color) { 50 | // const rbar = (a.r + b.r) / 2; 51 | // return Math.sqrt( 52 | // ((2 + (rbar / 256)) * Math.pow(b.r - a.r, 2)) + 53 | // (4 * Math.pow(b.g - a.g, 2)) + 54 | // ((2 + ((255 - rbar) / 256)) * Math.pow(b.b - a.b, 2)) 55 | // ); 56 | // } 57 | 58 | interface ColorDiffItem { 59 | diff: number; 60 | color: Color; 61 | } 62 | 63 | export function closestColor(color: Color, palette: Color[]): Color { 64 | if (palette.length) { 65 | const diffItems = palette.map((p) => { 66 | const diff = colorDifference(color, p); 67 | return { 68 | diff, 69 | color: p 70 | }; 71 | }); 72 | diffItems.sort((a, b) => { 73 | return a.diff - b.diff; 74 | }); 75 | console.log(color, diffItems[0]); 76 | return diffItems[0].color; 77 | } else { 78 | return color; 79 | } 80 | } -------------------------------------------------------------------------------- /src/legra.ts: -------------------------------------------------------------------------------- 1 | import { BrickRenderOptions, BrickRenderOptionsResolved, line, linearPath, rectangle, circle, ellipse, polygon, arc, bezierCurve, quadraticCurve, drawImage, ImageOrImageBitmap } from './legra-core.js'; 2 | import { Point } from './geometry.js'; 3 | 4 | export { ImageOrImageBitmap } from './legra-core'; 5 | 6 | export default class Legra { 7 | private ctx: CanvasRenderingContext2D; 8 | private defaultOptions: BrickRenderOptionsResolved = { 9 | brickSize: 24, 10 | color: '#2196F3', 11 | filled: false, 12 | palette: [] 13 | }; 14 | 15 | constructor(ctx: CanvasRenderingContext2D, brickSize = 24, options?: BrickRenderOptions) { 16 | this.ctx = ctx; 17 | this.defaultOptions.brickSize = brickSize; 18 | if (options) { 19 | if (options.color) { 20 | this.defaultOptions.color = options.color; 21 | } 22 | if (typeof options.filled === 'boolean') { 23 | this.defaultOptions.filled = options.filled; 24 | } 25 | if (options.palette) { 26 | this.defaultOptions.palette = options.palette; 27 | } 28 | } 29 | } 30 | 31 | private opt(options?: BrickRenderOptions): BrickRenderOptionsResolved { 32 | if (options) { 33 | return Object.assign({}, this.defaultOptions, options); 34 | } 35 | return this.defaultOptions; 36 | } 37 | 38 | line(x1: number, y1: number, x2: number, y2: number, options?: BrickRenderOptions) { 39 | line(x1, y1, x2, y2, this.ctx, this.opt(options)); 40 | } 41 | 42 | linearPath(points: Point[], options?: BrickRenderOptions) { 43 | linearPath(points, this.ctx, this.opt(options)); 44 | } 45 | 46 | rectangle(x: number, y: number, width: number, height: number, options?: BrickRenderOptions) { 47 | rectangle(x, y, width, height, this.ctx, this.opt(options)); 48 | } 49 | 50 | circle(xc: number, yc: number, radius: number, options?: BrickRenderOptions) { 51 | circle(xc, yc, radius, this.ctx, this.opt(options)); 52 | } 53 | 54 | ellipse(xc: number, yc: number, a: number, b: number, options?: BrickRenderOptions) { 55 | ellipse(xc, yc, a, b, this.ctx, this.opt(options)); 56 | } 57 | 58 | polygon(points: Point[], options?: BrickRenderOptions) { 59 | polygon(points, this.ctx, this.opt(options)); 60 | } 61 | 62 | arc(xc: number, yc: number, a: number, b: number, start: number, stop: number, closed: boolean, options?: BrickRenderOptions) { 63 | arc(xc, yc, a, b, start, stop, closed, this.ctx, this.opt(options)); 64 | } 65 | 66 | bezierCurve(x1: number, y1: number, cp1x: number, cp1y: number, cp2x: number, cp2y: number, x2: number, y2: number, options?: BrickRenderOptions) { 67 | bezierCurve(x1, y1, cp1x, cp1y, cp2x, cp2y, x2, y2, this.ctx, this.opt(options)); 68 | } 69 | 70 | quadraticCurve(x1: number, y1: number, cpx: number, cpy: number, x2: number, y2: number, options?: BrickRenderOptions) { 71 | quadraticCurve(x1, y1, cpx, cpy, x2, y2, this.ctx, this.opt(options)); 72 | } 73 | 74 | drawImage(image: ImageOrImageBitmap, dst: Point, dstSize?: Point, src?: Point, srcSize?: Point, options?: BrickRenderOptions) { 75 | drawImage(this.ctx, this.opt(options), image, dst, dstSize, src, srcSize); 76 | } 77 | } -------------------------------------------------------------------------------- /src/geometry.ts: -------------------------------------------------------------------------------- 1 | const TVALUES = [ 2 | -0.0640568928626056260850430826247450385909, 3 | 0.0640568928626056260850430826247450385909, 4 | -0.1911188674736163091586398207570696318404, 5 | 0.1911188674736163091586398207570696318404, 6 | -0.3150426796961633743867932913198102407864, 7 | 0.3150426796961633743867932913198102407864, 8 | -0.4337935076260451384870842319133497124524, 9 | 0.4337935076260451384870842319133497124524, 10 | -0.5454214713888395356583756172183723700107, 11 | 0.5454214713888395356583756172183723700107, 12 | -0.6480936519369755692524957869107476266696, 13 | 0.6480936519369755692524957869107476266696, 14 | -0.7401241915785543642438281030999784255232, 15 | 0.7401241915785543642438281030999784255232, 16 | -0.8200019859739029219539498726697452080761, 17 | 0.8200019859739029219539498726697452080761, 18 | -0.8864155270044010342131543419821967550873, 19 | 0.8864155270044010342131543419821967550873, 20 | -0.9382745520027327585236490017087214496548, 21 | 0.9382745520027327585236490017087214496548, 22 | -0.9747285559713094981983919930081690617411, 23 | 0.9747285559713094981983919930081690617411, 24 | -0.9951872199970213601799974097007368118745, 25 | 0.9951872199970213601799974097007368118745 26 | ]; 27 | 28 | const CVALUES = [ 29 | 0.1279381953467521569740561652246953718517, 30 | 0.1279381953467521569740561652246953718517, 31 | 0.1258374563468282961213753825111836887264, 32 | 0.1258374563468282961213753825111836887264, 33 | 0.121670472927803391204463153476262425607, 34 | 0.121670472927803391204463153476262425607, 35 | 0.1155056680537256013533444839067835598622, 36 | 0.1155056680537256013533444839067835598622, 37 | 0.1074442701159656347825773424466062227946, 38 | 0.1074442701159656347825773424466062227946, 39 | 0.0976186521041138882698806644642471544279, 40 | 0.0976186521041138882698806644642471544279, 41 | 0.086190161531953275917185202983742667185, 42 | 0.086190161531953275917185202983742667185, 43 | 0.0733464814110803057340336152531165181193, 44 | 0.0733464814110803057340336152531165181193, 45 | 0.0592985849154367807463677585001085845412, 46 | 0.0592985849154367807463677585001085845412, 47 | 0.0442774388174198061686027482113382288593, 48 | 0.0442774388174198061686027482113382288593, 49 | 0.0285313886289336631813078159518782864491, 50 | 0.0285313886289336631813078159518782864491, 51 | 0.0123412297999871995468056670700372915759, 52 | 0.0123412297999871995468056670700372915759 53 | ]; 54 | 55 | export type Point = [number, number]; 56 | 57 | export interface Rectangle { 58 | x: number; 59 | y: number; 60 | width: number; 61 | height: number; 62 | } 63 | 64 | export interface EdgeEntry { 65 | ymin: number; 66 | ymax: number; 67 | x: number; 68 | islope: number; 69 | } 70 | 71 | export interface ActiveEdgeEntry { 72 | s: number; 73 | edge: EdgeEntry; 74 | } 75 | 76 | export function angle(o: Point, v1: Point, v2: Point): number { 77 | const dx1 = v1[0] - o[0]; 78 | const dy1 = v1[1] - o[1]; 79 | const dx2 = v2[0] - o[0]; 80 | const dy2 = v2[1] - o[1]; 81 | const cross = dx1 * dy2 - dy1 * dx2; 82 | const dot = dx1 * dx2 + dy1 * dy2; 83 | return Math.atan2(cross, dot); 84 | } 85 | 86 | export function derive(points: Point[]): Point[][] { 87 | const dpoints: Point[][] = []; 88 | for (let p = points, d = p.length, c = d - 1; d > 1; d-- , c--) { 89 | const list: Point[] = []; 90 | let dpt: Point = [0, 0]; 91 | for (let j = 0; j < c; j++) { 92 | dpt = [ 93 | c * (p[j + 1][0] - p[j][0]), 94 | c * (p[j + 1][1] - p[j][1]) 95 | ]; 96 | list.push(dpt); 97 | } 98 | dpoints.push(list); 99 | p = list; 100 | } 101 | return dpoints; 102 | } 103 | 104 | export type DerivativeFn = (t: number) => Point; 105 | 106 | function arcfn(t: number, derivativeFn: DerivativeFn): number { 107 | const d = derivativeFn(t); 108 | const l = d[0] * d[0] + d[1] * d[1]; 109 | return Math.sqrt(l); 110 | } 111 | 112 | export function length(derivativeFn: DerivativeFn): number { 113 | const z = 0.5; 114 | let sum = 0; 115 | const len = TVALUES.length; 116 | for (let i = 0; i < len; i++) { 117 | const t = z * TVALUES[i] + z; 118 | sum += CVALUES[i] * arcfn(t, derivativeFn); 119 | } 120 | return z * sum; 121 | } 122 | 123 | export function computeBezierPoint(t: number, points: Point[]) { 124 | if (t === 0) { 125 | return points[0]; 126 | } 127 | const order = points.length - 1; 128 | if (t === 1) { 129 | return points[order]; 130 | } 131 | let p = points; 132 | const mt = 1 - t; 133 | 134 | // constant? 135 | if (order === 0) { 136 | return points[0]; 137 | } 138 | 139 | // linear? 140 | if (order === 1) { 141 | const ret: Point = [ 142 | mt * p[0][0] + t * p[1][0], 143 | mt * p[0][1] + t * p[1][1] 144 | ]; 145 | return ret; 146 | } 147 | 148 | // quadratic/cubic curve? 149 | if (order < 4) { 150 | const mt2 = mt * mt; 151 | const t2 = t * t; 152 | let d = 0; 153 | let a = 0; 154 | let b = 0; 155 | let c = 0; 156 | if (order === 2) { 157 | p = [p[0], p[1], p[2], [0, 0]]; 158 | a = mt2; 159 | b = mt * t * 2; 160 | c = t2; 161 | } else if (order === 3) { 162 | a = mt2 * mt; 163 | b = mt2 * t * 3; 164 | c = mt * t2 * 3; 165 | d = t * t2; 166 | } 167 | const ret: Point = [ 168 | a * p[0][0] + b * p[1][0] + c * p[2][0] + d * p[3][0], 169 | a * p[0][1] + b * p[1][1] + c * p[2][1] + d * p[3][1] 170 | ]; 171 | return ret; 172 | } 173 | 174 | // higher order curves: use de Casteljau's computation 175 | const dCpts: Point[] = JSON.parse(JSON.stringify(points)); 176 | while (dCpts.length > 1) { 177 | for (let i = 0; i < dCpts.length - 1; i++) { 178 | dCpts[i] = [ 179 | dCpts[i][0] + (dCpts[i + 1][0] - dCpts[i][0]) * t, 180 | dCpts[i][1] + (dCpts[i + 1][1] - dCpts[i][1]) * t 181 | ]; 182 | } 183 | dCpts.splice(dCpts.length - 1, 1); 184 | } 185 | return dCpts[0]; 186 | } -------------------------------------------------------------------------------- /src/legra-core.ts: -------------------------------------------------------------------------------- 1 | import { Point, Rectangle, EdgeEntry, ActiveEdgeEntry } from './geometry.js'; 2 | import { Bezier } from './bezier.js'; 3 | import { Color, closestColor } from './color'; 4 | 5 | export interface ImageOrImageBitmap { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | export interface BrickRenderOptions { 11 | color?: string; 12 | filled?: boolean; 13 | palette?: Color[]; 14 | } 15 | 16 | export interface BrickRenderOptionsResolved extends BrickRenderOptions { 17 | brickSize: number; 18 | color: string; 19 | filled: boolean; 20 | palette: Color[]; 21 | } 22 | 23 | const radiusCache = new Map(); 24 | function calculateRadius(size: number): number { 25 | if (radiusCache.has(size)) { 26 | return radiusCache.get(size)!; 27 | } 28 | const r = Math.min(24, (size * 0.5) / 2); 29 | radiusCache.set(size, r); 30 | return r; 31 | } 32 | 33 | function _drawBrick(ctx: CanvasRenderingContext2D, x: number, y: number, style: BrickRenderOptionsResolved) { 34 | const { brickSize, color } = style; 35 | ctx.save(); 36 | ctx.fillStyle = color; 37 | ctx.shadowColor = 'rgba(0,0,0,0.5)'; 38 | ctx.shadowBlur = 3; 39 | ctx.shadowOffsetX = 1; 40 | ctx.shadowOffsetY = 1; 41 | 42 | ctx.beginPath(); 43 | ctx.rect(x, y, brickSize, brickSize); 44 | ctx.fill(); 45 | ctx.beginPath(); 46 | ctx.arc(x + brickSize / 2, y + brickSize / 2, calculateRadius(brickSize), 0, Math.PI * 2); 47 | ctx.fill(); 48 | 49 | ctx.restore(); 50 | } 51 | 52 | function drawBrick(i: number, j: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 53 | const size = style.brickSize; 54 | _drawBrick(ctx, i * size, j * size, style); 55 | } 56 | 57 | function drawBrickList(points: Point[], ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved, sort = false) { 58 | if (sort) { 59 | points.sort((a, b) => { 60 | if (a[0] === b[0]) { 61 | if (a[1] < b[1]) { 62 | return -1; 63 | } else if (a[1] > b[1]) { 64 | return 1; 65 | } 66 | return 0; 67 | } 68 | if (a[0] < b[0]) { 69 | return -1; 70 | } else if (a[0] > b[0]) { 71 | return 1; 72 | } 73 | return 0; 74 | }); 75 | } 76 | const size = style.brickSize; 77 | points.forEach((p) => { 78 | _drawBrick(ctx, p[0] * size, p[1] * size, style); 79 | }); 80 | } 81 | 82 | function _line(x1: number, y1: number, x2: number, y2: number, used = new Set()): Point[] { 83 | const points: Point[] = []; 84 | 85 | const pushToPoints = (p: Point) => { 86 | const key = p.join(','); 87 | if (!used.has(key)) { 88 | used.add(key); 89 | points.push(p); 90 | } 91 | }; 92 | 93 | if (x1 === x2) { 94 | const min = Math.min(y1, y2); 95 | const max = Math.max(y1, y2); 96 | for (let j = min; j <= max; j++) { 97 | pushToPoints([x1, j]); 98 | } 99 | } else { 100 | const dy = y2 - y1; 101 | const dx = x2 - x1; 102 | const m = dy / dx; 103 | const c = y1 - (m * x1); 104 | if (Math.abs(dx) >= Math.abs(dy)) { 105 | const min = Math.min(x1, x2); 106 | const max = Math.max(x1, x2); 107 | for (let i = min; i <= max; i++) { 108 | const j = Math.round((m * i) + c); 109 | pushToPoints([i, j]); 110 | } 111 | } else { 112 | const min = Math.min(y1, y2); 113 | const max = Math.max(y1, y2); 114 | for (let j = min; j <= max; j++) { 115 | const i = Math.round((j - c) / m); 116 | pushToPoints([i, j]); 117 | } 118 | } 119 | } 120 | return points; 121 | } 122 | 123 | function _linearPath(points: Point[], used = new Set()): Point[] { 124 | let bricks: Point[] = []; 125 | for (let i = 0; i < points.length - 1; i++) { 126 | const [x1, y1] = points[i]; 127 | const [x2, y2] = points[i + 1]; 128 | const bp = _line(x1, y1, x2, y2, used); 129 | bricks = [...bricks, ...bp]; 130 | } 131 | return bricks; 132 | } 133 | 134 | /********************** 135 | * EXPORTED FUNCTIONS 136 | **********************/ 137 | 138 | export function rectangle(x: number, y: number, width: number, height: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved, fill = false) { 139 | if (style.filled || fill) { 140 | for (let i = 0; i < width; i++) { 141 | for (let j = 0; j < height; j++) { 142 | drawBrick(x + i, y + j, ctx, style); 143 | } 144 | } 145 | } else { 146 | if (width > 0 && height > 0) { 147 | linearPath([ 148 | [x, y], 149 | [x + width - 1, y], 150 | [x + width - 1, y + height - 1], 151 | [x, y + height - 1], 152 | [x, y] 153 | ], ctx, style); 154 | } 155 | } 156 | } 157 | 158 | export function line(x1: number, y1: number, x2: number, y2: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 159 | drawBrickList(_line(x1, y1, x2, y2), ctx, style); 160 | } 161 | 162 | export function linearPath(points: Point[], ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 163 | drawBrickList(_linearPath(points), ctx, style, true); 164 | } 165 | 166 | export function circle(xc: number, yc: number, radius: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 167 | ellipse(xc, yc, radius, radius, ctx, style); 168 | } 169 | 170 | export function ellipse(xc: number, yc: number, a: number, b: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 171 | let x = 0; 172 | let y = b; 173 | 174 | const a2 = a * a; 175 | const b2 = b * b; 176 | const crit1 = -(a2 / 4 + a % 2 + b2); 177 | const crit2 = -(b2 / 4 + b % 2 + a2); 178 | const crit3 = -(b2 / 4 + b % 2); 179 | let t = -a2 * y; 180 | let dxt = 2 * b2 * x; 181 | let dyt = -2 * a2 * y; 182 | const d2xt = 2 * b2; 183 | const d2yt = 2 * a2; 184 | 185 | const incx = () => { 186 | x++; 187 | dxt += d2xt; 188 | t += dxt; 189 | }; 190 | const incy = () => { 191 | y--; 192 | dyt += d2yt; 193 | t += dyt; 194 | }; 195 | 196 | if (style.filled) { 197 | const rects: Rectangle[] = []; 198 | let rx = x; 199 | let ry = y; 200 | let width = 1; 201 | let height = 1; 202 | 203 | const rectPush = (x: number, y: number, width: number, height: number) => { 204 | if (height < 0) { 205 | y += height + 1; 206 | height = Math.abs(height); 207 | } 208 | rects.push({ x, y, width, height }); 209 | }; 210 | 211 | if (b === 0) { 212 | rectPush(xc - 1, yc, 2 * a + 1, 1); 213 | } else { 214 | while (y >= 0 && x <= a) { 215 | if ((t + b2 * x <= crit1) || (t + a2 * y <= crit3)) { 216 | if (height === 1) { 217 | // do nothing; 218 | } else if ((ry * 2 + 1) > ((height - 1) * 2)) { 219 | rectPush(xc - rx, yc - ry, width, height - 1); 220 | rectPush(xc - rx, yc + ry, width, 1 - height); 221 | ry -= height - 1; 222 | height = 1; 223 | } else { 224 | rectPush(xc - rx, yc - ry, width, ry * 2 + 1); 225 | ry -= ry; 226 | height = 1; 227 | } 228 | incx(); 229 | rx++; 230 | width += 2; 231 | } else if ((t - a2 * y) > crit2) { 232 | incy(); 233 | height++; 234 | } else { 235 | if ((ry * 2 + 1) > (height * 2)) { 236 | rectPush(xc - rx, yc - ry, width, height); 237 | rectPush(xc - rx, yc + ry, width, -height); 238 | } else { 239 | rectPush(xc - rx, yc - ry, width, ry * 2 + 1); 240 | } 241 | incx(); 242 | incy(); 243 | rx++; 244 | width += 2; 245 | ry -= height; 246 | height = 1; 247 | } 248 | } 249 | if (ry > height) { 250 | rectPush(xc - rx, yc - ry, width, height); 251 | rectPush(xc - rx, yc + ry + 1, width, -height); 252 | } else { 253 | rectPush(xc - rx, yc - ry, width, ry * 2 + 1); 254 | } 255 | } 256 | rects.forEach((rect) => { 257 | if (rect.height < 0) { 258 | rect.y += rect.height + 1; 259 | rect.height = Math.abs(rect.height); 260 | } 261 | }); 262 | rects.sort((a, b) => { 263 | return a.y - b.y; 264 | }); 265 | rects.forEach((rect) => rectangle(rect.x, rect.y, rect.width, rect.height, ctx, style, true)); 266 | } else { 267 | const bricks: Point[] = []; 268 | while (y >= 0 && x <= a) { 269 | bricks.push([xc + x, yc + y]); 270 | if (x !== 0 || y !== 0) { 271 | bricks.push([xc - x, yc - y]); 272 | } 273 | if (x !== 0 && y !== 0) { 274 | bricks.push([xc + x, yc - y]); 275 | bricks.push([xc - x, yc + y]); 276 | } 277 | if ((t + b2 * x <= crit1) || (t + a2 * y <= crit3)) { 278 | incx(); 279 | } else if (t - a2 * y > crit2) { 280 | incy(); 281 | } else { 282 | incx(); 283 | incy(); 284 | } 285 | } 286 | drawBrickList(bricks, ctx, style, true); 287 | } 288 | } 289 | 290 | export function polygon(points: Point[], ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 291 | if (points.length === 0) { 292 | return; 293 | } 294 | if (points.length === 1) { 295 | drawBrick(points[0][0], points[0][1], ctx, style); 296 | return; 297 | } 298 | if (points.length === 2) { 299 | const [[x1, y1], [x2, y2]] = points; 300 | line(x1, y1, x2, y2, ctx, style); 301 | return; 302 | } 303 | const vertices = [...points]; 304 | if (vertices[0].join(',') !== vertices[vertices.length - 1].join(',')) { 305 | vertices.push([vertices[0][0], vertices[0][1]]); 306 | } 307 | 308 | if (!style.filled) { 309 | linearPath(vertices, ctx, style); 310 | } else { 311 | const used = new Set(); 312 | let bricks = _linearPath(vertices, used); 313 | 314 | // Create sorted edges table 315 | const edges: EdgeEntry[] = []; 316 | for (let i = 0; i < vertices.length - 1; i++) { 317 | const p1 = vertices[i]; 318 | const p2 = vertices[i + 1]; 319 | if (p1[1] !== p2[1]) { 320 | const ymin = Math.min(p1[1], p2[1]); 321 | edges.push({ 322 | ymin, 323 | ymax: Math.max(p1[1], p2[1]), 324 | x: ymin === p1[1] ? p1[0] : p2[0], 325 | islope: (p2[0] - p1[0]) / (p2[1] - p1[1]) 326 | }); 327 | } 328 | } 329 | edges.sort((e1, e2) => { 330 | if (e1.ymin < e2.ymin) { 331 | return -1; 332 | } 333 | if (e1.ymin > e2.ymin) { 334 | return 1; 335 | } 336 | if (e1.x < e2.x) { 337 | return -1; 338 | } 339 | if (e1.x > e2.x) { 340 | return 1; 341 | } 342 | if (e1.ymax === e2.ymax) { 343 | return 0; 344 | } 345 | return (e1.ymax - e2.ymax) / Math.abs((e1.ymax - e2.ymax)); 346 | }); 347 | 348 | let activeEdges: ActiveEdgeEntry[] = []; 349 | let y = edges[0].ymin; 350 | while (activeEdges.length || edges.length) { 351 | if (edges.length) { 352 | let ix = -1; 353 | for (let i = 0; i < edges.length; i++) { 354 | if (edges[i].ymin > y) { 355 | break; 356 | } 357 | ix = i; 358 | } 359 | const removed = edges.splice(0, ix + 1); 360 | removed.forEach((edge) => { 361 | activeEdges.push({ s: y, edge }); 362 | }); 363 | } 364 | activeEdges = activeEdges.filter((ae) => { 365 | if (ae.edge.ymax === y) { 366 | return false; 367 | } 368 | return true; 369 | }); 370 | activeEdges.sort((ae1, ae2) => { 371 | if (ae1.edge.x === ae2.edge.x) { 372 | return 0; 373 | } 374 | return (ae1.edge.x - ae2.edge.x) / Math.abs((ae1.edge.x - ae2.edge.x)); 375 | }); 376 | 377 | // fill between the edges 378 | if (activeEdges.length > 1) { 379 | for (let i = 0; i < activeEdges.length; i = i + 2) { 380 | const nexti = i + 1; 381 | if (nexti >= activeEdges.length) { 382 | break; 383 | } 384 | const ce = activeEdges[i].edge; 385 | const ne = activeEdges[nexti].edge; 386 | bricks = bricks.concat(_line(Math.round(ce.x), y, Math.round(ne.x), y, used)); 387 | } 388 | } 389 | 390 | y++; 391 | activeEdges.forEach((ae) => { 392 | ae.edge.x = ae.edge.x + ae.edge.islope; 393 | }); 394 | } 395 | drawBrickList(bricks, ctx, style, true); 396 | } 397 | } 398 | 399 | export function arc(xc: number, yc: number, a: number, b: number, start: number, stop: number, closed: boolean, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 400 | let angle1 = Math.min(start, stop); 401 | let angle2 = Math.max(start, stop); 402 | if (angle1 === angle2) { 403 | return; 404 | } 405 | if (a <= 0 || b <= 0) { 406 | return; 407 | } 408 | if (angle2 - angle1 > Math.PI * 2) { 409 | angle1 = 0; 410 | angle2 = Math.PI * 2; 411 | } 412 | const p = Math.round(2 * Math.sqrt((a * a + b * b) / 2) * (angle2 - angle1)); 413 | const da = (angle2 - angle1) / p; 414 | const used = new Set(); 415 | const points: Point[] = []; 416 | for (let i = 0; i <= p; i++) { 417 | const theta = angle1 + (i * da); 418 | const cos = Math.cos(theta); 419 | const sin = Math.sin(theta); 420 | const r = (a * b) / Math.sqrt(b * b * cos * cos + a * a * sin * sin); 421 | const point: Point = [xc + Math.round(r * cos), yc + Math.round(r * sin)]; 422 | const key = point.join(','); 423 | if (!used.has(key)) { 424 | used.add(key); 425 | points.push(point); 426 | } 427 | } 428 | if (closed) { 429 | const point: Point = [xc, yc]; 430 | const key = point.join(','); 431 | if (!used.has(key)) { 432 | used.add(key); 433 | points.push(point); 434 | } 435 | polygon(points, ctx, style); 436 | } else { 437 | linearPath(points, ctx, style); 438 | } 439 | } 440 | 441 | export function bezierCurve(x1: number, y1: number, cp1x: number, cp1y: number, cp2x: number, cp2y: number, x2: number, y2: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 442 | const bezier = new Bezier([x1, y1], [cp1x, cp1y], [cp2x, cp2y], [x2, y2]); 443 | const luts = bezier.getLUT(bezier.length()).map((p) => [Math.round(p[0]), Math.round(p[1])]); 444 | luts.push([x2, y2]); 445 | if (style.filled) { 446 | polygon(luts, ctx, style); 447 | } else { 448 | linearPath(luts, ctx, style); 449 | } 450 | } 451 | 452 | export function quadraticCurve(x1: number, y1: number, cpx: number, cpy: number, x2: number, y2: number, ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved) { 453 | const bezier = new Bezier([x1, y1], [cpx, cpy], [x2, y2]); 454 | const luts = bezier.getLUT(bezier.length()).map((p) => [Math.round(p[0]), Math.round(p[1])]); 455 | luts.push([x2, y2]); 456 | if (style.filled) { 457 | polygon(luts, ctx, style); 458 | } else { 459 | linearPath(luts, ctx, style); 460 | } 461 | } 462 | 463 | export function drawImage(ctx: CanvasRenderingContext2D, style: BrickRenderOptionsResolved, image: ImageOrImageBitmap, dst: Point, dstSize?: Point, src?: Point, srcSize?: Point) { 464 | const brickSize = style.brickSize; 465 | if (!src) { 466 | src = [0, 0]; 467 | } 468 | if (!srcSize) { 469 | srcSize = [image.width, image.height]; 470 | } 471 | if (!dstSize) { 472 | dstSize = [Math.round(srcSize[0] / brickSize), Math.round(srcSize[1] / brickSize)]; 473 | } 474 | const [refW, refH] = dstSize; 475 | const refCanvas = (typeof OffscreenCanvas !== 'undefined') ? new OffscreenCanvas(refW, refH) : document.createElement('canvas'); 476 | refCanvas.width = refW; 477 | refCanvas.height = refH; 478 | const refCtx = refCanvas.getContext('2d')!; 479 | refCtx.drawImage(image as any, src[0], src[1], srcSize[0], srcSize[1], 0, 0, dstSize[0], dstSize[1]); 480 | const imageData = refCtx.getImageData(0, 0, refW, refH); 481 | const colorMap = new Map(); 482 | for (let j = 0; j < refH; j++) { 483 | for (let i = 0; i < refW; i++) { 484 | let color: Color = { 485 | r: imageData.data[(j * refW * 4) + (i * 4)], 486 | g: imageData.data[(j * refW * 4) + (i * 4) + 1], 487 | b: imageData.data[(j * refW * 4) + (i * 4) + 2] 488 | }; 489 | if (style.palette && style.palette.length) { 490 | const ckey = `${color.r},${color.g},${color.b}`; 491 | if (colorMap.has(ckey)) { 492 | color = colorMap.get(ckey)!; 493 | } else { 494 | const matchingColor = closestColor(color, style.palette); 495 | colorMap.set(ckey, matchingColor); 496 | color = matchingColor; 497 | } 498 | } 499 | const pixelStyle: BrickRenderOptionsResolved = { 500 | brickSize, 501 | color: `rgb(${color.r}, ${color.g}, ${color.b})`, 502 | filled: false, 503 | palette: [] 504 | }; 505 | drawBrick(dst[0] + i, dst[1] + j, ctx, pixelStyle); 506 | } 507 | } 508 | } --------------------------------------------------------------------------------