// @ts-check /** * Draws the page. Each layer has its own canvas, so hiding a layer or * redrawing it after an erasure does not touch the others. * * A stroke is drawn as a chain of parts, one per point (see * stroke_geometry.js). The part for a point can be drawn only when the * following point is known, so the newest end of a stroke in progress (its * *tail*) is drawn on a separate canvas that is cleared and redrawn as the * stroke grows. */ import { BLANK_PAGE_HEIGHT, BLANK_PAGE_WIDTH, POINT_SIZE, pointCount, textBounds } from './page_model.js'; import { TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH, TEXT_MARK } from './page_file.js'; import { partAt, widthAt } from './stroke_geometry.js'; import { LEFT_GAP, LINE_HEIGHT, baselineOf, fontOf } from './text_layout.js'; /** @typedef {import('./page_model.js').Page} Page */ /** @typedef {import('./page_model.js').Stroke} Stroke */ /** @typedef {import('./page_model.js').TextBlock} TextBlock */ /** * A rectangle in page units. * @typedef {{x0: number, y0: number, x1: number, y1: number}} Region */ /** * One click of the scroll wheel zooms by this factor (10%, Andy's choice * to try, 2026-10-03). */ export const ZOOM_STEP = 1.1; /** * A pen drag with Ctrl held zooms by this factor for every 100 screen * pixels of movement, continuously (Andy, 2026-10-08; a constant so that * it can be tuned). */ export const ZOOM_PER_100_PIXELS = 2; const LEAST_CSS_PIXELS_PER_UNIT = 0.05; const MOST_CSS_PIXELS_PER_UNIT = 8; /** * How near a corner of an image a press must be, in screen pixels, to * scale the image rather than move it (Andy, 2026-10-08; a constant to * tune). In screen pixels so that the handle is the same size under * the pen at any zoom. */ export const CORNER_REACH_PX = 24; /** * How a text rectangle's frame is shown on screen: the whole rectangle, * as Andy's teacher is used to, or only its corners, which is quieter * (Andy, 2026-10-08: a constant, to try both). The page file and the * export always hold the whole rectangle. * @type {'rectangle' | 'corners'} */ export const TEXT_FRAME_STYLE = 'rectangle'; /** The length of a corner mark, in page units, when only corners are shown. */ const CORNER_MARK = 60; /** Hide rectangles are marked by blue corners on screen, for everyone. */ const HIDE_CORNER_COLOR = '#1e88e5'; const HIDE_CORNER_WIDTH = 2; /** * How the page is scaled on screen: a number of CSS pixels per page unit. * 'fit' asks for the scale at which the whole page fits the window, and * is replaced by that number as soon as the page is shown: "Fit" is a * command, not a mode (Andy, 2026-10-08), so a page that grows afterwards * extends beyond the window instead of being refitted. * @typedef {'fit' | number} Zoom */ /** * Stroke lines on the device pixel grid, so that they are as crisp as a * CSS border and the same width: a line of `width` page units becomes a * whole number of pixels, at least one, and its edges land on pixels. * The lines are given in page units and drawn through the context's * transform, which maps page units to device pixels. * @param {CanvasRenderingContext2D} context * @param {[number, number][][]} polylines * @param {string} color * @param {number} width */ function strokeCrisp(context, polylines, color, width) { const t = context.getTransform(); const pixels = Math.max(1, Math.round(width * t.a)); /** @param {number} v */ const snap = (v) => Math.round(v); context.save(); context.setTransform(1, 0, 0, 1, 0, 0); context.strokeStyle = color; context.lineWidth = pixels; context.lineCap = 'butt'; context.lineJoin = 'miter'; for (const line of polylines) { context.beginPath(); line.forEach(([x, y], i) => { // A line's centre at half a pixel in: its edges on the grid. const dx = snap(t.a * x + t.e); const dy = snap(t.d * y + t.f); const px = dx + (pixels % 2 ? 0.5 : 0); const py = dy + (pixels % 2 ? 0.5 : 0); if (i === 0) context.moveTo(px, py); else context.lineTo(px, py); }); context.stroke(); } context.restore(); } /** * Draw the corners of a rectangle as short L-shaped marks. * @param {CanvasRenderingContext2D} context * @param {import('./page_model.js').Rectangle} r * @param {string} color * @param {number} width */ function drawCornerMarks(context, r, color, width) { const mark = Math.min(CORNER_MARK, r.width / 2, r.height / 2); /** @type {[number, number][][]} */ const lines = []; for (const [cx, cy] of [[0, 0], [1, 0], [0, 1], [1, 1]]) { const x = r.left + cx * r.width; const y = r.top + cy * r.height; lines.push([[x, y + (cy ? -mark : mark)], [x, y], [x + (cx ? -mark : mark), y]]); } strokeCrisp(context, lines, color, width); } /** * Draw a text block: a text rectangle's red frame, then its lines. * @param {CanvasRenderingContext2D} context * @param {TextBlock} block */ function drawText(context, block) { if (block.height === null) { // Free text: an L at its top-left corner. strokeCrisp(context, [[[block.x, block.y + TEXT_MARK], [block.x, block.y], [block.x + TEXT_MARK, block.y]]], TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH); } else { const f = textBounds(block); if (TEXT_FRAME_STYLE === 'rectangle') { const r = f.left + f.width; const b = f.top + f.height; strokeCrisp(context, [[[f.left, f.top], [r, f.top], [r, b], [f.left, b], [f.left, f.top]]], TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH); } else { drawCornerMarks(context, f, TEXT_FRAME_COLOR, TEXT_FRAME_WIDTH); } } context.font = fontOf(block.size, block.font); context.fillStyle = block.color; context.textBaseline = 'alphabetic'; // On whole device pixels, the lines a whole number of pixels apart, as // the text field lays them out (TextEditor.layout): the same pixels in // both, whatever the zoom (Andy, 2026-10-09). const t = context.getTransform(); const x = Math.round(t.a * (block.x + block.size * LEFT_GAP) + t.e); const first = Math.round(t.d * (block.y + baselineOf(block.size, block.font, 0)) + t.f); const spacing = Math.round(t.d * block.size * LINE_HEIGHT); block.lines.forEach((line, i) => { context.fillText(line, (x - t.e) / t.a, (first + i * spacing - t.f) / t.d); }); } /** * Draw the part of a stroke that belongs to point `k`. * @param {CanvasRenderingContext2D} context * @param {Stroke} stroke * @param {number} k */ function drawPart(context, stroke, k) { const part = partAt(stroke, k); context.beginPath(); if (part.dot) { context.arc(part.x, part.y, part.width / 2, 0, 2 * Math.PI); context.fillStyle = stroke.color; context.fill(); return; } context.moveTo(part.fromX, part.fromY); context.quadraticCurveTo(part.x, part.y, part.toX, part.toY); context.strokeStyle = stroke.color; context.lineWidth = part.width; context.stroke(); } export class Renderer { /** * @param {HTMLElement} pageElement Element that holds the canvases. * @param {Page} page */ constructor(pageElement, page) { this.pageElement = pageElement; this.page = page; /** @type {Map} */ this.canvases = new Map(); /** @type {Set} */ this.hidden = new Set(); /** @type {WeakMap} Parts of each stroke already drawn. */ this.drawn = new WeakMap(); /** @type {Map} Strokes in progress and their layers. */ this.active = new Map(); /** Region of the tail canvas in use, in page units, or null. */ this.tailRegion = /** @type {Region | null} */ (null); /** The white grounds of text rectangles: above the images, beneath every layer. */ this.groundCanvas = this.makeCanvas(); this.groundCanvas.classList.add('grounds'); this.pageElement.append(this.groundCanvas); this.tailCanvas = this.makeCanvas(); this.tailCanvas.classList.add('tails'); this.pageElement.append(this.tailCanvas); /** The Hide rectangles, above all writing, with their blue corners. */ this.hideCanvas = this.makeCanvas(); this.hideCanvas.classList.add('hides'); this.pageElement.append(this.hideCanvas); /** * The elements that show the page's images beneath the layers, one for * each image, in the images' sequence, and the bytes each one shows. * @type {{element: HTMLImageElement, data: Uint8Array | null, hash: string}[]} */ this.imageElements = []; /** The green cue around an image being handled by hand. */ this.grip = document.createElement('div'); this.grip.className = 'image_grip'; this.grip.hidden = true; this.pageElement.append(this.grip); /** @type {{rect: import('./page_model.js').Rectangle, corner: [number, number] | null} | null} */ this.gripShown = null; /** Canvas pixels per page unit. */ this.scale = 1; /** The page's left and top as last shown, for keeping the view still. */ this.shownLeft = /** @type {number | null} */ (null); this.shownTop = /** @type {number | null} */ (null); /** @type {Zoom} */ this.zoom = 'fit'; /** CSS pixels per page unit as last shown. */ this.cssPixelsPerUnit = 1; /** @type {(() => void) | null} Told when the zoom changes. */ this.onZoomChanged = null; /** A zoom by pen drag in progress: the scale it started from and its anchor. */ this.zoomDrag = /** @type {{zoom: number, clientX: number, clientY: number} | null} */ (null); /** * True until a page that its images have made larger than a sheet has * been shown for the first time. Such a page is a series of exercises * to scroll through (Andy, 2026-10-05), so it is first shown at the * scale at which one sheet fits the window, not all of it made small. * (A reloaded tab shows it as it was last seen there: see showView.) */ this.sheetFirst = false; /** * The text block being edited, if any, which is shown by the editor's * own text field and so not drawn here. * @type {string | null} */ this.editingText = null; this.setPage(page); // Ctrl (Command on a Mac) and the scroll wheel zoom about the // pointer, as elsewhere. this.desk().addEventListener('wheel', (event) => { if (!(event.ctrlKey || event.metaKey)) return; event.preventDefault(); this.zoomBy(event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP, event.clientX, event.clientY); }, { passive: false }); } /** @returns {HTMLElement} The scrolling container of the page. */ desk() { return /** @type {HTMLElement} */ (this.pageElement.parentElement); } /** * The room the page can be shown in, in CSS pixels. * @returns {{width: number, height: number}} */ available() { const desk = this.desk(); const style = getComputedStyle(desk); // offsetWidth, not clientWidth: a fitted page needs no scrollbars, so // the room they take while zoomed in must not count. return { width: desk.offsetWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), height: desk.offsetHeight - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom), }; } /** * Show the page at the scale a new page has: that at which one A4 * sheet fits the window ("A4", Andy, 2026-10-08, so that a shrunk page * is seen at its size on the sheet rather than blown up by "Fit"). */ sheetScale() { const { width, height } = this.available(); if (!(width > 0) || !(height > 0)) return; this.setZoom(1 / Math.max(BLANK_PAGE_WIDTH / width, BLANK_PAGE_HEIGHT / height)); } /** Show the whole page in the window. */ fit() { this.setZoom('fit'); this.desk().scrollTop = 0; this.desk().scrollLeft = 0; } /** * A zoom by pen drag begins at this point, which stays under the pen. * @param {number} clientX * @param {number} clientY */ beginZoomDrag(clientX, clientY) { this.zoomDrag = { zoom: this.cssPixelsPerUnit, clientX, clientY }; } /** * The pen of a zoom drag has moved this far up from where it began * (negative: down). ZOOM_PER_100_PIXELS per 100 pixels, continuously. * @param {number} pixelsUp */ zoomDragTo(pixelsUp) { const drag = this.zoomDrag; if (!drag) return; this.setZoom(drag.zoom * ZOOM_PER_100_PIXELS ** (pixelsUp / 100), drag.clientX, drag.clientY); } endZoomDrag() { this.zoomDrag = null; } /** * Zoom in or out by a factor, from whatever the zoom is now. * @param {number} factor * @param {number} [clientX] The point to zoom about; else the middle. * @param {number} [clientY] */ zoomBy(factor, clientX, clientY) { this.setZoom(this.cssPixelsPerUnit * factor, clientX, clientY); } /** * Change the zoom. With a position given, the page point under it stays * under it; otherwise the point at the middle of the view stays. * @param {Zoom} zoom * @param {number} [clientX] * @param {number} [clientY] */ setZoom(zoom, clientX, clientY) { if (typeof zoom === 'number') { zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, zoom)); } const desk = this.desk(); const deskBox = desk.getBoundingClientRect(); const anchorX = clientX ?? deskBox.left + desk.clientWidth / 2; const anchorY = clientY ?? deskBox.top + desk.clientHeight / 2; const [pageX, pageY] = this.pageFromClient(anchorX, anchorY); this.zoom = zoom; this.sheetFirst = false; this.resize(); // Scroll so that the anchored page point is back under the anchor. const box = this.pageElement.getBoundingClientRect(); const nowX = box.left + (pageX - this.page.left) * this.cssPixelsPerUnit; const nowY = box.top + (pageY - this.page.top) * this.cssPixelsPerUnit; desk.scrollLeft += nowX - anchorX; desk.scrollTop += nowY - anchorY; this.onZoomChanged?.(); } /** * Show another page in place of the present one. * @param {Page} page */ setPage(page) { this.page = page; this.shownLeft = null; this.shownTop = null; this.zoom = 'fit'; this.sheetFirst = page.isLargerThanSheet(); this.onZoomChanged?.(); // A new page is seen from its top. this.desk().scrollTop = 0; this.desk().scrollLeft = 0; for (const canvas of this.canvases.values()) canvas.remove(); this.canvases.clear(); this.hidden.clear(); this.active.clear(); this.gripShown = null; this.showImages(); this.resize(); // Once more after the browser has laid the page out, in case anything // above (a dialog closing, say) left the measurements stale. Andy saw // a loaded page's image appear only after pressing Fit (2026-10-03), // which could not be reproduced. requestAnimationFrame(() => { if (this.page === page) this.resize(); }); } /** * How the page is being shown, so that it can be shown the same way * after the browser page is reloaded. * @returns {{zoom: Zoom, x: number, y: number}} The zoom, and the page * point at the top left corner of the view. */ view() { const desk = this.desk(); return { zoom: this.zoom, x: this.page.left + desk.scrollLeft / this.cssPixelsPerUnit, y: this.page.top + desk.scrollTop / this.cssPixelsPerUnit, }; } /** * Show the page as view() described it. * @param {{zoom: Zoom, x: number, y: number}} view */ showView(view) { this.zoom = view.zoom === 'fit' ? 'fit' : Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, view.zoom)); this.sheetFirst = false; this.resize(); const desk = this.desk(); desk.scrollLeft = (view.x - this.page.left) * this.cssPixelsPerUnit; desk.scrollTop = (view.y - this.page.top) * this.cssPixelsPerUnit; this.onZoomChanged?.(); } /** Place the image elements where the images lie on the page. */ layoutImages() { const page = this.page; for (const [i, image] of page.images.entries()) { const style = this.imageElements[i]?.element.style; if (!style) continue; style.left = `${(image.x - page.left) / page.width * 100}%`; style.top = `${(image.y - page.top) / page.height * 100}%`; style.width = `${image.width / page.width * 100}%`; style.height = `${image.height / page.height * 100}%`; } } /** * Show the page's images whose bytes are here, in their sequence (the * last in front). An element that already shows an image keeps it when * the images are reordered, so that nothing flickers. */ showImages() { const images = this.page.images; const spare = this.imageElements.slice(); /** @type {typeof this.imageElements} */ const shownNow = []; for (const image of images) { const at = spare.findIndex((one) => one.hash === image.hash); let shown = at >= 0 ? spare.splice(at, 1)[0] : null; if (!shown) { const element = document.createElement('img'); element.className = 'page_image'; element.alt = ''; element.draggable = false; element.hidden = true; shown = { element, data: null, hash: image.hash }; } if (shown.data !== image.data) { if (shown.element.src) URL.revokeObjectURL(shown.element.src); shown.data = image.data; shown.element.hidden = !image.data; if (image.data) { shown.element.src = URL.createObjectURL(new Blob([image.data], { type: image.type })); } else { shown.element.removeAttribute('src'); } } shownNow.push(shown); } for (const gone of spare) { if (gone.element.src) URL.revokeObjectURL(gone.element.src); gone.element.remove(); } // Beneath the layers, in the sequence of the images. const firstLayer = this.pageElement.querySelector('canvas'); for (const shown of shownNow) this.pageElement.insertBefore(shown.element, firstLayer); this.imageElements = shownNow; this.layoutImages(); } /** * Show, or hide, the cue around an image being handled: its outline, * and the corner being dragged if it is being scaled. * @param {import('./page_model.js').Rectangle | null} rect Page units. * @param {[number, number] | null} [corner] */ showGrip(rect, corner = null) { this.gripShown = rect ? { rect, corner } : null; this.layoutGrip(); } layoutGrip() { const shown = this.gripShown; this.grip.hidden = !shown; if (!shown) return; const page = this.page; const style = this.grip.style; style.left = `${(shown.rect.left - page.left) / page.width * 100}%`; style.top = `${(shown.rect.top - page.top) / page.height * 100}%`; style.width = `${shown.rect.width / page.width * 100}%`; style.height = `${shown.rect.height / page.height * 100}%`; this.grip.classList.toggle('corner', shown.corner !== null); if (shown.corner) { style.setProperty('--corner-x', shown.corner[0] ? '100%' : '0%'); style.setProperty('--corner-y', shown.corner[1] ? '100%' : '0%'); } } /** @returns {number} CORNER_REACH_PX in page units at the present zoom. */ cornerReach() { return CORNER_REACH_PX / this.cssPixelsPerUnit; } makeCanvas() { const canvas = document.createElement('canvas'); canvas.className = 'layer'; return canvas; } /** * @param {HTMLCanvasElement} canvas * @returns {CanvasRenderingContext2D} */ context(canvas) { const context = /** @type {CanvasRenderingContext2D} */ (canvas.getContext('2d')); context.setTransform(this.scale, 0, 0, this.scale, -this.page.left * this.scale, -this.page.top * this.scale); context.lineCap = 'round'; context.lineJoin = 'round'; return context; } /** @param {HTMLCanvasElement} canvas */ sizeCanvas(canvas) { canvas.width = Math.round(this.page.width * this.scale); canvas.height = Math.round(this.page.height * this.scale); } /** * Match the canvases to the page's rectangle and its displayed size, and * redraw. Called when the window changes, when the page grows and when * the zoom changes. * * A zoom of 'fit' (a page shown for the first time, or "Fit" pressed) * becomes the scale at which the whole page fits the window, and stays * a number from then on: growth extends the page beyond the window. */ resize() { const desk = this.desk(); const { width: availableWidth, height: availableHeight } = this.available(); if (!(availableWidth > 0) || !(availableHeight > 0)) return; const first = this.zoom === 'fit' && this.sheetFirst; this.sheetFirst = false; if (first) { this.zoom = Math.max(LEAST_CSS_PIXELS_PER_UNIT, Math.min(MOST_CSS_PIXELS_PER_UNIT, 1 / Math.max(BLANK_PAGE_WIDTH / availableWidth, BLANK_PAGE_HEIGHT / availableHeight))); } const fitted = this.zoom === 'fit'; const unitsPerCssPixel = this.zoom === 'fit' ? Math.max(this.page.width / availableWidth, this.page.height / availableHeight) : 1 / this.zoom; this.cssPixelsPerUnit = 1 / unitsPerCssPixel; if (fitted) this.zoom = this.cssPixelsPerUnit; this.pageElement.style.width = `${this.page.width / unitsPerCssPixel}px`; this.pageElement.style.height = `${this.page.height / unitsPerCssPixel}px`; this.layoutImages(); this.layoutGrip(); // When the page grew to the left or upward, keep what is in view in view. if (this.shownLeft !== null && this.page.left < this.shownLeft) { desk.scrollLeft += (this.shownLeft - this.page.left) / unitsPerCssPixel; } if (this.shownTop !== null && this.page.top < this.shownTop) { desk.scrollTop += (this.shownTop - this.page.top) / unitsPerCssPixel; } this.shownLeft = this.page.left; this.shownTop = this.page.top; this.scale = (window.devicePixelRatio || 1) / unitsPerCssPixel; this.sizeCanvas(this.tailCanvas); this.sizeCanvas(this.groundCanvas); this.sizeCanvas(this.hideCanvas); this.tailRegion = null; for (const id of this.canvases.keys()) this.redrawLayer(id, true); this.drawGrounds(); this.drawHides(); this.drawTails(); if (first || fitted) this.onZoomChanged?.(); } /** * Put the layer canvases in the given sequence, bottom to top, creating * canvases for new layers. * @param {string[]} ids */ setLayerOrder(ids) { for (const id of ids) { let canvas = this.canvases.get(id); if (!canvas) { canvas = this.makeCanvas(); this.sizeCanvas(canvas); this.canvases.set(id, canvas); } this.pageElement.insertBefore(canvas, this.tailCanvas); } } /** * @param {string} id * @param {boolean} visible */ setLayerVisible(id, visible) { if (visible) this.hidden.delete(id); else this.hidden.add(id); const canvas = this.canvases.get(id); if (canvas) canvas.hidden = !visible; this.drawGrounds(); this.drawTails(); } /** * Draw a layer again from the page. * @param {string} id * @param {boolean} [resized] The canvas size must change as well. */ redrawLayer(id, resized = false) { const canvas = this.canvases.get(id); const layer = this.page.layers.get(id); if (!canvas) return; if (resized) this.sizeCanvas(canvas); const context = this.context(canvas); context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height); for (const [stroke, layerId] of this.active) { if (layerId === id) this.active.delete(stroke); } if (!layer) return; for (const stroke of layer.strokes.values()) { this.drawn.set(stroke, 0); this.drawNewParts(context, id, stroke); } for (const block of layer.texts.values()) { if (block.id !== this.editingText) drawText(context, block); } this.drawGrounds(); this.drawTails(); } /** * Draw the white grounds of every text rectangle of every layer shown * here, beneath the layers: they hide the image, never anyone's writing. */ drawGrounds() { const context = this.context(this.groundCanvas); context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height); context.fillStyle = '#ffffff'; for (const [id, layer] of this.page.layers) { if (this.hidden.has(id)) continue; for (const block of layer.texts.values()) { if (block.height === null || block.id === this.editingText) continue; const b = textBounds(block); context.fillRect(b.left, b.top, b.width, b.height); } } } /** Draw the Hide rectangles, white with blue corners, above everything. */ drawHides() { const context = this.context(this.hideCanvas); context.clearRect(this.page.left, this.page.top, this.page.width, this.page.height); for (const hide of this.page.hides) { context.fillStyle = '#ffffff'; context.fillRect(hide.x, hide.y, hide.width, hide.height); drawCornerMarks(context, { left: hide.x, top: hide.y, width: hide.width, height: hide.height }, HIDE_CORNER_COLOR, HIDE_CORNER_WIDTH); } } /** * A stroke gained points or was finished: draw what is new. * @param {string} layerId * @param {Stroke} stroke */ strokeProgress(layerId, stroke) { const canvas = this.canvases.get(layerId); if (!canvas) return; this.drawNewParts(this.context(canvas), layerId, stroke); this.drawTails(); } /** * @param {CanvasRenderingContext2D} context * @param {string} layerId * @param {Stroke} stroke */ drawNewParts(context, layerId, stroke) { const count = pointCount(stroke); // The part for the newest point waits until the stroke is finished or // another point arrives. const ready = stroke.done ? count : count - 1; let k = this.drawn.get(stroke) ?? 0; for (; k < ready; k++) drawPart(context, stroke, k); this.drawn.set(stroke, Math.max(k, 0)); if (stroke.done) this.active.delete(stroke); else this.active.set(stroke, layerId); } /** Redraw the tails of the strokes in progress. */ drawTails() { const context = this.context(this.tailCanvas); if (this.tailRegion) { const r = this.tailRegion; context.clearRect(r.x0, r.y0, r.x1 - r.x0, r.y1 - r.y0); this.tailRegion = null; } for (const [stroke, layerId] of this.active) { const count = pointCount(stroke); if (count === 0 || this.hidden.has(layerId)) continue; const p = stroke.points; const end = (count - 1) * POINT_SIZE; const width = widthAt(stroke, count - 1); context.beginPath(); let startX = p[end]; let startY = p[end + 1]; if (count === 1) { context.arc(p[end], p[end + 1], width / 2, 0, 2 * Math.PI); context.fillStyle = stroke.color; context.fill(); } else { startX = (p[end - POINT_SIZE] + p[end]) / 2; startY = (p[end - POINT_SIZE + 1] + p[end + 1]) / 2; context.moveTo(startX, startY); context.lineTo(p[end], p[end + 1]); context.strokeStyle = stroke.color; context.lineWidth = width; context.stroke(); } const margin = width + 2 / this.scale; /** @type {Region} */ const r = this.tailRegion ?? { x0: Infinity, y0: Infinity, x1: -Infinity, y1: -Infinity }; r.x0 = Math.min(r.x0, startX - margin, p[end] - margin); r.y0 = Math.min(r.y0, startY - margin, p[end + 1] - margin); r.x1 = Math.max(r.x1, startX + margin, p[end] + margin); r.y1 = Math.max(r.y1, startY + margin, p[end + 1] + margin); this.tailRegion = r; } } /** * Convert a position in the window to page units. * @param {number} clientX * @param {number} clientY * @returns {[number, number]} */ pageFromClient(clientX, clientY) { const box = this.pageElement.getBoundingClientRect(); return [ this.page.left + (clientX - box.left) * this.page.width / box.width, this.page.top + (clientY - box.top) * this.page.height / box.height, ]; } /** * @param {number} cssPixels * @returns {number} The same length in page units. */ pageLength(cssPixels) { return cssPixels * this.page.width / this.pageElement.getBoundingClientRect().width; } }