A Path2D holds a path you build once and fill, stroke, clip or hit-test many times. Its constructor accepts SVG path data, so SVG's shapes move over unchanged; addPath(path, matrix) appends one through a DOMMatrix. roundRect(x, y, w, h, radii), with radii in border-radius order, is Baseline widely available since October 2025.
<canvas id="c" style="width:600px; height:106px"></canvas><script src="hidpi.js"></script>
<script>
const ctx = hidpi(c), r = devicePixelRatio;
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const nest = new Path2D('M36 118 Q120 142 204 118 Q196 182 120 184 Q44 182 36 118 Z');
ctx.fillStyle = '#263238'; ctx.save(); ctx.scale(0.55, 0.55); ctx.lineWidth = 22;
[42, 13, 56, 70, 34, 76].forEach((top, i) => { // spine tops: pages / 4 + 30
const turn = new DOMMatrix().translate(120, 240).rotate(10 * i - 25).translate(-120, -240);
const spine = new Path2D(); spine.addPath(new Path2D(`M120 150 V${top}`), turn);
ctx.strokeStyle = colors[i]; ctx.stroke(spine);
});
ctx.fill(nest);
const inside = ctx.isPointInPath(nest, 66 * r, 88 * r); ctx.restore(); // bitmap pixels
ctx.fillStyle = '#e09a10'; ctx.beginPath(); ctx.roundRect(180, 30, 150, 50, [24, 4]);
ctx.fill(); ctx.fillStyle = '#263238'; ctx.font = 'bold 18px Georgia';
ctx.fillText('$14.99', 225, 62); ctx.fillText(`(66, 88) in the nest: ${inside}`, 350, 62);
</script>
isPointInPath(path, x, y) takes the point in bitmap pixels and the path through the current transform, hence the test before restore(). The mark is kept in demos/ch02/booknest-mark.js.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
canvas { width: 100%; max-width: 600px; height: 130px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<p>Move the pointer over the nest: <span id="hit">outside</span></p>
<script>
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const c = document.getElementById('c');
const ctx = hidpi(c), r = devicePixelRatio;
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
// Path2D accepts SVG path data, so the BookNest nest moves over unchanged
const nest = new Path2D('M36 118 Q120 142 204 118 Q196 182 120 184 Q44 182 36 118 Z');
let over = false;
function draw() {
ctx.clearRect(0, 0, 600, 130);
ctx.save(); ctx.translate(10, 0); ctx.scale(0.62, 0.62); ctx.lineWidth = 22;
[42, 13, 56, 70, 34, 76].forEach((top, i) => {
// Fan the spines out: rotate each one about a point below the nest
const turn = new DOMMatrix().translate(120, 240).rotate(10 * i - 25).translate(-120, -240);
const spine = new Path2D();
spine.addPath(new Path2D(`M120 150 V${top}`), turn);
ctx.strokeStyle = colors[i]; ctx.stroke(spine);
});
ctx.fillStyle = over ? '#5d4037' : '#263238'; ctx.fill(nest);
ctx.restore();
// roundRect radii follow border-radius order: [top-left/bottom-right, top-right/bottom-left]
ctx.fillStyle = '#e09a10'; ctx.beginPath(); ctx.roundRect(200, 40, 150, 50, [24, 4]); ctx.fill();
ctx.fillStyle = '#263238'; ctx.font = 'bold 20px Georgia'; ctx.fillText('$14.99', 240, 72);
ctx.beginPath(); ctx.roundRect(380, 40, 200, 50, 25); ctx.strokeStyle = '#1f5f8b';
ctx.lineWidth = 3; ctx.stroke();
ctx.fillStyle = '#1f5f8b'; ctx.font = '16px sans-serif'; ctx.fillText('Add to cart', 437, 71);
}
c.addEventListener('pointermove', e => {
const b = c.getBoundingClientRect();
// isPointInPath uses bitmap pixels and applies the current transform to the path
ctx.save(); ctx.translate(10, 0); ctx.scale(0.62, 0.62);
over = ctx.isPointInPath(nest, (e.clientX - b.left) * r, (e.clientY - b.top) * r);
ctx.restore();
document.getElementById('hit').textContent = over ? 'inside' : 'outside';
draw();
});
draw();
</script>