Skip to content

Instantly share code, notes, and snippets.

@aziis98
Last active April 25, 2024 12:06
Show Gist options
  • Save aziis98/4277af9574d38a42d4d4038ce0dfc42c to your computer and use it in GitHub Desktop.
Save aziis98/4277af9574d38a42d4d4038ce0dfc42c to your computer and use it in GitHub Desktop.
A small declarative DSL for CanvasRenderingContext2D

GDSL

Graphics DSL is a small library to help write canvas drawing code in a more declarative style.

The main function of this library is render2d(g, dsl)

  • The first argument is the CanvasRenderingContext2D to draw to.

  • The second one is a structure must be one of the following:

    • An array, this recursively calls render2d on every primitive inside the array, automatically scoping the call with save/restore.

    • An object, this is useful for passing multiple options all in one like strokeStyle, lineWidth, ... This can also be used to easily create custom compund styles

      const LINE_STYLE_1 = {
          strokeStyle: 'royalblue',
          lineWidth: 1,
      }
      
      const LINE_STYLE_2 = {
          ...LINE_STYLE_1,
          lineWidth: 5,
      }
    • A function, this is how primitives are implemented. This simply gets called with the g passed as input. To create new primitives one can use function currying like the circle function

      export const circle = (x, y, r) => g => {
          g.arc(x, y, r, 0, Math.PI * 2)
      }

This idea is mostly borrowed from the Mathematica Graphics function

Example: Live Interactive Graph

interactive graph screenshot

In this example I show how one can create a pretty decent live interactive graph just using render2d (and some utility functions like rescale)

import { memo, useEffect, useRef } from 'preact/compat'
import { circle, fill, lineTo, moveTo, render2d, stroke, text, translate } from '../gdsl.js'
const round = d => Math.round(100 * d) / 100
const rescale = ([sourceMin, sourceMax], [targetMin, targetMax], value) => {
const sourceRange = sourceMax - sourceMin
const targetRange = targetMax - targetMin
return targetMin + ((value - sourceMin) / sourceRange) * targetRange
}
const data1 = []
const data2 = []
let yValue1 = 50
let yValue2 = -50
const updateData = (removeFirst = true) => {
yValue1 += Math.random() * 10 - 5
yValue2 += Math.random() * 10 - 5
if (removeFirst) data1.shift()
data1.push(round(yValue1))
if (removeFirst) data2.shift()
data2.push(round(yValue2))
}
for (let i = 0; i < 100; i++) {
updateData(false)
}
const render = (canvas, { mouse }) => {
// The canvas has 100% width and height set from css and lives in a container with `display: grid` to make the canvas fill the whole container
const [WIDTH, HEIGHT] = [canvas.offsetWidth, canvas.offsetHeight]
canvas.width = WIDTH
canvas.height = HEIGHT
/**
* @type {CanvasRenderingContext2D}
*/
const g = canvas.getContext('2d')
const minY = Math.min(...data1, ...data2)
const maxY = Math.max(...data1, ...data2)
const yZero = rescale([minY, maxY], [HEIGHT, 0], 0)
const mouseIndex = Math.round(rescale([0, WIDTH], [0, data1.length], mouse.x))
const mouseData1 = data1[mouseIndex]
const mouseData2 = data2[mouseIndex]
render2d(g, [
translate(0.5, 0.5),
[
{ strokeStyle: '#ccc' },
stroke([
moveTo(0, yZero),
lineTo(WIDTH, yZero),
moveTo(rescale([0, data1.length], [0, WIDTH], mouseIndex), 0),
lineTo(rescale([0, data1.length], [0, WIDTH], mouseIndex), HEIGHT),
]),
],
[
[[data1, mouseData1], { color: '#f66', labelColor: '#800' }],
[[data2, mouseData2], { color: '#66f', labelColor: '#008' }],
].map(([[data, mouseData], { color, labelColor }]) => [
[
{ strokeStyle: color, lineWidth: 2 },
stroke([
moveTo(0, rescale([minY, maxY], [HEIGHT, 0], data[0])),
data.map((y, i) => [
lineTo(
rescale([0, data.length], [0, WIDTH], i),
rescale([minY, maxY], [HEIGHT, 0], y)
),
]),
]),
],
[
{ fillStyle: labelColor },
fill([
circle(
rescale([0, data1.length], [0, WIDTH], mouseIndex),
rescale([minY, maxY], [HEIGHT, 0], mouseData),
3
),
]),
],
[
{ fillStyle: labelColor, font: '500 14px Geist Sans' },
text(
rescale([0, data1.length], [0, WIDTH], mouseIndex) + 5,
rescale([minY, maxY], [HEIGHT, 0], mouseData) - 5,
`${mouseData}`
),
],
]),
[
{ strokeStyle: '#ccc', lineWidth: 1 },
data1.map((_, i) =>
stroke([
moveTo(
rescale([0, data1.length], [0, WIDTH], i),
yZero - (i % 10 === 0 ? 4 : 2)
),
lineTo(
rescale([0, data1.length], [0, WIDTH], i),
yZero + (i % 10 === 0 ? 4 : 2)
),
])
),
],
])
}
export const Graph = memo(({}) => {
const canvasRef = useRef(null)
const graphStateRef = useRef({
mouse: { x: 0, y: 0 },
})
useEffect(() => {
if (!canvasRef.current) return
render(canvasRef.current, graphStateRef.current)
const interval = setInterval(() => {
updateData()
render(canvasRef.current, graphStateRef.current)
}, 250)
return () => {
console.log('unmount')
clearInterval(interval)
}
}, [canvasRef.current])
return (
<canvas
ref={canvasRef}
onMouseMove={e => {
graphStateRef.current.mouse.x = e.offsetX
graphStateRef.current.mouse.y = e.offsetY
render(e.target, graphStateRef.current)
}}
></canvas>
)
})
export const moveTo = (x, y) => g => {
g.moveTo(x, y)
}
export const lineTo = (x, y) => g => {
g.lineTo(x, y)
}
const applyAttrs = (g, attrs) => {
for (const [k, v] of Object.entries(attrs)) {
g[k] = v
}
}
export const translate = (x, y) => g => {
g.translate(x, y)
}
export const scale = (x, y) => g => {
g.scale(x, y)
}
export const rotate = angle => g => {
g.rotate(angle)
}
export const stroke = dsl => g => {
g.beginPath()
render2d(g, dsl)
g.stroke()
}
export const fill = dsl => g => {
g.beginPath()
render2d(g, dsl)
g.fill()
}
export const text = (x, y, text) => g => {
g.fillText(text, x, y)
}
export const circle = (x, y, r) => g => {
g.arc(x, y, r, 0, Math.PI * 2)
}
export const rect = (x, y, w, h) => g => {
g.rect(x, y, w, h)
}
export const render2d = (g, dsl) => {
g.save()
for (const cmd of dsl) {
if (Array.isArray(cmd)) {
render2d(g, cmd)
} else if (typeof cmd === 'function') {
cmd(g)
} else {
applyAttrs(g, cmd)
}
}
g.restore()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment