Open 9am opened 2 years ago
If you are using macOS, you'll definitely notice the minimization effect when clicking the little yellow button on the top-left corner. I remember the first time I saw it. The animation is so smoothy, and the action perfectly makes sense.
This time, I'll try to implement this effect using canvas. Actually, after I figure out how to do it. I made a web component out of SVG to enable the animation for any position on the page.
https://user-images.githubusercontent.com/1435457/169258793-a07ca0be-8522-443a-bf99-3fdd8cdb250d.mov
https://user-images.githubusercontent.com/1435457/169258840-bd191233-8136-4331-a96b-888a21a5209f.mov
Before writing the codes, let's talk about how the animation effect works.
If we took a screenshot in the middle of the animation, it's like the original image is twisted. This is called Projective Mapping[^1]. It 'projects' a group of points from the current axis system to another with a certain transform method. One common use case is the 3D model texture rendering. The 3D models are just vector data to compose a body shape which have several surfaces. To make it looks real, a texture image is applied to the surfaces like a skin using this method.
Back to our job, to map every pixel in the original image to the target area. Since it's a 2D transform, the documents^2 told us we need a matrix like this. {X, Y}
is the points of the original surface and {U, V}
is the target. It takes at least 3 pairs of points to solve the equation.
And you may notice that just the rect couldn't make those curvey borders. So we need to use little rects or even better, triangles to trick the eyes, like the 3D model uses triangles to simulate a round surface. Here is a live demo to show the transformation process.
Split the area into triangles.
The split()
method takes width, height, column, row
to slice big rect into small pieces of triangles. We render the area with triangles and keep the references of points to do the animation.
// util.js
export const split = ({ w, h, c, r }) => {
const wc = w / c;
const hr = h / r;
const points = [];
for (let j = 0; j < r + 1; j++) {
for (let i = 0; i < c + 1; i++) {
points.push([i * wc, j * hr]);
}
}
const triangles = points.flatMap((point, i) => {
if (!((i + 1) % (c + 1)) || i / (c + 1) >= r) {
return [];
}
return [
[point, points[i + 1], points[i + c + 2]],
[point, points[i + c + 1], points[i + c + 2]]
];
});
return { points, triangles };
};
// main.js
const { triangles: dstTriangles } = split({ w: W, h: H, c: 4, r: 4 });
const render = () => {
ctx.clearRect(0, 0, W, H);
dstTriangles.forEach((tri) => {
ctx.beginPath();
for (let [i, [x, y]] of tri.entries()) {
const fn = i ? ctx.lineTo : ctx.moveTo;
fn.call(ctx, x, y);
}
ctx.closePath();
ctx.stroke();
});
};
Animate the triangle points to the target when clicking.
For this step, we listen to the 'click' event and tween points to the target position. To make the effect right, an extra delay is needed for the tween function. More distance, more delay. We use a linear tween function for this part, you can try to replace it to get other vibes.
// main.js
const frame = (timestamp) => {
if (start === undefined) {
start = timestamp;
distList = srcPoints.map(([cx, cy]) => Math.hypot(cx - tx, cy - ty));
distMax = Math.max.apply(null, distList);
}
const len = dstPoints.length;
for (let i = 0; i < len; i++) {
const dst = dstPoints[i];
const src = srcPoints[i];
const delay = (distList[i] / distMax) * duration;
dst[0] = linear(timestamp - start, src[0], tx, duration, delay);
dst[1] = linear(timestamp - start, src[1], ty, duration, delay);
}
render();
if (timestamp - start < duration * 2) {
rid = window.requestAnimationFrame(frame);
}
};
canvas.addEventListener("click", (evt) => {
tx = +evt.offsetX;
ty = +evt.offsetY;
if (rid) {
start = undefined;
window.cancelAnimationFrame(rid);
}
rid = window.requestAnimationFrame(frame);
});
// util.js
export const linear = (t, b, c, d, delay = 0) => {
const next = b + (c - b) * (Math.max(0, t - delay) / d);
return c - b > 0 ? Math.min(next, c) : Math.max(next, c);
};
Transform and Clip the image for each triangle.
Now it's time to rewrite the render()
method to texture the triangles using the transform matrix, and clipping the image with the triangle area.
// main.js
const render = () => {
ctx.clearRect(0, 0, W, H);
dstTriangles.forEach((dst, i) => {
ctx.save();
ctx.beginPath();
for (let [i, [x, y]] of dst.entries()) {
const fn = i ? ctx.lineTo : ctx.moveTo;
fn.call(ctx, x, y);
}
ctx.clip();
const src = srcTriangles[i];
const mat = getTextureMatrix(src, dst);
ctx.transform.apply(ctx, mat);
ctx.drawImage(img, 0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.restore();
});
};
https://user-images.githubusercontent.com/1435457/169260058-d9357976-54b7-4ffc-b92d-f043708e7461.mov
Notice: There are some little white gaps between the triangle areas, those are caused by anti-aliasing, a little scaling-up for the clipping area will fix the issue.
Why an SVG version you may ask. Yeah, the canvas works perfectly but the effect is restrained by the canvas area since it has a width and height. What if we need to minimize the image to a position outside the area. That's the situation where SVG stepped in.
The <clipPath>
can to do the job context.clip
does, and the element support transform
attribute as well. All we need is there, meet <img-tissue>
.
<img-tissue
src="/9am.jpeg"
column="8"
row="8"
title="9am"
></img-tissue>
const tissue = document.querySelector('img-tissue');
tissue.zoomIn({ clientX: 100, clientY: 100, duration: 300 })
tissue.zoomOut({ clientX: 100, clientY: 100, duration: 300 })
npm npm install @9am/img-tissue
skypack https://cdn.skypack.dev/@9am/img-tissue
unpkg https://unpkg.com/@9am/img-tissue
github
I think it fits the use case of many things, add-to-cart action, image slide show...
Hope you enjoy it, I'll see you next time.