Paint a simple shape on the pixel grid below, then step through the Canny pipeline to watch edges emerge. Each button advances one stage.
<!-- Canvas-based Canny edge detection demo -->
<p class="hint">Paint pixels on the grid (click or drag), then step through the Canny pipeline: <b>Gradient</b> finds intensity changes, <b>Non-Max Suppression</b> thins them to single-pixel ridges, and <b>Hysteresis</b> keeps only the true edges.</p>
<div class="controls top-controls">
<button id="btn-paint" class="active" type="button">Paint</button>
<button id="btn-erase" type="button">Erase</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div id="grid-wrap">
<canvas id="canvas" width="240" height="240"></canvas>
</div>
<div class="pipeline-row">
<button id="btn-gradient" type="button">1. Gradient</button>
<button id="btn-nms" type="button" disabled>2. Non-Max Suppression</button>
<button id="btn-hysteresis" type="button" disabled>3. Hysteresis</button>
</div>
<div class="status" id="status">Draw a shape, then click Gradient.</div>
/* Base reset and layout */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#grid-wrap { display: inline-block; border: 2px solid #8899aa; border-radius: 6px; overflow: hidden; cursor: crosshair; margin: .4rem 0; }
canvas { display: block; image-rendering: pixelated; }
.controls { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
.pipeline-row { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; transition: opacity .15s; }
button.ghost { background: #fff; color: #1d3557; }
button.active { outline: 2px solid #e63946; outline-offset: 2px; }
button:disabled { opacity: .35; cursor: default; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin-top: .35rem; color: #1d3557; }
.status.done { color: #0a7d33; }
/* Canny pipeline: Sobel gradient → non-maximum suppression → hysteresis thresholding */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grid dimensions — 12x12 cells, each 20px */
const COLS = 12, ROWS = 12, CELL = 20;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
/* Pixel state array (0=empty, 255=filled) */
const px = new Uint8Array(ROWS * COLS); // 0=empty 255=filled
let mode = 'paint'; // paint or erase
let stage = 'draw'; // draw | gradient | nms | edge
/* Computed pipeline stage data */
let gradMag = null; // normalized 0-255 gradient magnitude
let nmsMap = null; // binary: 1=ridge pixel
let edgeMap = null; // binary: 1=confirmed edge
/* Color palette for each pipeline stage */
const COL_BG = '#f0f4f8';
const COL_FILL = '#1d3557';
const COL_GRAD = '#e07b39';
const COL_NMS = '#9333ea';
const COL_EDGE = '#e63946';
function idx(r, c) { return r * COLS + c; }
/* Render the canvas according to current pipeline stage */
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = c * CELL, y = r * CELL;
if (stage === 'draw') {
ctx.fillStyle = px[idx(r, c)] ? COL_FILL : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'gradient' && gradMag) {
const v = gradMag[idx(r, c)];
const t = Math.min(v / 255, 1);
ctx.fillStyle = lerpColor(COL_BG, COL_GRAD, t);
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'nms' && nmsMap) {
ctx.fillStyle = nmsMap[idx(r, c)] ? COL_NMS : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'edge' && edgeMap) {
ctx.fillStyle = edgeMap[idx(r, c)] ? COL_EDGE : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
}
/* Faint grid lines */
ctx.strokeStyle = 'rgba(100,120,140,0.25)';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, CELL, CELL);
}
}
}
/* Linearly interpolate between two hex colors */
function lerpColor(a, b, t) {
const parse = h => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b);
const r = Math.round(ar + (br-ar)*t), g = Math.round(ag + (bg-ag)*t), bl = Math.round(ab + (bb-ab)*t);
return `rgb(${r},${g},${bl})`;
}
/* Mouse and touch painting handlers */
let painting = false;
canvas.addEventListener('mousedown', e => { painting = true; applyBrush(e); });
canvas.addEventListener('mousemove', e => { if (painting) applyBrush(e); });
canvas.addEventListener('mouseup', () => { painting = false; });
canvas.addEventListener('mouseleave', () => { painting = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); painting = true; applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (painting) applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchend', () => { painting = false; });
function applyBrush(e) {
if (stage !== 'draw') return;
const rect = canvas.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
px[idx(r, c)] = mode === 'paint' ? 255 : 0;
draw();
}
/* Sobel operator — approximates image gradient magnitude and angle */
function sobelGradient() {
const Gx = [-1,0,1,-2,0,2,-1,0,1];
const Gy = [-1,-2,-1, 0, 0, 0, 1, 2, 1];
const mag = new Float32Array(ROWS * COLS);
const ang = new Float32Array(ROWS * COLS);
for (let r = 1; r < ROWS-1; r++) {
for (let c = 1; c < COLS-1; c++) {
let gx = 0, gy = 0, ki = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const val = px[idx(r+dr, c+dc)];
gx += Gx[ki] * val;
gy += Gy[ki] * val;
ki++;
}
}
mag[idx(r,c)] = Math.sqrt(gx*gx + gy*gy);
ang[idx(r,c)] = Math.atan2(gy, gx);
}
}
return { mag, ang };
}
/* Non-maximum suppression — keep only local gradient maxima along edge direction */
function nonMaxSuppression(mag, ang) {
const out = new Uint8Array(ROWS * COLS);
const maxMag = Math.max(...mag);
if (maxMag === 0) return out;
const norm = new Float32Array(mag.length);
for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;
for (let r = 1; r < ROWS-1; r++) {
for (let c = 1; c < COLS-1; c++) {
const a = ang[idx(r,c)];
/* Quantize gradient angle to 4 directions */
const deg = ((a * 180 / Math.PI) % 180 + 180) % 180;
let r1, c1, r2, c2;
if (deg < 22.5 || deg >= 157.5) { r1=0; c1=1; r2=0; c2=-1; }
else if (deg < 67.5) { r1=-1; c1=1; r2=1; c2=-1; }
else if (deg < 112.5) { r1=-1; c1=0; r2=1; c2=0; }
else { r1=-1; c1=-1; r2=1; c2=1; }
const m = norm[idx(r,c)];
const m1 = norm[idx(r+r1, c+c1)];
const m2 = norm[idx(r+r2, c+c2)];
out[idx(r,c)] = (m >= m1 && m >= m2 && m > 10) ? 1 : 0;
}
}
return out;
}
/* Hysteresis thresholding — flood-fill from strong edges to connected weak edges */
function hysteresis(nms, mag) {
const maxMag = Math.max(...mag);
if (maxMag === 0) return new Uint8Array(ROWS * COLS);
const norm = new Float32Array(mag.length);
for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;
const TH = 80, TL = 25; // high and low thresholds (normalized 0-255)
const strong = new Uint8Array(ROWS * COLS);
const weak = new Uint8Array(ROWS * COLS);
for (let i = 0; i < ROWS * COLS; i++) {
if (!nms[i]) continue;
if (norm[i] >= TH) strong[i] = 1;
else if (norm[i] >= TL) weak[i] = 1;
}
/* BFS flood-fill from strong edge pixels */
const out = new Uint8Array(ROWS * COLS);
const queue = [];
for (let i = 0; i < ROWS * COLS; i++) { if (strong[i]) { out[i] = 1; queue.push(i); } }
while (queue.length) {
const i = queue.pop();
const r = Math.floor(i / COLS), c = i % COLS;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const nr = r+dr, nc = c+dc;
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
const ni = idx(nr, nc);
if (weak[ni] && !out[ni]) { out[ni] = 1; queue.push(ni); }
}
}
return out;
}
/* Wire up toolbar buttons */
document.getElementById('btn-paint').addEventListener('click', () => {
mode = 'paint';
document.getElementById('btn-paint').classList.add('active');
document.getElementById('btn-erase').classList.remove('active');
});
document.getElementById('btn-erase').addEventListener('click', () => {
mode = 'erase';
document.getElementById('btn-erase').classList.add('active');
document.getElementById('btn-paint').classList.remove('active');
});
document.getElementById('btn-reset').addEventListener('click', () => {
px.fill(0); gradMag = null; nmsMap = null; edgeMap = null;
stage = 'draw';
document.getElementById('btn-nms').disabled = true;
document.getElementById('btn-hysteresis').disabled = true;
setStatus('Draw a shape, then click Gradient.', '');
draw();
});
document.getElementById('btn-gradient').addEventListener('click', () => {
const { mag, ang } = sobelGradient();
const maxMag = Math.max(...mag);
gradMag = new Uint8Array(ROWS * COLS);
for (let i = 0; i < mag.length; i++) gradMag[i] = maxMag > 0 ? Math.round(mag[i] / maxMag * 255) : 0;
/* Store raw angle and magnitude for subsequent stages */
canvas._ang = ang; canvas._rawMag = mag;
stage = 'gradient';
document.getElementById('btn-nms').disabled = false;
const nonZero = [...gradMag].filter(v => v > 10).length;
setStatus(i18nFmt('Gradient computed — %n% active pixels. Click Non-Max Suppression.', { n: nonZero }), '');
draw();
});
document.getElementById('btn-nms').addEventListener('click', () => {
nmsMap = nonMaxSuppression(canvas._rawMag, canvas._ang);
stage = 'nms';
document.getElementById('btn-hysteresis').disabled = false;
const edgePx = [...nmsMap].filter(v => v).length;
setStatus(i18nFmt('Suppressed to %n% ridge pixel(s). Click Hysteresis.', { n: edgePx }), '');
draw();
});
document.getElementById('btn-hysteresis').addEventListener('click', () => {
edgeMap = hysteresis(nmsMap, canvas._rawMag);
stage = 'edge';
const edgePx = [...edgeMap].filter(v => v).length;
setStatus(i18nFmt('Edges found: %n% pixel(s) after hysteresis thresholding.', { n: edgePx }), 'done');
draw();
});
function setStatus(t, cls) {
const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls;
}
/* Initialize display */
setStatus('Draw a shape, then click Gradient.', '');
draw();
<!-- Canvas-based Canny edge detection demo -->
<p class="hint">Paint pixels on the grid (click or drag), then step through the Canny pipeline: <b>Gradient</b> finds intensity changes, <b>Non-Max Suppression</b> thins them to single-pixel ridges, and <b>Hysteresis</b> keeps only the true edges.</p>
<div class="controls top-controls">
<button id="btn-paint" class="active" type="button">Paint</button>
<button id="btn-erase" type="button">Erase</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div id="grid-wrap">
<canvas id="canvas" width="240" height="240"></canvas>
</div>
<div class="pipeline-row">
<button id="btn-gradient" type="button">1. Gradient</button>
<button id="btn-nms" type="button" disabled>2. Non-Max Suppression</button>
<button id="btn-hysteresis" type="button" disabled>3. Hysteresis</button>
</div>
<div class="status" id="status">Draw a shape, then click Gradient.</div>
/* Base reset and layout */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#grid-wrap { display: inline-block; border: 2px solid #8899aa; border-radius: 6px; overflow: hidden; cursor: crosshair; margin: .4rem 0; }
canvas { display: block; image-rendering: pixelated; }
.controls { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
.pipeline-row { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; transition: opacity .15s; }
button.ghost { background: #fff; color: #1d3557; }
button.active { outline: 2px solid #e63946; outline-offset: 2px; }
button:disabled { opacity: .35; cursor: default; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin-top: .35rem; color: #1d3557; }
.status.done { color: #0a7d33; }
/* Canny pipeline: Sobel gradient → non-maximum suppression → hysteresis thresholding */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Grid dimensions — 12x12 cells, each 20px */
const COLS = 12, ROWS = 12, CELL = 20;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;
/* Pixel state array (0=empty, 255=filled) */
const px = new Uint8Array(ROWS * COLS); // 0=empty 255=filled
let mode = 'paint'; // paint or erase
let stage = 'draw'; // draw | gradient | nms | edge
/* Computed pipeline stage data */
let gradMag = null; // normalized 0-255 gradient magnitude
let nmsMap = null; // binary: 1=ridge pixel
let edgeMap = null; // binary: 1=confirmed edge
/* Color palette for each pipeline stage */
const COL_BG = '#f0f4f8';
const COL_FILL = '#1d3557';
const COL_GRAD = '#e07b39';
const COL_NMS = '#9333ea';
const COL_EDGE = '#e63946';
function idx(r, c) { return r * COLS + c; }
/* Render the canvas according to current pipeline stage */
function draw() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS; c++) {
const x = c * CELL, y = r * CELL;
if (stage === 'draw') {
ctx.fillStyle = px[idx(r, c)] ? COL_FILL : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'gradient' && gradMag) {
const v = gradMag[idx(r, c)];
const t = Math.min(v / 255, 1);
ctx.fillStyle = lerpColor(COL_BG, COL_GRAD, t);
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'nms' && nmsMap) {
ctx.fillStyle = nmsMap[idx(r, c)] ? COL_NMS : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
} else if (stage === 'edge' && edgeMap) {
ctx.fillStyle = edgeMap[idx(r, c)] ? COL_EDGE : COL_BG;
ctx.fillRect(x, y, CELL, CELL);
}
/* Faint grid lines */
ctx.strokeStyle = 'rgba(100,120,140,0.25)';
ctx.lineWidth = 0.5;
ctx.strokeRect(x, y, CELL, CELL);
}
}
}
/* Linearly interpolate between two hex colors */
function lerpColor(a, b, t) {
const parse = h => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b);
const r = Math.round(ar + (br-ar)*t), g = Math.round(ag + (bg-ag)*t), bl = Math.round(ab + (bb-ab)*t);
return `rgb(${r},${g},${bl})`;
}
/* Mouse and touch painting handlers */
let painting = false;
canvas.addEventListener('mousedown', e => { painting = true; applyBrush(e); });
canvas.addEventListener('mousemove', e => { if (painting) applyBrush(e); });
canvas.addEventListener('mouseup', () => { painting = false; });
canvas.addEventListener('mouseleave', () => { painting = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); painting = true; applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (painting) applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchend', () => { painting = false; });
function applyBrush(e) {
if (stage !== 'draw') return;
const rect = canvas.getBoundingClientRect();
const c = Math.floor((e.clientX - rect.left) / CELL);
const r = Math.floor((e.clientY - rect.top) / CELL);
if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
px[idx(r, c)] = mode === 'paint' ? 255 : 0;
draw();
}
/* Sobel operator — approximates image gradient magnitude and angle */
function sobelGradient() {
const Gx = [-1,0,1,-2,0,2,-1,0,1];
const Gy = [-1,-2,-1, 0, 0, 0, 1, 2, 1];
const mag = new Float32Array(ROWS * COLS);
const ang = new Float32Array(ROWS * COLS);
for (let r = 1; r < ROWS-1; r++) {
for (let c = 1; c < COLS-1; c++) {
let gx = 0, gy = 0, ki = 0;
for (let dr = -1; dr <= 1; dr++) {
for (let dc = -1; dc <= 1; dc++) {
const val = px[idx(r+dr, c+dc)];
gx += Gx[ki] * val;
gy += Gy[ki] * val;
ki++;
}
}
mag[idx(r,c)] = Math.sqrt(gx*gx + gy*gy);
ang[idx(r,c)] = Math.atan2(gy, gx);
}
}
return { mag, ang };
}
/* Non-maximum suppression — keep only local gradient maxima along edge direction */
function nonMaxSuppression(mag, ang) {
const out = new Uint8Array(ROWS * COLS);
const maxMag = Math.max(...mag);
if (maxMag === 0) return out;
const norm = new Float32Array(mag.length);
for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;
for (let r = 1; r < ROWS-1; r++) {
for (let c = 1; c < COLS-1; c++) {
const a = ang[idx(r,c)];
/* Quantize gradient angle to 4 directions */
const deg = ((a * 180 / Math.PI) % 180 + 180) % 180;
let r1, c1, r2, c2;
if (deg < 22.5 || deg >= 157.5) { r1=0; c1=1; r2=0; c2=-1; }
else if (deg < 67.5) { r1=-1; c1=1; r2=1; c2=-1; }
else if (deg < 112.5) { r1=-1; c1=0; r2=1; c2=0; }
else { r1=-1; c1=-1; r2=1; c2=1; }
const m = norm[idx(r,c)];
const m1 = norm[idx(r+r1, c+c1)];
const m2 = norm[idx(r+r2, c+c2)];
out[idx(r,c)] = (m >= m1 && m >= m2 && m > 10) ? 1 : 0;
}
}
return out;
}
/* Hysteresis thresholding — flood-fill from strong edges to connected weak edges */
function hysteresis(nms, mag) {
const maxMag = Math.max(...mag);
if (maxMag === 0) return new Uint8Array(ROWS * COLS);
const norm = new Float32Array(mag.length);
for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;
const TH = 80, TL = 25; // high and low thresholds (normalized 0-255)
const strong = new Uint8Array(ROWS * COLS);
const weak = new Uint8Array(ROWS * COLS);
for (let i = 0; i < ROWS * COLS; i++) {
if (!nms[i]) continue;
if (norm[i] >= TH) strong[i] = 1;
else if (norm[i] >= TL) weak[i] = 1;
}
/* BFS flood-fill from strong edge pixels */
const out = new Uint8Array(ROWS * COLS);
const queue = [];
for (let i = 0; i < ROWS * COLS; i++) { if (strong[i]) { out[i] = 1; queue.push(i); } }
while (queue.length) {
const i = queue.pop();
const r = Math.floor(i / COLS), c = i % COLS;
for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
const nr = r+dr, nc = c+dc;
if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
const ni = idx(nr, nc);
if (weak[ni] && !out[ni]) { out[ni] = 1; queue.push(ni); }
}
}
return out;
}
/* Wire up toolbar buttons */
document.getElementById('btn-paint').addEventListener('click', () => {
mode = 'paint';
document.getElementById('btn-paint').classList.add('active');
document.getElementById('btn-erase').classList.remove('active');
});
document.getElementById('btn-erase').addEventListener('click', () => {
mode = 'erase';
document.getElementById('btn-erase').classList.add('active');
document.getElementById('btn-paint').classList.remove('active');
});
document.getElementById('btn-reset').addEventListener('click', () => {
px.fill(0); gradMag = null; nmsMap = null; edgeMap = null;
stage = 'draw';
document.getElementById('btn-nms').disabled = true;
document.getElementById('btn-hysteresis').disabled = true;
setStatus('Draw a shape, then click Gradient.', '');
draw();
});
document.getElementById('btn-gradient').addEventListener('click', () => {
const { mag, ang } = sobelGradient();
const maxMag = Math.max(...mag);
gradMag = new Uint8Array(ROWS * COLS);
for (let i = 0; i < mag.length; i++) gradMag[i] = maxMag > 0 ? Math.round(mag[i] / maxMag * 255) : 0;
/* Store raw angle and magnitude for subsequent stages */
canvas._ang = ang; canvas._rawMag = mag;
stage = 'gradient';
document.getElementById('btn-nms').disabled = false;
const nonZero = [...gradMag].filter(v => v > 10).length;
setStatus(i18nFmt('Gradient computed — %n% active pixels. Click Non-Max Suppression.', { n: nonZero }), '');
draw();
});
document.getElementById('btn-nms').addEventListener('click', () => {
nmsMap = nonMaxSuppression(canvas._rawMag, canvas._ang);
stage = 'nms';
document.getElementById('btn-hysteresis').disabled = false;
const edgePx = [...nmsMap].filter(v => v).length;
setStatus(i18nFmt('Suppressed to %n% ridge pixel(s). Click Hysteresis.', { n: edgePx }), '');
draw();
});
document.getElementById('btn-hysteresis').addEventListener('click', () => {
edgeMap = hysteresis(nmsMap, canvas._rawMag);
stage = 'edge';
const edgePx = [...edgeMap].filter(v => v).length;
setStatus(i18nFmt('Edges found: %n% pixel(s) after hysteresis thresholding.', { n: edgePx }), 'done');
draw();
});
function setStatus(t, cls) {
const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls;
}
/* Initialize display */
setStatus('Draw a shape, then click Gradient.', '');
draw();
Comments
Loading comments...