├── .nvmrc ├── .husky └── pre-commit ├── .gitignore ├── src ├── storybook │ ├── stories │ │ ├── import-png.d.ts │ │ ├── resources │ │ │ └── js-logo.png │ │ ├── index.ts │ │ ├── text.stories.ts │ │ ├── circle.stories.ts │ │ ├── animation.stories.ts │ │ ├── image.stories.ts │ │ ├── transform.stories.ts │ │ ├── path.stories.ts │ │ ├── customHandlers.stories.ts │ │ ├── convertCanvasCoordinates.stories.ts │ │ ├── rect.stories.ts │ │ ├── performance.stories.ts │ │ └── camera.stories.ts │ ├── utils │ │ ├── createCanvasElement.ts │ │ └── createCanvasDescriptionWrapper.ts │ └── init.ts ├── drawMethods.ts ├── objectTypes.ts ├── index.ts ├── drawHandlerFunctions │ ├── index.ts │ ├── drawImage.ts │ ├── drawCircle.ts │ ├── drawText.ts │ ├── drawPath.ts │ ├── drawTransform.ts │ ├── drawRect.ts │ └── __tests__ │ │ ├── drawRect.test.ts │ │ └── drawText.test.ts ├── types.ts ├── multiplyMatrices.ts ├── canvasContextUtils.ts ├── setCameraTransform.ts ├── convertCanvasCoordinates.ts ├── __tests__ │ ├── canvasContextUtils.test.ts │ ├── createDrawFunction.test.ts │ └── convertCanvasCoordinates.test.ts ├── getTransformMatrix.ts └── createDrawFunction.ts ├── .prettierrc.js ├── .editorconfig ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ ├── verify.yml │ ├── publish-package.yml │ └── deploy-storybook.yml ├── .eslintrc.js ├── LICENSE.txt ├── package.json ├── index.html └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.15.1 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run verify 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | out 4 | .DS_Store 5 | .vscode/ 6 | -------------------------------------------------------------------------------- /src/storybook/stories/import-png.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png' { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | trailingComma: 'es5', 3 | tabWidth: 2, 4 | semi: true, 5 | singleQuote: true, 6 | }; 7 | -------------------------------------------------------------------------------- /src/storybook/stories/resources/js-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lukix/declarative-canvas/HEAD/src/storybook/stories/resources/js-logo.png -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = false 8 | insert_final_newline = true -------------------------------------------------------------------------------- /src/drawMethods.ts: -------------------------------------------------------------------------------- 1 | enum drawMethods { 2 | FILL = 'FILL', 3 | STROKE = 'STROKE', 4 | FILL_AND_STROKE = 'FILL_AND_STROKE', 5 | } 6 | 7 | export default drawMethods; 8 | -------------------------------------------------------------------------------- /src/objectTypes.ts: -------------------------------------------------------------------------------- 1 | enum objectTypes { 2 | CIRCLE = 'CIRCLE', 3 | PATH = 'PATH', 4 | IMAGE = 'IMAGE', 5 | TEXT = 'TEXT', 6 | RECT = 'RECT', 7 | TRANSFORM = 'TRANSFORM', 8 | } 9 | 10 | export default objectTypes; 11 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: ['./src'], 3 | transform: { 4 | '^.+\\.(js|ts)?$': 'ts-jest', 5 | }, 6 | testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(js|ts)?$', 7 | moduleFileExtensions: ['ts', 'js', 'json'], 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export type { Camera } from './types'; 2 | export { default as drawMethods } from './drawMethods'; 3 | export { default as objectTypes } from './objectTypes'; 4 | export { default as createDrawFunction } from './createDrawFunction'; 5 | export { default as convertCanvasCoordinates } from './convertCanvasCoordinates'; 6 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/index.ts: -------------------------------------------------------------------------------- 1 | export { default as drawCircle } from './drawCircle'; 2 | export { default as drawImage } from './drawImage'; 3 | export { default as drawPath } from './drawPath'; 4 | export { default as drawRect } from './drawRect'; 5 | export { default as drawText } from './drawText'; 6 | export { default as drawTransform } from './drawTransform'; 7 | -------------------------------------------------------------------------------- /src/storybook/utils/createCanvasElement.ts: -------------------------------------------------------------------------------- 1 | const createCanvasElement = () => { 2 | const $canvas = document.createElement('canvas'); 3 | $canvas.width = 600; 4 | $canvas.height = 600; 5 | $canvas.style.border = '1px solid black'; 6 | 7 | const context = $canvas.getContext('2d'); 8 | 9 | return { $canvas, context }; 10 | }; 11 | 12 | export default createCanvasElement; 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Camera = { 2 | zoom: number; 3 | position: { x: number; y: number }; 4 | rotation?: number; 5 | }; 6 | export type GraphicalObject = { 7 | type: T; 8 | contextProps?: Partial; 9 | }; 10 | export type DrawHandler = ( 11 | context: CanvasRenderingContext2D, 12 | options: any, 13 | drawObject: (props: GraphicalObject) => void 14 | ) => void; 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./lib", 4 | "allowJs": false, 5 | "checkJs": false, 6 | "module": "commonjs", 7 | "target": "es5", 8 | "moduleResolution": "node", 9 | "lib": ["es2015", "dom"], 10 | "noImplicitAny": true, 11 | "strict": true, 12 | "types": ["jest"], 13 | "declaration": true, 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | "lib", 18 | "out" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/storybook/utils/createCanvasDescriptionWrapper.ts: -------------------------------------------------------------------------------- 1 | const createCanvasDescriptionWrapper = ( 2 | description: string, 3 | $canvas: HTMLCanvasElement 4 | ) => { 5 | const $wrapper = document.createElement('div'); 6 | const $description = document.createElement('p'); 7 | $description.innerHTML = description; 8 | $description.style.maxWidth = `${$canvas.width}px`; 9 | $wrapper.appendChild($description); 10 | $wrapper.appendChild($canvas); 11 | 12 | return $wrapper; 13 | }; 14 | 15 | export default createCanvasDescriptionWrapper; 16 | -------------------------------------------------------------------------------- /.github/workflows/verify.yml: -------------------------------------------------------------------------------- 1 | name: Verify 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [20.x] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: ${{ matrix.node-version }} 22 | cache: 'npm' 23 | - run: npm ci 24 | - run: npm run verify 25 | - run: npm run build 26 | -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Publish package to npm 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-node@v4 14 | with: 15 | node-version-file: '.nvmrc' 16 | registry-url: 'https://registry.npmjs.org' 17 | - run: npm ci 18 | - run: npm publish --provenance 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /src/multiplyMatrices.ts: -------------------------------------------------------------------------------- 1 | const multiplyTwoMatrices = (A: number[][], B: number[][]) => { 2 | const resultMatrix: number[][] = []; 3 | for (let i = 0; i < A.length; i++) { 4 | resultMatrix[i] = []; 5 | for (let j = 0; j < B.length; j++) { 6 | resultMatrix[i][j] = 0; 7 | for (let k = 0; k < A[i].length; k++) { 8 | resultMatrix[i][j] += A[i][k] * B[k][j]; 9 | } 10 | } 11 | } 12 | return resultMatrix; 13 | }; 14 | 15 | const multiplyMatrices = (...matrices: number[][][]) => { 16 | return matrices.reduce((prev, curr) => multiplyTwoMatrices(prev, curr)); 17 | }; 18 | 19 | export default multiplyMatrices; 20 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawImage.ts: -------------------------------------------------------------------------------- 1 | const drawImage = ( 2 | context: CanvasRenderingContext2D, 3 | { 4 | image, 5 | x, 6 | y, 7 | width = Number(image.width), 8 | height = Number(image.height), 9 | rotation = 0, 10 | }: { 11 | image: HTMLImageElement; 12 | x: number; 13 | y: number; 14 | width?: number; 15 | height?: number; 16 | rotation?: number; 17 | } 18 | ): void => { 19 | context.translate(x, y); 20 | context.rotate(rotation); 21 | const relativeX = -width / 2; 22 | const relativeY = -height / 2; 23 | context.drawImage(image, relativeX, relativeY, width, height); 24 | }; 25 | 26 | export default drawImage; 27 | -------------------------------------------------------------------------------- /src/canvasContextUtils.ts: -------------------------------------------------------------------------------- 1 | import drawMethods from './drawMethods'; 2 | 3 | export const setContextProps = ( 4 | context: CanvasRenderingContext2D, 5 | contextProps: Partial 6 | ) => { 7 | Object.assign(context, contextProps); 8 | }; 9 | 10 | export const fillAndStroke = ( 11 | context: CanvasRenderingContext2D, 12 | drawMethod: drawMethods 13 | ) => { 14 | if ( 15 | drawMethod === drawMethods.FILL || 16 | drawMethod === drawMethods.FILL_AND_STROKE 17 | ) { 18 | context.fill(); 19 | } 20 | if ( 21 | drawMethod === drawMethods.STROKE || 22 | drawMethod === drawMethods.FILL_AND_STROKE 23 | ) { 24 | context.stroke(); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawCircle.ts: -------------------------------------------------------------------------------- 1 | import { fillAndStroke } from '../canvasContextUtils'; 2 | import drawMethods from '../drawMethods'; 3 | 4 | const drawCircle = ( 5 | context: CanvasRenderingContext2D, 6 | { 7 | x, 8 | y, 9 | radius, 10 | drawMethod = drawMethods.FILL, 11 | }: { 12 | x: number; 13 | y: number; 14 | radius: number; 15 | drawMethod?: drawMethods; 16 | } 17 | ): void => { 18 | context.beginPath(); 19 | const rotation = 0; 20 | const startAngle = 0; 21 | const endAngle = Math.PI * 2; 22 | context.ellipse(x, y, radius, radius, rotation, startAngle, endAngle); 23 | fillAndStroke(context, drawMethod); 24 | }; 25 | 26 | export default drawCircle; 27 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawText.ts: -------------------------------------------------------------------------------- 1 | import drawMethods from '../drawMethods'; 2 | 3 | const drawText = ( 4 | context: CanvasRenderingContext2D, 5 | { 6 | text, 7 | x, 8 | y, 9 | drawMethod = drawMethods.FILL, 10 | }: { 11 | text: string; 12 | x: number; 13 | y: number; 14 | drawMethod?: drawMethods; 15 | } 16 | ): void => { 17 | if ( 18 | drawMethod === drawMethods.FILL || 19 | drawMethod === drawMethods.FILL_AND_STROKE 20 | ) { 21 | context.fillText(text, x, y); 22 | } 23 | if ( 24 | drawMethod === drawMethods.STROKE || 25 | drawMethod === drawMethods.FILL_AND_STROKE 26 | ) { 27 | context.strokeText(text, x, y); 28 | } 29 | }; 30 | 31 | export default drawText; 32 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:jest/recommended', 5 | 'plugin:@typescript-eslint/eslint-recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | ], 8 | plugins: ['jest', '@typescript-eslint'], 9 | parser: '@typescript-eslint/parser', 10 | parserOptions: { 11 | ecmaVersion: 2018, 12 | sourceType: 'module', 13 | ecmaFeatures: { 14 | modules: true, 15 | }, 16 | }, 17 | env: { 18 | node: true, 19 | es6: true, 20 | browser: true, 21 | 'jest/globals': true, 22 | }, 23 | rules: { 24 | '@typescript-eslint/no-empty-function': 0, 25 | '@typescript-eslint/explicit-module-boundary-types': 0, 26 | }, 27 | root: true, 28 | }; 29 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawPath.ts: -------------------------------------------------------------------------------- 1 | import { fillAndStroke } from '../canvasContextUtils'; 2 | import drawMethods from '../drawMethods'; 3 | 4 | const drawPath = ( 5 | context: CanvasRenderingContext2D, 6 | { 7 | points: [startPoint, ...restPoints], 8 | closePath = false, 9 | drawMethod = drawMethods.STROKE, 10 | }: { 11 | points: Array<{ x: number; y: number }>; 12 | closePath?: boolean; 13 | drawMethod?: drawMethods; 14 | } 15 | ): void => { 16 | context.beginPath(); 17 | context.moveTo(startPoint.x, startPoint.y); 18 | restPoints.forEach(({ x, y }) => context.lineTo(x, y)); 19 | if (closePath) { 20 | context.closePath(); 21 | } 22 | fillAndStroke(context, drawMethod); 23 | }; 24 | 25 | export default drawPath; 26 | -------------------------------------------------------------------------------- /src/setCameraTransform.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from './types'; 2 | import { getTransformMatrix } from './getTransformMatrix'; 3 | 4 | const setCameraTransform = ({ 5 | context, 6 | canvasWidth, 7 | canvasHeight, 8 | camera, 9 | }: { 10 | context: CanvasRenderingContext2D; 11 | canvasWidth: number; 12 | canvasHeight: number; 13 | camera: Camera; 14 | }): void => { 15 | const transformMatrix = getTransformMatrix({ 16 | canvasWidth, 17 | canvasHeight, 18 | camera, 19 | }); 20 | context.setTransform( 21 | transformMatrix[0][0], 22 | transformMatrix[1][0], 23 | transformMatrix[0][1], 24 | transformMatrix[1][1], 25 | transformMatrix[0][2], 26 | transformMatrix[1][2] 27 | ); 28 | }; 29 | 30 | export default setCameraTransform; 31 | -------------------------------------------------------------------------------- /src/convertCanvasCoordinates.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from './types'; 2 | import { getInverseTransformMatrix } from './getTransformMatrix'; 3 | import multiplyMatrices from './multiplyMatrices'; 4 | 5 | const convertCanvasCoordinates = ( 6 | x: number, 7 | y: number, 8 | canvasWidth: number, 9 | canvasHeight: number, 10 | camera: Camera 11 | ) => { 12 | const inverseTransformMatrix = getInverseTransformMatrix({ 13 | canvasWidth, 14 | canvasHeight, 15 | camera, 16 | }); 17 | 18 | const coordinatesMatrix = [[x], [y], [1]]; 19 | const canvasCoordinatesMatrix = multiplyMatrices( 20 | inverseTransformMatrix, 21 | coordinatesMatrix 22 | ); 23 | 24 | return { 25 | x: canvasCoordinatesMatrix[0][0], 26 | y: canvasCoordinatesMatrix[1][0], 27 | }; 28 | }; 29 | 30 | export default convertCanvasCoordinates; 31 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawTransform.ts: -------------------------------------------------------------------------------- 1 | import { GraphicalObject } from '../types'; 2 | 3 | const drawTransform = ( 4 | context: CanvasRenderingContext2D, 5 | { 6 | dx = 0, 7 | dy = 0, 8 | scaleX = 1, 9 | scaleY = 1, 10 | skewX = 0, 11 | skewY = 0, 12 | rotation = 0, 13 | children, 14 | }: { 15 | dx?: number; 16 | dy?: number; 17 | scaleX?: number; 18 | scaleY?: number; 19 | skewX?: number; 20 | skewY?: number; 21 | rotation?: number; 22 | children: Array>; 23 | }, 24 | drawObject: (props: GraphicalObject) => void 25 | ): void => { 26 | context.transform(scaleX, skewY, skewX, scaleY, dx, dy); 27 | context.rotate(rotation); 28 | children.forEach((childObject) => { 29 | drawObject(childObject); 30 | }); 31 | }; 32 | 33 | export default drawTransform; 34 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/drawRect.ts: -------------------------------------------------------------------------------- 1 | import drawMethods from '../drawMethods'; 2 | 3 | const drawRect = ( 4 | context: CanvasRenderingContext2D, 5 | { 6 | x, 7 | y, 8 | width, 9 | height, 10 | drawMethod = drawMethods.FILL, 11 | rotation = 0, 12 | }: { 13 | x: number; 14 | y: number; 15 | width: number; 16 | height: number; 17 | drawMethod?: drawMethods; 18 | rotation?: number; 19 | } 20 | ): void => { 21 | context.translate(x, y); 22 | context.rotate(rotation); 23 | const relativeX = -width / 2; 24 | const relativeY = -height / 2; 25 | if ( 26 | drawMethod === drawMethods.FILL || 27 | drawMethod === drawMethods.FILL_AND_STROKE 28 | ) { 29 | context.fillRect(relativeX, relativeY, width, height); 30 | } 31 | if ( 32 | drawMethod === drawMethods.STROKE || 33 | drawMethod === drawMethods.FILL_AND_STROKE 34 | ) { 35 | context.strokeRect(relativeX, relativeY, width, height); 36 | } 37 | }; 38 | 39 | export default drawRect; 40 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Łukasz Jenczmyk 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 | -------------------------------------------------------------------------------- /src/storybook/stories/index.ts: -------------------------------------------------------------------------------- 1 | import { rectBasic, rectFillAndStroke, rectRotated } from './rect.stories'; 2 | import { animation } from './animation.stories'; 3 | import { cameraTranslation, cameraRotation } from './camera.stories'; 4 | import { circleBasic } from './circle.stories'; 5 | import { pathBasic } from './path.stories'; 6 | import { textBasic } from './text.stories'; 7 | import { imageBasic } from './image.stories'; 8 | import { transformBasic } from './transform.stories'; 9 | import { customHandlersBasic } from './customHandlers.stories'; 10 | import { convertCanvasCoordinatesBasic } from './convertCanvasCoordinates.stories'; 11 | import { performanceBasic } from './performance.stories'; 12 | 13 | export type CreatedStoryType = { 14 | element: HTMLElement; 15 | cleanUp?: () => void; 16 | }; 17 | 18 | export type StoryType = { 19 | name: string; 20 | description?: string; 21 | create: () => CreatedStoryType; 22 | }; 23 | 24 | const stories: StoryType[] = [ 25 | animation, 26 | rectBasic, 27 | rectRotated, 28 | rectFillAndStroke, 29 | circleBasic, 30 | cameraTranslation, 31 | cameraRotation, 32 | pathBasic, 33 | textBasic, 34 | imageBasic, 35 | transformBasic, 36 | customHandlersBasic, 37 | convertCanvasCoordinatesBasic, 38 | performanceBasic, 39 | ]; 40 | 41 | export default stories; 42 | -------------------------------------------------------------------------------- /.github/workflows/deploy-storybook.yml: -------------------------------------------------------------------------------- 1 | name: Deploy storybook to Pages 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | 7 | # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. 14 | # However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. 15 | concurrency: 16 | group: "pages" 17 | cancel-in-progress: false 18 | 19 | jobs: 20 | deploy: 21 | environment: 22 | name: github-pages 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v4 27 | - name: Use Node.js 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version-file: '.nvmrc' 31 | - run: npm ci 32 | - run: npm run verify 33 | - run: npm run build-storybook -- --base=/declarative-canvas/ 34 | - name: Setup Pages 35 | uses: actions/configure-pages@v5 36 | - name: Upload artifact 37 | uses: actions/upload-pages-artifact@v3 38 | with: 39 | path: './out' 40 | - name: Deploy to GitHub Pages 41 | id: deployment 42 | uses: actions/deploy-pages@v4 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "declarative-canvas", 3 | "version": "1.3.0", 4 | "main": "lib/index.js", 5 | "types": "lib/index.d.ts", 6 | "author": "Łukasz Jenczmyk", 7 | "license": "MIT", 8 | "description": "Library which lets you draw on canvas in a declarative way.", 9 | "keywords": [ 10 | "canvas", 11 | "draw", 12 | "drawing", 13 | "render", 14 | "rendering", 15 | "declarative programming" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/lukix/declarative-canvas.git" 20 | }, 21 | "files": [ 22 | "lib/", 23 | "!lib/storybook/", 24 | "!__tests__/" 25 | ], 26 | "scripts": { 27 | "dev": "tsc --watch", 28 | "test": "jest", 29 | "test:watch": "jest --watch", 30 | "build": "tsc", 31 | "lint": "eslint ./src/**/*.ts", 32 | "verify": "npm run lint && npm run test && prettier --check ./src/* && tsc --noEmit", 33 | "prepublishOnly": "npm run verify && npm run build", 34 | "storybook": "vite dev", 35 | "build-storybook": "vite build --outDir out", 36 | "prepare": "husky" 37 | }, 38 | "devDependencies": { 39 | "@types/jest": "29.5.12", 40 | "@typescript-eslint/eslint-plugin": "7.17.0", 41 | "@typescript-eslint/parser": "7.17.0", 42 | "eslint": "8.57.0", 43 | "eslint-plugin-jest": "28.6.0", 44 | "husky": "9.1.2", 45 | "jest": "29.7.0", 46 | "prettier": "3.3.3", 47 | "ts-jest": "29.2.3", 48 | "typescript": "5.5.4", 49 | "vite": "5.3.5" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/storybook/stories/text.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes, drawMethods } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const textBasic = { 11 | name: 'Text', 12 | create: () => { 13 | const { $canvas, context } = createCanvasElement(); 14 | 15 | if (!context) { 16 | return { 17 | element: createDivWithContent('Context identifier is not supported'), 18 | }; 19 | } 20 | 21 | const draw = createDrawFunction(); 22 | 23 | const objects = [ 24 | { 25 | type: objectTypes.TEXT, 26 | contextProps: { fillStyle: '#708871', font: '45px Times New Roman' }, 27 | x: 50, 28 | y: 150, 29 | text: 'data-renderer', 30 | }, 31 | { 32 | type: objectTypes.TEXT, 33 | contextProps: { fillStyle: '#708871', font: '45px Courier' }, 34 | x: 50, 35 | y: 250, 36 | text: 'data-renderer', 37 | }, 38 | { 39 | type: objectTypes.TEXT, 40 | contextProps: { strokeStyle: '#708871', font: '60px Courier' }, 41 | x: 50, 42 | y: 350, 43 | text: 'data-renderer', 44 | drawMethod: drawMethods.STROKE, 45 | }, 46 | ]; 47 | 48 | draw({ context, objects }); 49 | 50 | return { 51 | element: $canvas, 52 | }; 53 | }, 54 | }; 55 | -------------------------------------------------------------------------------- /src/storybook/stories/circle.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes, drawMethods } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const circleBasic = { 11 | name: 'Circle', 12 | create: () => { 13 | const { $canvas, context } = createCanvasElement(); 14 | 15 | if (!context) { 16 | return { 17 | element: createDivWithContent('Context identifier is not supported'), 18 | }; 19 | } 20 | 21 | const draw = createDrawFunction(); 22 | 23 | const objects = [ 24 | { 25 | type: objectTypes.CIRCLE, 26 | contextProps: { fillStyle: '#BEC6A0' }, 27 | x: 150, 28 | y: 150, 29 | radius: 100, 30 | drawMethod: drawMethods.FILL, 31 | }, 32 | { 33 | type: objectTypes.CIRCLE, 34 | contextProps: { strokeStyle: '#708871', lineWidth: 10 }, 35 | x: 400, 36 | y: 150, 37 | radius: 100, 38 | drawMethod: drawMethods.STROKE, 39 | }, 40 | { 41 | type: objectTypes.CIRCLE, 42 | contextProps: { 43 | fillStyle: '#BEC6A0', 44 | strokeStyle: '#708871', 45 | lineWidth: 10, 46 | }, 47 | x: 275, 48 | y: 400, 49 | radius: 100, 50 | drawMethod: drawMethods.FILL_AND_STROKE, 51 | }, 52 | ]; 53 | 54 | draw({ context, objects }); 55 | 56 | return { 57 | element: $canvas, 58 | }; 59 | }, 60 | }; 61 | -------------------------------------------------------------------------------- /src/__tests__/canvasContextUtils.test.ts: -------------------------------------------------------------------------------- 1 | import { fillAndStroke } from '../canvasContextUtils'; 2 | import drawMethods from '../drawMethods'; 3 | 4 | const getContext = () => { 5 | const context = { 6 | fill: jest.fn(), 7 | stroke: jest.fn(), 8 | }; 9 | 10 | return context as unknown as CanvasRenderingContext2D; 11 | }; 12 | 13 | describe('canvasContextUtils', () => { 14 | describe('fillAndStroke', () => { 15 | it('should call fill method when draw method is FILL', () => { 16 | // given 17 | const context = getContext(); 18 | const drawMethod = drawMethods.FILL; 19 | 20 | // when 21 | fillAndStroke(context, drawMethod); 22 | 23 | // then 24 | expect(context.fill).toHaveBeenCalledTimes(1); 25 | expect(context.stroke).toHaveBeenCalledTimes(0); 26 | }); 27 | 28 | it('should call stroke method when draw method is STROKE', () => { 29 | // given 30 | const context = getContext(); 31 | const drawMethod = drawMethods.STROKE; 32 | 33 | // when 34 | fillAndStroke(context, drawMethod); 35 | 36 | // then 37 | expect(context.fill).toHaveBeenCalledTimes(0); 38 | expect(context.stroke).toHaveBeenCalledTimes(1); 39 | }); 40 | 41 | it('should call both fill and stroke methods when draw method is FILL_AND_STROKE', () => { 42 | // given 43 | const context = getContext(); 44 | const drawMethod = drawMethods.FILL_AND_STROKE; 45 | 46 | // when 47 | fillAndStroke(context, drawMethod); 48 | 49 | // then 50 | expect(context.fill).toHaveBeenCalledTimes(1); 51 | expect(context.stroke).toHaveBeenCalledTimes(1); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/storybook/stories/animation.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes, drawMethods } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const animation = { 11 | name: 'Animation', 12 | create: () => { 13 | let isRunning = true; 14 | const { $canvas, context } = createCanvasElement(); 15 | 16 | if (!context) { 17 | return { 18 | element: createDivWithContent('Context identifier is not supported'), 19 | }; 20 | } 21 | 22 | const draw = createDrawFunction(); 23 | 24 | let timeElapsed = 0; 25 | 26 | const renderFrame = () => { 27 | timeElapsed = timeElapsed + 1; 28 | const rotation = timeElapsed * 0.005; 29 | const lineWidth = 5 + Math.sin(timeElapsed * 0.07) * 5; 30 | 31 | const objects = [ 32 | { 33 | type: objectTypes.RECT, 34 | contextProps: { 35 | fillStyle: '#BEC6A0', 36 | strokeStyle: '#708871', 37 | lineWidth, 38 | }, 39 | x: 300, 40 | y: 300, 41 | width: 200, 42 | height: 200, 43 | rotation, 44 | drawMethod: drawMethods.FILL_AND_STROKE, 45 | }, 46 | ]; 47 | 48 | draw({ context, objects }); 49 | 50 | if (isRunning) { 51 | requestAnimationFrame(renderFrame); 52 | } 53 | }; 54 | 55 | renderFrame(); 56 | 57 | return { 58 | element: $canvas, 59 | cleanUp: () => { 60 | isRunning = false; 61 | }, 62 | }; 63 | }, 64 | }; 65 | -------------------------------------------------------------------------------- /src/__tests__/createDrawFunction.test.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction } from '../index'; 2 | 3 | const getContext = () => { 4 | const context = { 5 | fill: jest.fn(), 6 | stroke: jest.fn(), 7 | clearRect: () => {}, 8 | save: () => {}, 9 | restore: () => {}, 10 | setTransform: () => {}, 11 | }; 12 | 13 | return context as unknown as CanvasRenderingContext2D; 14 | }; 15 | 16 | describe('createDrawFunction', () => { 17 | it('should throw an error when passing object with unknown type', () => { 18 | // given 19 | const draw = createDrawFunction(); 20 | 21 | const UNKNOWN_TYPE = 'UNKNOWN_TYPE'; 22 | 23 | const objects = [{ type: UNKNOWN_TYPE }]; 24 | const context = getContext(); 25 | 26 | // when 27 | // turning off TS in order to test a case when someone is not using TypeScript 28 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 29 | // @ts-ignore 30 | const callDraw = () => draw({ context, objects }); 31 | 32 | // then 33 | expect(callDraw).toThrow(); 34 | }); 35 | 36 | it('should call specified custom draw handler', () => { 37 | // given 38 | enum CustomTypes { 39 | CUSTOM_TYPE = 'CUSTOM_TYPE', 40 | } 41 | const draw = createDrawFunction({ 42 | CUSTOM_TYPE: (context, { x, y }) => context.fillRect(x, y, 20, 20), 43 | }); 44 | const objects = [{ type: CustomTypes.CUSTOM_TYPE, x: 5, y: 10 }]; 45 | const drawRectMock = jest.fn(); 46 | const context = { ...getContext(), fillRect: drawRectMock }; 47 | 48 | // when 49 | draw({ context, objects }); 50 | 51 | // then 52 | expect(drawRectMock).toHaveBeenCalledTimes(1); 53 | expect(drawRectMock).toHaveBeenCalledWith(5, 10, 20, 20); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /src/storybook/stories/image.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | import jsLogoPath from './resources/js-logo.png'; 4 | 5 | const createImage = (src: string): Promise => { 6 | const image = new Image(); 7 | image.src = src; 8 | return new Promise((res, rej) => { 9 | image.onload = () => { 10 | return res(image); 11 | }; 12 | image.onerror = rej; 13 | }); 14 | }; 15 | 16 | const createDivWithContent = (content: string) => { 17 | const $div = document.createElement('div'); 18 | $div.innerText = content; 19 | return $div; 20 | }; 21 | 22 | export const imageBasic = { 23 | name: 'Image', 24 | create: () => { 25 | const { $canvas, context } = createCanvasElement(); 26 | 27 | if (!context) { 28 | return { 29 | element: createDivWithContent('Context identifier is not supported'), 30 | }; 31 | } 32 | 33 | const draw = createDrawFunction(); 34 | 35 | createImage(jsLogoPath).then((jsLogo) => { 36 | const objects = [ 37 | { 38 | type: objectTypes.IMAGE, 39 | contextProps: { fillStyle: '#BEC6A0' }, 40 | x: 150, 41 | y: 150, 42 | image: jsLogo, 43 | }, 44 | { 45 | type: objectTypes.IMAGE, 46 | contextProps: { fillStyle: '#BEC6A0' }, 47 | x: 400, 48 | y: 150, 49 | width: 120, 50 | height: 120, 51 | rotation: Math.PI / 4, 52 | image: jsLogo, 53 | }, 54 | ]; 55 | 56 | draw({ context, objects }); 57 | }); 58 | 59 | return { 60 | element: $canvas, 61 | }; 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /src/storybook/stories/transform.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const transformBasic = { 11 | name: 'Transform', 12 | description: 13 | 'With transform object you can apply translation, rotation and scaling to children objects.', 14 | create: () => { 15 | const { $canvas, context } = createCanvasElement(); 16 | 17 | if (!context) { 18 | return { 19 | element: createDivWithContent('Context identifier is not supported'), 20 | }; 21 | } 22 | 23 | const draw = createDrawFunction(); 24 | 25 | const objects = [ 26 | { 27 | type: objectTypes.TRANSFORM, 28 | dx: 50, 29 | dy: 50, 30 | rotation: Math.PI / 4, 31 | children: [ 32 | { 33 | type: objectTypes.RECT, 34 | contextProps: { fillStyle: '#BEC6A0' }, 35 | x: 200, 36 | y: 0, 37 | width: 50, 38 | height: 50, 39 | }, 40 | { 41 | type: objectTypes.PATH, 42 | points: [ 43 | { x: 0, y: 0 }, 44 | { x: 200, y: 0 }, 45 | ], 46 | }, 47 | { 48 | type: objectTypes.TEXT, 49 | contextProps: { 50 | fillStyle: 'black', 51 | textAlign: 'center' as CanvasTextAlign, 52 | font: '18px Times New Roman', 53 | }, 54 | text: 'Text', 55 | x: 200, 56 | y: -30, 57 | }, 58 | ], 59 | }, 60 | ]; 61 | 62 | draw({ context, objects }); 63 | 64 | return { 65 | element: $canvas, 66 | }; 67 | }, 68 | }; 69 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/__tests__/drawRect.test.ts: -------------------------------------------------------------------------------- 1 | import drawRect from '../drawRect'; 2 | import drawMethods from '../../drawMethods'; 3 | 4 | const getContext = () => { 5 | const context = { 6 | translate: () => {}, 7 | rotate: () => {}, 8 | fillRect: jest.fn(), 9 | strokeRect: jest.fn(), 10 | }; 11 | 12 | return context as unknown as CanvasRenderingContext2D; 13 | }; 14 | 15 | describe('drawRect', () => { 16 | it('should call fillRect when draw method is FILL', () => { 17 | // given 18 | const context = getContext(); 19 | const options = { 20 | x: 10, 21 | y: 20, 22 | width: 5, 23 | height: 5, 24 | drawMethod: drawMethods.FILL, 25 | }; 26 | 27 | // when 28 | drawRect(context, options); 29 | 30 | // then 31 | expect(context.strokeRect).toHaveBeenCalledTimes(0); 32 | expect(context.fillRect).toHaveBeenCalledTimes(1); 33 | }); 34 | 35 | it('should call strokeRect when draw method is STROKE', () => { 36 | // given 37 | const context = getContext(); 38 | const options = { 39 | x: 10, 40 | y: 20, 41 | width: 5, 42 | height: 5, 43 | drawMethod: drawMethods.STROKE, 44 | }; 45 | 46 | // when 47 | drawRect(context, options); 48 | 49 | // then 50 | expect(context.strokeRect).toHaveBeenCalledTimes(1); 51 | expect(context.fillRect).toHaveBeenCalledTimes(0); 52 | }); 53 | 54 | it('should call both fillRect and strokeRect when draw method is FILL_AND_STROKE', () => { 55 | // given 56 | const context = getContext(); 57 | const options = { 58 | x: 10, 59 | y: 20, 60 | width: 5, 61 | height: 5, 62 | drawMethod: drawMethods.FILL_AND_STROKE, 63 | }; 64 | 65 | // when 66 | drawRect(context, options); 67 | 68 | // then 69 | expect(context.strokeRect).toHaveBeenCalledTimes(1); 70 | expect(context.fillRect).toHaveBeenCalledTimes(1); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/storybook/init.ts: -------------------------------------------------------------------------------- 1 | import stories, { CreatedStoryType } from './stories/index'; 2 | 3 | const $storyContainer = document.querySelector('#story-container'); 4 | const $menu = document.querySelector('#menu'); 5 | 6 | if (!$menu || !$storyContainer) { 7 | throw Error('Root elements not found'); 8 | } 9 | 10 | let createdStory: CreatedStoryType | null = null; 11 | 12 | const createDescriptionElement = (description: string) => { 13 | const $description = document.createElement('p'); 14 | $description.classList.add('story-description'); 15 | $description.innerText = description; 16 | return $description; 17 | }; 18 | 19 | const selectStory = (storyIndex: number) => { 20 | // Clean up old story 21 | if (createdStory) { 22 | createdStory.cleanUp?.(); 23 | } 24 | 25 | // Clean up DOM 26 | document.querySelector('.menu-item.selected')?.classList.remove('selected'); 27 | $storyContainer.innerHTML = ''; 28 | 29 | const newStory = stories[storyIndex]; 30 | const $newStoryMenuItem = document.querySelector( 31 | `[data-story-index="${storyIndex}"]` 32 | ); 33 | 34 | $newStoryMenuItem?.classList.add('selected'); 35 | 36 | createdStory = newStory.create(); 37 | if (newStory.description) { 38 | $storyContainer.appendChild(createDescriptionElement(newStory.description)); 39 | } 40 | $storyContainer.appendChild(createdStory.element); 41 | }; 42 | 43 | const createMenuItem = (storyIndex: number) => { 44 | const story = stories[storyIndex]; 45 | 46 | const $li = document.createElement('li'); 47 | $li.classList.add('menu-item'); 48 | $li.innerText = story.name; 49 | $li.setAttribute('data-story-index', `${storyIndex}`); 50 | $li.addEventListener('click', () => selectStory(storyIndex)); 51 | return $li; 52 | }; 53 | 54 | const init = () => { 55 | const menuItems = stories.map((_, storyIndex) => createMenuItem(storyIndex)); 56 | menuItems.forEach(($li) => $menu.appendChild($li)); 57 | 58 | selectStory(0); 59 | }; 60 | 61 | init(); 62 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | declarative-canvas demo 5 | 67 | 68 | 69 | 73 |
74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/getTransformMatrix.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from './types'; 2 | import multiplyMatrices from './multiplyMatrices'; 3 | 4 | const getTranslationMatrix = (x: number, y: number) => { 5 | return [ 6 | [1, 0, x], 7 | [0, 1, y], 8 | [0, 0, 1], 9 | ]; 10 | }; 11 | 12 | const getRotationMatrix = (rotation: number) => { 13 | return [ 14 | [Math.cos(rotation), -Math.sin(rotation), 0], 15 | [Math.sin(rotation), Math.cos(rotation), 0], 16 | [0, 0, 1], 17 | ]; 18 | }; 19 | 20 | const getScalingMatrix = (zoom: number) => { 21 | return [ 22 | [zoom, 0, 0], 23 | [0, zoom, 0], 24 | [0, 0, 1], 25 | ]; 26 | }; 27 | 28 | export const getTransformMatrix = ({ 29 | canvasWidth, 30 | canvasHeight, 31 | camera, 32 | }: { 33 | canvasWidth: number; 34 | canvasHeight: number; 35 | camera: Camera; 36 | }) => { 37 | const originTranslationMatrix = getTranslationMatrix( 38 | -camera.position.x, 39 | -camera.position.y 40 | ); 41 | const canvasCenteringTranslationMatrix = getTranslationMatrix( 42 | canvasWidth / 2, 43 | canvasHeight / 2 44 | ); 45 | const scalingMatrix = getScalingMatrix(camera.zoom); 46 | const rotationMatrix = getRotationMatrix(-(camera.rotation || 0)); 47 | 48 | return multiplyMatrices( 49 | canvasCenteringTranslationMatrix, 50 | scalingMatrix, 51 | rotationMatrix, 52 | originTranslationMatrix 53 | ); 54 | }; 55 | 56 | export const getInverseTransformMatrix = ({ 57 | canvasWidth, 58 | canvasHeight, 59 | camera, 60 | }: { 61 | canvasWidth: number; 62 | canvasHeight: number; 63 | camera: Camera; 64 | }) => { 65 | const originTranslationMatrix = getTranslationMatrix( 66 | camera.position.x, 67 | camera.position.y 68 | ); 69 | const canvasCenteringTranslationMatrix = getTranslationMatrix( 70 | -canvasWidth / 2, 71 | -canvasHeight / 2 72 | ); 73 | const scalingMatrix = getScalingMatrix(1 / camera.zoom); 74 | const rotationMatrix = getRotationMatrix(camera.rotation || 0); 75 | 76 | return multiplyMatrices( 77 | originTranslationMatrix, 78 | rotationMatrix, 79 | scalingMatrix, 80 | canvasCenteringTranslationMatrix 81 | ); 82 | }; 83 | -------------------------------------------------------------------------------- /src/storybook/stories/path.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes, drawMethods } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const pathBasic = { 11 | name: 'Path', 12 | create: () => { 13 | const { $canvas, context } = createCanvasElement(); 14 | 15 | if (!context) { 16 | return { 17 | element: createDivWithContent('Context identifier is not supported'), 18 | }; 19 | } 20 | 21 | const draw = createDrawFunction(); 22 | 23 | const objects = [ 24 | { 25 | type: objectTypes.PATH, 26 | contextProps: { 27 | fillStyle: '#BEC6A0', 28 | strokeStyle: '#708871', 29 | lineWidth: 5, 30 | }, 31 | points: [ 32 | { x: 70, y: 50 }, 33 | { x: 120, y: 75 }, 34 | { x: 140, y: 150 }, 35 | { x: 40, y: 110 }, 36 | ], 37 | drawMethod: drawMethods.STROKE, 38 | }, 39 | { 40 | type: objectTypes.PATH, 41 | contextProps: { 42 | fillStyle: '#BEC6A0', 43 | strokeStyle: '#708871', 44 | lineWidth: 5, 45 | }, 46 | points: [ 47 | { x: 200 + 70, y: 50 }, 48 | { x: 200 + 120, y: 75 }, 49 | { x: 200 + 140, y: 150 }, 50 | { x: 200 + 40, y: 110 }, 51 | ], 52 | drawMethod: drawMethods.STROKE, 53 | closePath: true, 54 | }, 55 | { 56 | type: objectTypes.PATH, 57 | contextProps: { 58 | fillStyle: '#BEC6A0', 59 | strokeStyle: '#708871', 60 | lineWidth: 5, 61 | }, 62 | points: [ 63 | { x: 400 + 70, y: 50 }, 64 | { x: 400 + 120, y: 75 }, 65 | { x: 400 + 140, y: 150 }, 66 | { x: 400 + 40, y: 110 }, 67 | ], 68 | drawMethod: drawMethods.FILL, 69 | }, 70 | ]; 71 | 72 | draw({ context, objects }); 73 | 74 | return { 75 | element: $canvas, 76 | }; 77 | }, 78 | }; 79 | -------------------------------------------------------------------------------- /src/drawHandlerFunctions/__tests__/drawText.test.ts: -------------------------------------------------------------------------------- 1 | import drawText from '../drawText'; 2 | import drawMethods from '../../drawMethods'; 3 | 4 | const getContext = () => { 5 | const context = { 6 | translate: () => {}, 7 | rotate: () => {}, 8 | fillText: jest.fn(), 9 | strokeText: jest.fn(), 10 | }; 11 | 12 | return context as unknown as CanvasRenderingContext2D; 13 | }; 14 | 15 | describe('drawText', () => { 16 | it('should call fillText when draw method is FILL', () => { 17 | // given 18 | const context = getContext(); 19 | const options = { 20 | text: 'Hello World', 21 | x: 10, 22 | y: 20, 23 | drawMethod: drawMethods.FILL, 24 | }; 25 | 26 | // when 27 | drawText(context, options); 28 | 29 | // then 30 | expect(context.strokeText).toHaveBeenCalledTimes(0); 31 | expect(context.fillText).toHaveBeenCalledTimes(1); 32 | 33 | expect(context.fillText).toHaveBeenCalledWith( 34 | options.text, 35 | options.x, 36 | options.y 37 | ); 38 | }); 39 | 40 | it('should call strokeText when draw method is STROKE', () => { 41 | // given 42 | const context = getContext(); 43 | const options = { 44 | text: 'Hello World', 45 | x: 10, 46 | y: 20, 47 | drawMethod: drawMethods.STROKE, 48 | }; 49 | 50 | // when 51 | drawText(context, options); 52 | 53 | // then 54 | expect(context.strokeText).toHaveBeenCalledTimes(1); 55 | expect(context.fillText).toHaveBeenCalledTimes(0); 56 | 57 | expect(context.strokeText).toHaveBeenCalledWith( 58 | options.text, 59 | options.x, 60 | options.y 61 | ); 62 | }); 63 | 64 | it('should call both fillText and strokeText when draw method is FILL_AND_STROKE', () => { 65 | // given 66 | const context = getContext(); 67 | const options = { 68 | text: 'Hello World', 69 | x: 10, 70 | y: 20, 71 | drawMethod: drawMethods.FILL_AND_STROKE, 72 | }; 73 | 74 | // when 75 | drawText(context, options); 76 | 77 | // then 78 | expect(context.strokeText).toHaveBeenCalledTimes(1); 79 | expect(context.fillText).toHaveBeenCalledTimes(1); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /src/storybook/stories/customHandlers.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const customHandlersBasic = { 11 | name: 'Custom Draw Handlers', 12 | description: 13 | 'These glowing stars are not included in declarative-canvas library. They are build using custom objects.', 14 | create: () => { 15 | const { $canvas, context } = createCanvasElement(); 16 | 17 | if (!context) { 18 | return { 19 | element: createDivWithContent('Context identifier is not supported'), 20 | }; 21 | } 22 | 23 | enum CustomTypes { 24 | CUSTOM_GLOWING_STAR = 'CUSTOM_GLOWING_STAR', 25 | } 26 | 27 | const customDrawHandlers = { 28 | [CustomTypes.CUSTOM_GLOWING_STAR]: ( 29 | context: CanvasRenderingContext2D, 30 | { x, y }: { x: number; y: number } 31 | ) => { 32 | context.beginPath(); 33 | const rotation = 0; 34 | const startAngle = 0; 35 | const endAngle = Math.PI * 2; 36 | const radius = 3; 37 | context.save(); 38 | context.filter = 'blur(8px)'; 39 | context.fillStyle = 'yellow'; 40 | context.ellipse( 41 | x, 42 | y, 43 | radius, 44 | 3 * radius, 45 | rotation, 46 | startAngle, 47 | endAngle 48 | ); 49 | context.fill(); 50 | context.restore(); 51 | context.fillStyle = 'yellow'; 52 | context.ellipse( 53 | x, 54 | y, 55 | 3 * radius, 56 | radius, 57 | rotation, 58 | startAngle, 59 | endAngle 60 | ); 61 | context.fill(); 62 | }, 63 | }; 64 | const draw = createDrawFunction(customDrawHandlers); 65 | 66 | const objects = [ 67 | { 68 | type: objectTypes.RECT, 69 | x: 300, 70 | y: 300, 71 | width: 600, 72 | height: 600, 73 | contextProps: { fillStyle: 'black' }, 74 | }, 75 | { type: CustomTypes.CUSTOM_GLOWING_STAR, x: 280, y: 260 }, 76 | { type: CustomTypes.CUSTOM_GLOWING_STAR, x: 320, y: 300 }, 77 | { type: CustomTypes.CUSTOM_GLOWING_STAR, x: 270, y: 330 }, 78 | ]; 79 | 80 | draw({ context, objects }); 81 | 82 | return { 83 | element: $canvas, 84 | }; 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /src/__tests__/convertCanvasCoordinates.test.ts: -------------------------------------------------------------------------------- 1 | import convertCanvasCoordinates from '../convertCanvasCoordinates'; 2 | 3 | describe('convertCanvasCoordinates', () => { 4 | it('should correctly convert coordinates when camera is looking at (0,0)', () => { 5 | // given 6 | const x = 400 + 100; 7 | const y = 300 + 50; 8 | const canvasWidth = 800; 9 | const canvasHeight = 600; 10 | const camera = { zoom: 1, position: { x: 0, y: 0 } }; 11 | 12 | // when 13 | const convertedCoords = convertCanvasCoordinates( 14 | x, 15 | y, 16 | canvasWidth, 17 | canvasHeight, 18 | camera 19 | ); 20 | 21 | // then 22 | expect(convertedCoords).toEqual({ x: 100, y: 50 }); 23 | }); 24 | 25 | it('should correctly convert coordinates when zoom is not equal to 1', () => { 26 | // given 27 | const x = 400 + 100; 28 | const y = 300 + 50; 29 | const canvasWidth = 800; 30 | const canvasHeight = 600; 31 | const camera = { zoom: 0.5, position: { x: 0, y: 0 } }; 32 | 33 | // when 34 | const convertedCoords = convertCanvasCoordinates( 35 | x, 36 | y, 37 | canvasWidth, 38 | canvasHeight, 39 | camera 40 | ); 41 | 42 | // then 43 | expect(convertedCoords).toEqual({ x: 2 * 100, y: 2 * 50 }); 44 | }); 45 | 46 | it('should correctly convert coordinates when camera is displaced', () => { 47 | // given 48 | const x = 400; 49 | const y = 300; 50 | const canvasWidth = 800; 51 | const canvasHeight = 600; 52 | const camera = { zoom: 0.5, position: { x: -100, y: -100 } }; 53 | 54 | // when 55 | const convertedCoords = convertCanvasCoordinates( 56 | x, 57 | y, 58 | canvasWidth, 59 | canvasHeight, 60 | camera 61 | ); 62 | 63 | // then 64 | expect(convertedCoords).toEqual({ x: -100, y: -100 }); 65 | }); 66 | 67 | it('should correctly convert coordinates when camera is rotated', () => { 68 | // given 69 | const x = 400 + 50; // 50px to the right of canvas center 70 | const y = 300; 71 | const canvasWidth = 800; 72 | const canvasHeight = 600; 73 | const camera = { 74 | zoom: 0.5, 75 | position: { x: -100, y: -100 }, 76 | rotation: -Math.PI / 2, 77 | }; 78 | 79 | // when 80 | const convertedCoords = convertCanvasCoordinates( 81 | x, 82 | y, 83 | canvasWidth, 84 | canvasHeight, 85 | camera 86 | ); 87 | 88 | // then 89 | expect(convertedCoords).toEqual({ x: -100, y: -100 - 50 * 2 }); // below instead of to the right due to camera rotation 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /src/storybook/stories/convertCanvasCoordinates.stories.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createDrawFunction, 3 | objectTypes, 4 | convertCanvasCoordinates, 5 | } from '../../index'; 6 | import createCanvasElement from '../utils/createCanvasElement'; 7 | 8 | const createDivWithContent = (content: string) => { 9 | const $div = document.createElement('div'); 10 | $div.innerText = content; 11 | return $div; 12 | }; 13 | 14 | export const convertCanvasCoordinatesBasic = { 15 | name: 'Convert Canvas Coordinates', 16 | description: 17 | "declarative-canvas exports a function that lets you convert canvas coordinates (for example event.offsetX and event.offsetY from onclick event) to base coordinates that you use to render objects (taking into account any transformations caused by camera's position, zoom and rotation).", 18 | create: () => { 19 | const { $canvas, context } = createCanvasElement(); 20 | 21 | if (!context) { 22 | return { 23 | element: createDivWithContent('Context identifier is not supported'), 24 | }; 25 | } 26 | 27 | const draw = createDrawFunction(); 28 | 29 | const centerAlign: CanvasTextAlign = 'center'; 30 | const middleBaseline: CanvasTextBaseline = 'middle'; 31 | 32 | const textObject = { 33 | type: objectTypes.TEXT, 34 | contextProps: { 35 | fillStyle: '#708871', 36 | font: '40px Courier', 37 | textAlign: centerAlign, 38 | textBaseline: middleBaseline, 39 | }, 40 | x: 50, 41 | y: 250, 42 | text: 'Click to add objects', 43 | }; 44 | 45 | const dotObjects = [] as Array<{ 46 | type: objectTypes; 47 | contextProps: Partial; 48 | x: number; 49 | y: number; 50 | radius: number; 51 | }>; 52 | 53 | const camera = { 54 | position: { x: textObject.x, y: textObject.y }, 55 | zoom: 0.8, 56 | rotation: Math.PI / 6, 57 | }; 58 | 59 | $canvas.addEventListener('click', (event) => { 60 | const { x, y } = convertCanvasCoordinates( 61 | event.offsetX, 62 | event.offsetY, 63 | $canvas.width, 64 | $canvas.height, 65 | camera 66 | ); 67 | 68 | dotObjects.push({ 69 | type: objectTypes.CIRCLE, 70 | contextProps: { fillStyle: '#BEC6A0' }, 71 | x: x, 72 | y: y, 73 | radius: 20, 74 | }); 75 | }); 76 | 77 | const drawLoop = () => { 78 | const objects = [textObject, ...dotObjects]; 79 | draw({ context, objects, camera }); 80 | requestAnimationFrame(drawLoop); 81 | }; 82 | 83 | drawLoop(); 84 | 85 | return { 86 | element: $canvas, 87 | }; 88 | }, 89 | }; 90 | -------------------------------------------------------------------------------- /src/createDrawFunction.ts: -------------------------------------------------------------------------------- 1 | import { setContextProps } from './canvasContextUtils'; 2 | import setCameraTransform from './setCameraTransform'; 3 | import { 4 | drawCircle, 5 | drawImage, 6 | drawPath, 7 | drawRect, 8 | drawText, 9 | drawTransform, 10 | } from './drawHandlerFunctions'; 11 | import objectTypes from './objectTypes'; 12 | import { Camera, GraphicalObject, DrawHandler } from './types'; 13 | 14 | const defaultDrawHandlers = { 15 | [objectTypes.CIRCLE]: drawCircle, 16 | [objectTypes.PATH]: drawPath, 17 | [objectTypes.IMAGE]: drawImage, 18 | [objectTypes.TEXT]: drawText, 19 | [objectTypes.RECT]: drawRect, 20 | [objectTypes.TRANSFORM]: drawTransform, 21 | }; 22 | 23 | const unknownTypeHandler = () => { 24 | throw new Error('Unknown object type passed to declarative-canvas'); 25 | }; 26 | 27 | type DrawHandlersDictionary = Record< 28 | T, 29 | DrawHandler 30 | >; 31 | 32 | function drawObjectFactory< 33 | Handlers extends DrawHandlersDictionary, 34 | >(context: CanvasRenderingContext2D, drawHandlers: Handlers) { 35 | function drawObject({ 36 | type, 37 | contextProps = {}, 38 | ...options 39 | }: GraphicalObject): void { 40 | context.save(); 41 | const drawHandler = drawHandlers[type] || unknownTypeHandler; 42 | setContextProps(context, contextProps); 43 | drawHandler(context, options, drawObject); 44 | context.restore(); 45 | } 46 | 47 | return drawObject; 48 | } 49 | 50 | type GObject< 51 | Handlers extends DrawHandlersDictionary, 52 | T extends keyof Handlers, 53 | > = { 54 | type: T; 55 | contextProps?: Partial; 56 | } & Parameters[1]; 57 | 58 | type DrawFunctionProps< 59 | Handlers extends DrawHandlersDictionary, 60 | > = { 61 | context: CanvasRenderingContext2D; 62 | objects: Array>; 63 | canvasWidth?: number; 64 | canvasHeight?: number; 65 | camera?: Camera; 66 | clearCanvas?: boolean; 67 | }; 68 | 69 | function createDrawFunction>( 70 | customDrawHandlers: CH = {} as CH 71 | ) { 72 | const drawHandlers = { ...defaultDrawHandlers, ...customDrawHandlers }; 73 | function drawFunction({ 74 | context, 75 | objects, 76 | canvasWidth = context.canvas?.width, 77 | canvasHeight = context.canvas?.height, 78 | camera = { 79 | position: { x: canvasWidth / 2, y: canvasHeight / 2 }, 80 | zoom: 1, 81 | rotation: 0, 82 | }, 83 | clearCanvas = true, 84 | }: DrawFunctionProps): void { 85 | if (clearCanvas) { 86 | context.clearRect(0, 0, canvasWidth, canvasHeight); 87 | } 88 | context.save(); 89 | setCameraTransform({ context, canvasWidth, canvasHeight, camera }); 90 | const drawObject = drawObjectFactory(context, drawHandlers); 91 | objects.forEach((objectToRender) => { 92 | drawObject(objectToRender); 93 | }); 94 | context.restore(); 95 | } 96 | return drawFunction; 97 | } 98 | 99 | export default createDrawFunction; 100 | -------------------------------------------------------------------------------- /src/storybook/stories/rect.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes, drawMethods } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | export const rectBasic = { 11 | name: 'Rect - Basic', 12 | create: () => { 13 | const { $canvas, context } = createCanvasElement(); 14 | 15 | if (!context) { 16 | return { 17 | element: createDivWithContent('Context identifier is not supported'), 18 | }; 19 | } 20 | 21 | const draw = createDrawFunction(); 22 | 23 | const objects = [ 24 | { 25 | type: objectTypes.RECT, 26 | contextProps: { fillStyle: '#708871' }, 27 | x: 150, 28 | y: 200, 29 | width: 200, 30 | height: 200, 31 | }, 32 | ]; 33 | 34 | draw({ context, objects }); 35 | 36 | return { 37 | element: $canvas, 38 | }; 39 | }, 40 | }; 41 | 42 | export const rectFillAndStroke = { 43 | name: 'Rect - Fill and Stroke', 44 | create: () => { 45 | const { $canvas, context } = createCanvasElement(); 46 | 47 | if (!context) { 48 | return { 49 | element: createDivWithContent('Context identifier is not supported'), 50 | }; 51 | } 52 | 53 | const draw = createDrawFunction(); 54 | 55 | const objects = [ 56 | { 57 | type: objectTypes.RECT, 58 | contextProps: { fillStyle: '#BEC6A0' }, 59 | x: 150, 60 | y: 150, 61 | width: 200, 62 | height: 200, 63 | drawMethod: drawMethods.FILL, 64 | }, 65 | { 66 | type: objectTypes.RECT, 67 | contextProps: { strokeStyle: '#708871', lineWidth: 10 }, 68 | x: 400, 69 | y: 150, 70 | width: 200, 71 | height: 200, 72 | drawMethod: drawMethods.STROKE, 73 | }, 74 | { 75 | type: objectTypes.RECT, 76 | contextProps: { 77 | fillStyle: '#BEC6A0', 78 | strokeStyle: '#708871', 79 | lineWidth: 10, 80 | }, 81 | x: 275, 82 | y: 400, 83 | width: 200, 84 | height: 200, 85 | drawMethod: drawMethods.FILL_AND_STROKE, 86 | }, 87 | ]; 88 | 89 | draw({ context, objects }); 90 | 91 | return { 92 | element: $canvas, 93 | }; 94 | }, 95 | }; 96 | 97 | export const rectRotated = { 98 | name: 'Rect - Rotated', 99 | create: () => { 100 | const { $canvas, context } = createCanvasElement(); 101 | 102 | if (!context) { 103 | return { 104 | element: createDivWithContent('Context identifier is not supported'), 105 | }; 106 | } 107 | 108 | const draw = createDrawFunction(); 109 | 110 | const objects = [ 111 | { 112 | type: objectTypes.RECT, 113 | contextProps: { fillStyle: '#BEC6A0' }, 114 | x: 300, 115 | y: 300, 116 | width: 200, 117 | height: 200, 118 | rotation: Math.PI / 6, 119 | }, 120 | ]; 121 | 122 | draw({ context, objects }); 123 | 124 | return { 125 | element: $canvas, 126 | }; 127 | }, 128 | }; 129 | -------------------------------------------------------------------------------- /src/storybook/stories/performance.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const NUMBER_OF_OBJECTS = 5_000; 5 | 6 | const createDivWithContent = (content: string) => { 7 | const $div = document.createElement('div'); 8 | $div.innerText = content; 9 | return $div; 10 | }; 11 | 12 | const refreshStats = ( 13 | renderTimes: number[], 14 | $performancePanel?: HTMLElement 15 | ) => { 16 | const $performanceInfoPanel = 17 | $performancePanel || document.querySelector('#performance-info-panel'); 18 | if (!$performanceInfoPanel) { 19 | console.warn('Performance info panel not found'); 20 | return; 21 | } 22 | const lastRenderTime = renderTimes[renderTimes.length - 1]; 23 | const averageRenderTime = 24 | renderTimes.reduce((acc, time) => acc + time, 0) / renderTimes.length; 25 | $performanceInfoPanel.innerHTML = `Last render time: ${lastRenderTime}ms. Average render time over ${renderTimes.length} runs: ${averageRenderTime.toFixed(2)}ms`; 26 | }; 27 | 28 | const wrapCanvasInPerformanceInfoPanel = ( 29 | $canvas: HTMLCanvasElement, 30 | renderTimes: number[] = [] 31 | ) => { 32 | const $wrapper = document.createElement('div'); 33 | const $performanceInfoPanel = document.createElement('div'); 34 | $performanceInfoPanel.id = 'performance-info-panel'; 35 | 36 | $wrapper.append($performanceInfoPanel); 37 | $wrapper.append($canvas); 38 | 39 | refreshStats(renderTimes, $performanceInfoPanel); 40 | 41 | return $wrapper; 42 | }; 43 | 44 | export const performanceBasic = { 45 | name: 'Performance', 46 | description: `Performance test of rendering ${NUMBER_OF_OBJECTS} objects. Click on canvas to rerender.`, 47 | create: () => { 48 | const { $canvas, context } = createCanvasElement(); 49 | 50 | if (!context) { 51 | return { 52 | element: createDivWithContent('Context identifier is not supported'), 53 | }; 54 | } 55 | 56 | const draw = createDrawFunction(); 57 | 58 | const renderTimes: number[] = []; 59 | 60 | const drawAndCollectStats = () => { 61 | const rects = new Array(NUMBER_OF_OBJECTS).fill(0).map(() => ({ 62 | x: Math.random() * $canvas.width, 63 | y: Math.random() * $canvas.height, 64 | rotation: Math.random() * Math.PI, 65 | color: ['#BEC6A0', '#708871', '#4d5565'][Math.round(Math.random() * 2)], 66 | })); 67 | 68 | const objects = [ 69 | ...rects.map(({ x, y, rotation, color }) => ({ 70 | type: objectTypes.RECT, 71 | contextProps: { 72 | fillStyle: color, 73 | }, 74 | x, 75 | y, 76 | rotation, 77 | width: 12, 78 | height: 12, 79 | })), 80 | ]; 81 | 82 | const timeStart = performance.now(); 83 | draw({ context, objects }); 84 | const renderTime = performance.now() - timeStart; 85 | 86 | renderTimes.push(renderTime); 87 | }; 88 | 89 | drawAndCollectStats(); 90 | 91 | const redraw = () => { 92 | drawAndCollectStats(); 93 | refreshStats(renderTimes); 94 | }; 95 | $canvas.addEventListener('click', redraw); 96 | 97 | const $element = wrapCanvasInPerformanceInfoPanel($canvas, renderTimes); 98 | 99 | return { 100 | element: $element, 101 | cleanUp: () => { 102 | $canvas.removeEventListener('click', redraw); 103 | }, 104 | }; 105 | }, 106 | }; 107 | -------------------------------------------------------------------------------- /src/storybook/stories/camera.stories.ts: -------------------------------------------------------------------------------- 1 | import { createDrawFunction, objectTypes } from '../../index'; 2 | import createCanvasElement from '../utils/createCanvasElement'; 3 | 4 | const createDivWithContent = (content: string) => { 5 | const $div = document.createElement('div'); 6 | $div.innerText = content; 7 | return $div; 8 | }; 9 | 10 | const getPointObjects = ({ 11 | x, 12 | y, 13 | name, 14 | }: { 15 | x: number; 16 | y: number; 17 | name: string; 18 | }) => [ 19 | { 20 | type: objectTypes.CIRCLE, 21 | contextProps: { fillStyle: 'black' }, 22 | x, 23 | y, 24 | radius: 5, 25 | }, 26 | { 27 | type: objectTypes.TEXT, 28 | contextProps: { fillStyle: 'black', font: '35px Times New Roman' }, 29 | x: x + 10, 30 | y: y - 10, 31 | text: `${name} = (${x}, ${y})`, 32 | }, 33 | ]; 34 | 35 | const points = [ 36 | { x: -200, y: -200, name: 'A' }, 37 | { x: 200, y: -200, name: 'B' }, 38 | { x: 200, y: 200, name: 'C' }, 39 | { x: -200, y: 200, name: 'D' }, 40 | { x: 0, y: 0, name: 'E' }, 41 | ]; 42 | 43 | const objects = [ 44 | ...getPointObjects(points[0]), 45 | ...getPointObjects(points[1]), 46 | ...getPointObjects(points[2]), 47 | ...getPointObjects(points[3]), 48 | ...getPointObjects(points[4]), 49 | ]; 50 | 51 | export const cameraTranslation = { 52 | name: 'Camera - Translation', 53 | description: 54 | "Move the cursor over the canvas to influence camera's position.", 55 | create: () => { 56 | let isRunning = true; 57 | const { $canvas, context } = createCanvasElement(); 58 | 59 | if (!context) { 60 | return { 61 | element: createDivWithContent('Context identifier is not supported'), 62 | }; 63 | } 64 | 65 | const draw = createDrawFunction(); 66 | 67 | const lastCursorPosition = { x: 0, y: 0 }; 68 | const mouseMoveListener = (e: MouseEvent) => { 69 | lastCursorPosition.x = e.clientX; 70 | lastCursorPosition.y = e.clientY; 71 | }; 72 | $canvas.addEventListener('mousemove', mouseMoveListener); 73 | 74 | const drawLoop = () => { 75 | const camera = { 76 | position: { 77 | x: lastCursorPosition.x * 0.3, 78 | y: lastCursorPosition.y * 0.3, 79 | }, 80 | zoom: 0.6, 81 | }; 82 | draw({ context, objects, camera }); 83 | if (isRunning) { 84 | requestAnimationFrame(drawLoop); 85 | } 86 | }; 87 | 88 | drawLoop(); 89 | 90 | return { 91 | element: $canvas, 92 | cleanUp: () => { 93 | isRunning = false; 94 | $canvas.removeEventListener('mousemove', mouseMoveListener); 95 | }, 96 | }; 97 | }, 98 | }; 99 | 100 | export const cameraRotation = { 101 | name: 'Camera - Rotation', 102 | description: 103 | "Move the cursor left and right over the canvas to influence camera's rotation. Click to move the camera over the next point.", 104 | create: () => { 105 | let isRunning = true; 106 | const { $canvas, context } = createCanvasElement(); 107 | 108 | if (!context) { 109 | return { 110 | element: createDivWithContent('Context identifier is not supported'), 111 | }; 112 | } 113 | 114 | const draw = createDrawFunction(); 115 | 116 | const objects = [ 117 | ...getPointObjects({ x: -200, y: -200, name: 'A' }), 118 | ...getPointObjects({ x: 200, y: -200, name: 'B' }), 119 | ...getPointObjects({ x: 200, y: 200, name: 'C' }), 120 | ...getPointObjects({ x: -200, y: 200, name: 'D' }), 121 | ...getPointObjects({ x: 0, y: 0, name: 'E' }), 122 | ]; 123 | 124 | const lastCursorPosition = { x: $canvas.width / 2, y: 0 }; 125 | let focusedObjectIndex = 0; 126 | const mouseMoveListener = (e: MouseEvent) => { 127 | lastCursorPosition.x = e.clientX; 128 | lastCursorPosition.y = e.clientY; 129 | }; 130 | const clickListener = () => { 131 | focusedObjectIndex = (focusedObjectIndex + 1) % points.length; 132 | }; 133 | $canvas.addEventListener('mousemove', mouseMoveListener); 134 | $canvas.addEventListener('click', clickListener); 135 | 136 | const drawLoop = () => { 137 | const camera = { 138 | position: { 139 | x: points[focusedObjectIndex].x, 140 | y: points[focusedObjectIndex].y, 141 | }, 142 | rotation: 143 | (lastCursorPosition.x - $canvas.width / 2) * 144 | ((2 * Math.PI) / $canvas.width), 145 | zoom: 0.6, 146 | }; 147 | draw({ context, objects, camera }); 148 | if (isRunning) { 149 | requestAnimationFrame(drawLoop); 150 | } 151 | }; 152 | 153 | drawLoop(); 154 | 155 | return { 156 | element: $canvas, 157 | cleanUp: () => { 158 | isRunning = false; 159 | $canvas.removeEventListener('mousemove', mouseMoveListener); 160 | $canvas.removeEventListener('click', clickListener); 161 | }, 162 | }; 163 | }, 164 | }; 165 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # declarative-canvas 2 | 3 | [![npm version](https://badge.fury.io/js/declarative-canvas.svg)](https://www.npmjs.com/package/declarative-canvas) 4 | 5 | JavaScript/TypeScript library which lets you draw on HTML5 Canvas in a declarative way. 6 | 7 | ## Installation 8 | 9 | ``` 10 | npm install declarative-canvas 11 | ``` 12 | 13 | ## Example Usage 14 | 15 | The following code draws a rectangle and a circle on a canvas. 16 | 17 | ```js 18 | import { createDrawFunction, objectTypes } from 'declarative-canvas'; 19 | 20 | const canvas = document.getElementById('canvas'); 21 | const context = canvas.getContext('2d'); 22 | 23 | const draw = createDrawFunction(); 24 | 25 | const objectsToRender = [ 26 | { type: objectTypes.RECT, x: 50, y: 100, width: 200, height: 100 }, 27 | { type: objectTypes.CIRCLE, x: 200, y: 100, radius: 100 }, 28 | ]; 29 | 30 | draw({ context, objects: objectsToRender }); 31 | ``` 32 | 33 | ## Storybook 34 | 35 | More examples can be found in the [storybook](https://lukix.github.io/declarative-canvas). Source code of storybook stories is placed in the [src/stories](./src/storybook/stories) directory. 36 | 37 | ## API Reference 38 | 39 | `declarative-canvas` exports four objects/functions: 40 | 41 | - `createDrawFunction` - draw function factory, 42 | - `objectTypes` - dictionary object of available object types which can be drawn, 43 | - `drawMethods` - dictionary object of available drawing methods. 44 | - `convertCanvasCoordinates` - lets you convert canvas coordinates to base coordinates that you use to render objects. 45 | 46 | ### createDrawFunction 47 | 48 | A factory function that takes one optional argument: 49 | 50 | ```ts 51 | (customDrawHandlers = {}) => Function; 52 | ``` 53 | 54 | `customDrawHandlers` argument is described in [Custom draw handlers](#custom-draw-handlers) chapter. 55 | 56 | A function returned from this factory has the following signature: 57 | 58 | ```ts 59 | ({ 60 | context: CanvasRenderingContext2D, 61 | objects: Array, 62 | canvasWidth = context.canvas && context.canvas.width, 63 | canvasHeight = context.canvas && context.canvas.width, 64 | camera = { position: { x: canvasWidth / 2, y: canvasHeight / 2 }, zoom: 1, rotation: 0 }, 65 | clearCanvas = true, 66 | }) => void 67 | ``` 68 | 69 | `clearCanvas` option clears canvas before rendering objects. 70 | 71 | ### objectTypes 72 | 73 | ```js 74 | { 75 | CIRCLE: 'CIRCLE', 76 | PATH: 'PATH', 77 | IMAGE: 'IMAGE', 78 | TEXT: 'TEXT', 79 | RECT: 'RECT', 80 | TRANSFORM: 'TRANSFORM', 81 | } 82 | ``` 83 | 84 | ### drawMethods 85 | 86 | ```js 87 | { 88 | FILL: 'FILL', 89 | STROKE: 'STROKE', 90 | FILL_AND_STROKE: 'FILL_AND_STROKE', 91 | } 92 | ``` 93 | 94 | Draw method tells the renderer if the given graphical object should be drawn by filling it with some color 95 | or just by drawing its outline (or both). 96 | 97 | ### convertCanvasCoordinates 98 | 99 | ```ts 100 | ( 101 | x: number, 102 | y: number, 103 | canvasWidth: number, 104 | canvasHeight: number, 105 | camera: Camera 106 | ) => { x: number, y: number } 107 | ``` 108 | 109 | A function that converts canvas coordinates (for example `event.offsetX` and `event.offsetY` from `onclick` event) to base coordinates that you use to render objects (taking into account any transformations caused by camera's position, rotation and zoom). 110 | 111 | ## Available graphical objects 112 | 113 | ### Rectangle 114 | 115 | ```ts 116 | { 117 | type: objectTypes.RECT; 118 | contextProps?: Partial; 119 | drawMethod?: string; 120 | x: number; 121 | y: number; 122 | width: number; 123 | height: number; 124 | rotation?: number; 125 | } 126 | ``` 127 | 128 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 129 | `drawMethod` - [Draw method](#drawMethods). _Default: `drawMethods.FILL`_ 130 | `x` - position of the center of rectangle in X axis 131 | `y` - position of the center of rectangle in Y axis 132 | `width` - rectangle width 133 | `height` - rectangle height 134 | `rotation` - rectangle rotation in radians. _Default: `0`_ 135 | 136 | ### Circle 137 | 138 | ```ts 139 | { 140 | type: objectTypes.CIRCLE; 141 | contextProps?: Partial; 142 | drawMethod?: string; 143 | x: number; 144 | y: number; 145 | radius: number; 146 | } 147 | ``` 148 | 149 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 150 | `drawMethod` - [Draw method](#drawMethods). _Default: `drawMethods.FILL`_ 151 | `x` - position of the center of circle in X axis 152 | `y` - position of the center of circle in Y axis 153 | `radius` - circle radius 154 | 155 | ### Path 156 | 157 | ```ts 158 | { 159 | type: objectTypes.PATH; 160 | contextProps?: Partial; 161 | drawMethod?: string; 162 | points: Array<{ x: number; y: number }>, 163 | closePath?: boolean, 164 | } 165 | ``` 166 | 167 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 168 | `drawMethod` - [Draw method](#drawMethods). _Default: `drawMethods.FILL`_ 169 | `points` - array of points that make the path 170 | `closePath` - indicates if the last point should be connected to the first point. _Default: `false`_ 171 | 172 | ### Image 173 | 174 | ```ts 175 | { 176 | type: objectTypes.IMAGE; 177 | contextProps?: Partial; 178 | image 179 | x: number; 180 | y: number; 181 | width?: number; 182 | height?: number; 183 | rotation?: number; 184 | } 185 | ``` 186 | 187 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 188 | `image` - [Image](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image) object 189 | `x` - position of the center of image in X axis 190 | `y` - position of the center of image in Y axis 191 | `width` - image width. _Defaults to image orginal width_ 192 | `height` - image height. _Defaults to image orginal height_ 193 | `rotation` - image rotation in radians. _Default: `0`_ 194 | 195 | ### Text 196 | 197 | ```ts 198 | { 199 | type: objectTypes.TEXT; 200 | contextProps?: Partial; 201 | drawMethod?: string; 202 | text: string; 203 | x: number; 204 | y: number; 205 | } 206 | ``` 207 | 208 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 209 | `drawMethod` - [Draw method](#drawMethods). _Default: `drawMethods.FILL`_ 210 | `image` - [Image](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/Image) object 211 | `text` - text to be rendered 212 | `x` - position of the text in X axis. Text horizontal and vertical align can be adjusted by `contextProps` 213 | `y` - position of the text in Y axis Text horizontal and vertical align can be adjusted by `contextProps` 214 | 215 | ### Transform 216 | 217 | ```ts 218 | { 219 | type: objectTypes.TRANSFORM; 220 | contextProps?: Partial; 221 | children: Array; 222 | dx?: number; 223 | dy?: number; 224 | scaleX?: number; 225 | scaleY?: number; 226 | skewX?: number; 227 | skewY?: number; 228 | rotation?: number; 229 | } 230 | ``` 231 | 232 | `contextProps` - [Canvas context props](#Context-Props). _Default: `{}`_ 233 | `children` - array of graphical objects 234 | `dx` - displacement of child objects in X axis. _Default: `0`_ 235 | `dy` - displacement of child objects in Y axis. _Default: `0`_ 236 | `scaleX` - scaling of child objects in X axis. _Default: `1`_ 237 | `scaleY` - scaling of child objects in Y axis. _Default: `1`_ 238 | `skewX` - skew transformation applied to child objects in X axis. _Default: `0`_ 239 | `skewY` - skew transformation applied to child objects in Y axis. _Default: `0`_ 240 | `rotation` - rotation in radians of child objects. _Default: `0`_ 241 | 242 | ## Context Props 243 | 244 | For every graphical object you can specify `contextProps` property. 245 | Before drawing graphical object, all values of `contextProps` object will be assigned 246 | to [Canvas Rendering Context](https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D). 247 | Some example values that you can use: `fillStyle`, `strokeStyle`, `lineWidth`, `filter` and so on. 248 | After drawing the graphical object, context properties will be restored back to their orginal values. 249 | 250 | ## Custom draw handlers 251 | 252 | If you want to expand the capabilities of `declarative-canvas` to support more object types, 253 | you can specify custom draw handlers which will be used to draw objects with specified object type. 254 | Draw handler is a function with the following signature: 255 | 256 | ```ts 257 | (context, options, drawObject) => void 258 | ``` 259 | 260 | To see examples of draw handlers, you can check out default draw handlers in [src/drawHandlerFunctions](./src/drawHandlerFunctions) directory. 261 | 262 | Custom handlers can be passed as a `customDrawHandlers` argument to `createDrawFunction`. 263 | `customDrawHandlers` should be an object, where keys represent object types and values represent custom handlers. 264 | --------------------------------------------------------------------------------