To build on top of the user1693593 answer, I've added a method to add missing steps between generated images for thicker outlines or more pointy base image. It works by adding steps between set checkpoint based on set granularity.
// Notice that I've used 0.75 instead of 0.5 as it generates less pointy border
let dArr: number[][] = [
[-0.75, -0.75], // ↖️
[ 0 , -1 ], // ⬆️
[ 0.75, -0.75], // ↗️
[ 1 , 0 ], // ➡️
[ 0.75, 0.75], // ↘️
[ 0 , 1 ], // ⬇️
[-0.75, 0.75], // ↙️
[-1 , 0 ], // ⬅️
];
// Our breaking point, below 5 pixels it's not worth runing
if (thickness <= 5) {
return dArr;
}
// 2.5 is the factor deciding the amount of steps in between checkpoints
// the lower the number => the more steps will be added
const granularity = Math.floor(thickness / 2.5);
let newDArr: number[][] = [];
for(let i=0; i < dArr.length; i++) {
newDArr.push(dArr[i]);
// c* is our current directions and d* is a destination
const [cX, cY] = dArr[i],
[dX, dY] = i + 1 === dArr.length ? dArr[0] : dArr[i + 1]
;
// Here we are defining our trends: up or down.
// As Y and X can have different trend (X can go down where Y up)
// we have to treat them independly
const trendX = cX > dX ? -1 : 1,
trendY = cY > dY ? -1 : 1,
bX = (Math.abs(cX - dX)/granularity) * trendX,
bY = (Math.abs(cY - dY)/granularity) * trendY,
between: number[][] = []
;
let x = cX,
y = cY
;
while (
(
trendX > 0 && x + bX < dX
|| trendX < 0 && x + bX > dX
)
&& (
trendY > 0 && y + bY < dY
|| trendY < 0 && y + bY > dY
)
) {
x += bX;
y += bY;
between.push([x, y]);
}
newDArr = newDArr.concat(between);
}
return newDArr;
Whole function (with user1693593 answer):
const outlineImage = (
image: HTMLImageElement,
fill: string,
thickness: number,
asWidth: number,
asHeight: number,
): void => {
const canvas = document.createElement('canvas'),
ctx = canvas.getContext('2d')!
;
let dArr = [
[-0.75, -0.75], // ↖️
[ 0 , -1 ], // ⬆️
[ 0.75, -0.75], // ↗️
[ 1 , 0 ], // ➡️
[ 0.75, 0.75], // ↘️
[ 0 , 1 ], // ⬇️
[-0.75, 0.75], // ↙️
[-1 , 0 ], // ⬅️
];
if (thickness > 5) {
const granularity = Math.floor(thickness / 2.5);
let newDArr: number[][] = [];
for(let i=0; i < dArr.length; i++) {
newDArr.push(dArr[i]);
const [cX, cY] = dArr[i],
[dX, dY] = i + 1 === dArr.length ? dArr[0] : dArr[i + 1]
;
const trendX = cX > dX ? -1 : 1,
trendY = cY > dY ? -1 : 1,
bX = (Math.abs(cX - dX)/granularity) * trendX,
bY = (Math.abs(cY - dY)/granularity) * trendY,
between: number[][] = []
;
let x = cX,
y = cY
;
while (
(
trendX > 0 && x + bX < dX
|| trendX < 0 && x + bX > dX
)
&& (
trendY > 0 && y + bY < dY
|| trendY < 0 && y + bY > dY
)
) {
x += bX;
y += bY;
between.push([x, y]);
}
newDArr = newDArr.concat(between);
}
dArr = newDArr;
}
canvas.setAttribute('width', String(asWidth + thickness*2));
canvas.setAttribute('height', String(asHeight + thickness*2));
for (let i = 0; i < dArr.length; i++) {
ctx.drawImage(
image,
thickness + dArr[i][0] * thickness,
thickness + dArr[i][1] * thickness,
asWidth,
asHeight,
);
if (thickness === 0) {
break;
}
}
ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = fill;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.globalCompositeOperation = 'source-over';
ctx.drawImage(image, thickness, thickness, asWidth, asHeight);
}
Based on the amount of additional steps you want to add or thickness of the border, this might become quite slow and loose its edge over the marching ant's solution.