Published
Edited
Nov 12, 2021
Importers
3 stars
Insert cell
Insert cell
viewof sampleMiniDraw = {
const md = miniDraw();
invalidation.then(() => md.cleanup());
return md;
}
Insert cell
Insert cell
icons = ({
trash: "🗑️",
slow: "🐢",
medium: "🚶",
fast: "🐆",
pen: "🖋️",
erase: "✖️",
fill: "💧",
undo: "↩️"
})
Insert cell
Insert cell
Insert cell
//
// Creates a pen settings interface
//
function settings() {
return pack({
mode: HideInput(
"mode",
Inputs.radio(["write", "erase"], { value: "write" })
),
speed: HideInput(
"speed",
Inputs.radio(Object.keys(pens), { value: "medium" })
),
lineWidth: HideInput(
"line width",
BubbleRange([1, 40], { value: 2, step: 1 })
),
color: HideInput("color", colorInput("#000000"))
});
}
Insert cell
//
// Creates an alternate pen settings interface
//
function settingsAlt() {
const sep = () =>
htl.html`<div style="min-width:3px;min-height:20px;margin:2px 10px;border-color:gray;border-style:none solid;border-width:1px;display:flex; flex-direction: column">`;
return pack(
{
mode: radioGroup([icons.pen, icons.fill, icons.erase], {
names: ["write", "fill", "erase"]
}),
dummy: sep(),
speed: radioGroup([icons.slow, icons.medium, icons.fast], {
names: Object.keys(pens),
value: "medium"
}),
dummy2: sep(),
lineWidth: radioGroup([1, 2, 5, 10, 20, 40], {
value: 2,
draw: (ctx, v) => {
ctx.strokeStyle = "black";
ctx.lineWidth = v;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(100, 100);
ctx.stroke();
}
}),
dummy3: sep(),
color: colorInput("#000000")
// radioGroup(palette, {
// value: "black",
// size: 14,
// columns: Math.round(palette.length / 2),
// draw: (ctx, v) => {
// ctx.fillStyle = v;
// ctx.fillRect(0, 0, 100, 100);
// }
// })
},
{ sep: 0 }
);
}
Insert cell
//
// Creates a set of controls for miniDraw with menu and pen settings
//
function controls(drawing) {
const m = menu(drawing);
const s = settings();
const c = htl.html`<div>${rangeStyles}<div style="display:inline-flex">${m}
<div style="display: flex; flex-direction: column;justify-content: center;font:bold 11pt sans-serif;text-align:right;width:4em;">Pen:&nbsp;</div>
${s}</div>`;
s.oninput = () => {
c.value = s.value;
c.dispatchEvent(new CustomEvent("input"));
};
c.value = s.value;
return c;
}
Insert cell
//
// Creates a set of controls for miniDraw with menu and pen settings
//
function controlsAlt(drawing) {
const m = menuAlt(drawing);
const s = settingsAlt();
const sep = () =>
htl.html`<div style="min-width:3px;margin:2px 10px;background:gray;display:flex; flex-direction: column">`;
const c = htl.html`<div style="display:inline-flex">${m}${sep()}${s}</div>`;
s.oninput = () => {
c.value = s.value;
c.dispatchEvent(new CustomEvent("input"));
};
c.value = s.value;
return c;
}
Insert cell
//
// Some nice dynadraw pen settings
//
pens = ({
slow: { mass: 20, drag: 0.5 },
medium: { mass: 5, drag: 0.5 },
fast: { mass: 1, drag: 0.7 }
})
Insert cell
//
// class to generate dynaDraw lines
//
class DynLine {
constructor(options = {}) {
let { mass = 5, drag = 0.5 } = options;
Object.assign(this, {
mass,
drag,
active: false,
pos: Vec(0, 0),
prev: Vec(0, 0),
vel: Vec(0, 0)
});
}

start(newpos) {
this.pos = this.prev = newpos;
this.vel = Vec(0, 0);
this.active = false;
}

move(newpos) {
this.active = false;
let f = newpos.sub(this.pos);
let acc = f.scale(1 / this.mass);
if (acc.mag() < 0.01) return false;
this.vel = this.vel.add(acc).scale(1.0 - this.drag);
if (this.vel.mag() < 0.1) return false;
this.prev = this.pos;
this.pos = this.pos.add(this.vel);
this.active = true;
return true;
}
}
Insert cell
//
// Base class for a miniDraw drawing
//
class Drawing {
constructor(options = {}) {
let { width = 800, height = 600 } = options;
let canvas = htl.html`<canvas tabindex=1 width=${width} height=${height} style="border:1px solid gray">`;
let ctx = canvas.getContext("2d");
let contents = [];

Object.assign(this, {
width,
height,
contents,
canvas,
ctx,
cacheInfo: null
});
}
add(graphics) {
this.contents.push(graphics);
}
clear() {
this.contents = [];
this.cacheInfo = null;
}
get length() {
return this.contents.length;
}
draw() {
let first = 0;
if (this.cacheInfo) {
let { last, imgData } = this.cacheInfo;
this.ctx.putImageData(imgData, 0, 0);
first = last + 1;
} else {
this.ctx.clearRect(0, 0, this.width, this.height);
}
for (let i = first; i < this.contents.length; i++) {
let g = this.contents[i];
g.draw(this.ctx);
}
this.canvas.dispatchEvent(new CustomEvent("input"));
}
cache() {
this.cacheInfo = null;
this.draw();
this.cacheInfo = {
last: this.contents.length - 1,
imgData: this.ctx.getImageData(0, 0, this.width, this.height)
};
}
top() {
return this.contents[this.length - 1];
}
pop() {
this.cacheInfo = null;
return this.contents.pop();
}
}
Insert cell
//
// Creates a miniDraw widget
//
function miniDraw(options = {}) {
const w = options.width || width;
const h = options.height || (w * 9) / 16;

const drawing = new Drawing({ width: w, height: h });
const sampleControls = controlsAlt(drawing);
const theMiniDraw = htl.html`<div>${sampleControls}<br>${drawing.canvas}`;
theMiniDraw.value = drawing.canvas;

let dl = new DynLine(pens[sampleControls.value.speed]);
let lastStroke = null;
let lastPoint = Vec(0, 0);

const refresh = () => drawing.draw();

const addToStroke = () => {
if (dl.active) {
lastStroke.push(dl.pos);
dl.move(lastPoint);
refresh();
}
};

const mouse = (e) => Vec(e.offsetX, e.offsetY);

drawing.canvas.onmousedown = (e) => {
lastPoint = mouse(e);
drawing.cache();
if (sampleControls.value.mode == "fill") {
drawing.add(
new FillArea(lastPoint, {
color: sampleControls.value.color
})
);
refresh();
} else {
dl.start(lastPoint);
lastStroke = [lastPoint];
drawing.add(new StyledLine(lastStroke, sampleControls.value));
}
};

drawing.canvas.onmousemove = (e) => {
if (sampleControls.value.mode != "fill" && e.buttons) {
lastPoint = mouse(e);
dl.move(lastPoint);
}
};

drawing.canvas.onkeydown = (e) => {
e.preventDefault();
if (e.keyCode == 90 && (e.ctrlKey || e.metaKey)) {
drawing.pop();
drawing.draw();
}
};

drawing.canvas.oninput = (e) => {
theMiniDraw.dispatchEvent(new CustomEvent("input"));
};

const changePen = () => {
dl = new DynLine(pens[sampleControls.value.speed]);
};
sampleControls.oninput = changePen;

const intervalId = setInterval(addToStroke, 1000 / 60);

theMiniDraw.cleanup = () => {
clearInterval(intervalId);
};

return theMiniDraw;
}
Insert cell
//
// Class to represent a stylized polyline
//
class StyledLine {
constructor(points = [], options = {}) {
let {
color = "#000",
mode = "write",
lineWidth = 1,
lineJoin = "round",
lineCap = "round"
} = options;
Object.assign(this, { points, color, lineWidth, lineJoin, lineCap, mode });
}
get geometry() {
return this.points;
}
set geometry(points) {
this.points = points;
}
draw(ctx) {
ctx.lineWidth = this.lineWidth;
ctx.strokeStyle = this.color;
ctx.lineCap = this.lineCap;
ctx.lineJoin = this.lineJoin;
if (this.mode == "erase") ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
for (let p of this.points) ctx.lineTo(p.x, p.y);
ctx.stroke();
if (this.mode == "erase") ctx.globalCompositeOperation = "source-over";
}
}
Insert cell
//
// Class to represent a flood fill
//
class FillArea {
constructor(seed, options = {}) {
let { color = "green" } = options;
Object.assign(this, { seed, color });
}
draw(ctx) {
floodFill(ctx, this.seed.x, this.seed.y, colorToInt(this.color));
}
}
Insert cell
colorToInt("blue")
Insert cell
//
// Generates a color input with 80 pixels, suitable for use in a compact interface
//
function colorInput(value) {
let inp = Inputs.color({ value, datalist: [] });
inp.style.maxWidth = "80px";
inp.querySelector("output").style.visibility = "hidden";
return inp;
}
Insert cell
//
// Default color palette
//
palette = [
"black",
"lightgray",
"gray",
"white",
"red", "orange", "yellow",
"green", "brown", "blue",
"magenta", "cyan"
]
Insert cell
//
// A reasonably fast flood fill algorithm
//
floodFill = {
function getPixel(pixelData, x, y) {
if (x < 0 || y < 0 || x >= pixelData.width || y >= pixelData.height) {
return -1; // impossible color
} else {
return pixelData.data[y * pixelData.width + x];
}
}

function floodFill(ctx, x, y, fillColor) {
// read the pixels in the canvas
const imageData = ctx.getImageData(
0,
0,
ctx.canvas.width,
ctx.canvas.height
);

// make a Uint32Array view on the pixels so we can manipulate pixels
// one 32bit value at a time instead of as 4 bytes per pixel
const pixelData = {
width: imageData.width,
height: imageData.height,
data: new Uint32Array(imageData.data.buffer)
};

// get the color we're filling
const targetColor = getPixel(pixelData, x, y);

// The function we are using to test if a pixel must be painted
let colorMatch;
if (alpha(targetColor) <= 128 && alpha(fillColor) == 255) {
// Filling a non opaque pixel
colorMatch = (pixel) => alpha(pixel) <= 128;
} else {
// Filling pixels equal to targetColor
colorMatch = (pixel) => pixel === targetColor;
}

// check we are actually filling a different color
if (targetColor !== fillColor) {
const spansToCheck = [];

function addSpan(left, right, y, direction) {
spansToCheck.push({ left, right, y, direction });
}

function checkSpan(left, right, y, direction) {
let inSpan = false;
let start;
let x;
for (x = left; x < right; ++x) {
const color = getPixel(pixelData, x, y);
if (colorMatch(color)) {
if (!inSpan) {
inSpan = true;
start = x;
}
} else {
if (inSpan) {
inSpan = false;
addSpan(start, x - 1, y, direction);
}
}
}
if (inSpan) {
inSpan = false;
addSpan(start, x - 1, y, direction);
}
}

addSpan(x, x, y, 0);

while (spansToCheck.length > 0) {
const { left, right, y, direction } = spansToCheck.pop();

// do left until we hit something, while we do this check above and below and add
let l = left;
for (;;) {
--l;
const color = getPixel(pixelData, l, y);
if (color !== targetColor) {
break;
}
}
++l;

let r = right;
for (;;) {
++r;
const color = getPixel(pixelData, r, y);
if (color !== targetColor) {
break;
}
}

const lineOffset = y * pixelData.width;
pixelData.data.fill(fillColor, lineOffset + l, lineOffset + r);

if (direction <= 0) {
checkSpan(l, r, y - 1, -1);
} else {
checkSpan(l, left, y - 1, -1);
checkSpan(right, r, y - 1, -1);
}

if (direction >= 0) {
checkSpan(l, r, y + 1, +1);
} else {
checkSpan(l, left, y + 1, +1);
checkSpan(right, r, y + 1, +1);
}
}
// put the data back
ctx.putImageData(imageData, 0, 0);
}
}

return floodFill;
}
Insert cell
function colorToInt(color) {
let { r, g, b, opacity } = d3.color(color);
return ((~~(opacity * 255) * 256 + b) * 256 + g) * 256 + r;
}
Insert cell
function rgbaToInt(r, g, b, a) {
let X = new Uint8Array([r, g, b, a]);
let Y = new Uint32Array(X.buffer);
return Y[0];
}
Insert cell
alpha = {
let X = new Uint8Array(4);
let Y = new Uint32Array(X.buffer);
return function (uint32) {
Y[0] = uint32;
return X[3];
};
}
Insert cell
function intToRgba(i) {
let X = new Uint32Array([i]);
let Y = new Uint8Array(X.buffer);
return Y;
}
Insert cell
alpha(rgbaToInt(0, 0, 0, 255)) == 255
Insert cell
intToRgba(colorToInt("blue"))
Insert cell
{
const ctx = DOM.context2d(300, 300, 1);

ctx.beginPath();

ctx.moveTo(20, 20);
ctx.lineTo(250, 70);
ctx.lineTo(270, 120);
ctx.lineTo(170, 140);
ctx.lineTo(100, 145);
ctx.lineTo(110, 105);
ctx.lineTo(130, 125);
ctx.lineTo(190, 80);
ctx.lineTo(100, 60);
ctx.lineTo(50, 130);
ctx.lineTo(20, 20);

ctx.stroke();

floodFill(ctx, 40, 50, colorToInt("blue"));

return ctx.canvas;
}

Insert cell
Insert cell
import { Vec } from "@esperanc/2d-geometry-utils"
Insert cell
import {
pack,
HideInput,
BubbleRange,
rangeStyles
} from "@esperanc/range-input-variations"
Insert cell
import { actionButton, radioGroup } from "@esperanc/buttons-gui"
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more