UNPKG

canvas-grid-lines

Version:

Draws grid lines as HTML canvas element (baseline, squared and more)

374 lines 17.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.canvasGridLines = exports.CanvasGridLines = void 0; const types_1 = require("./types"); const constants_1 = require("./constants"); const gridTypeConfig_1 = require("./gridTypeConfig"); const parseColumns_1 = require("./parseColumns"); const gapPattern_1 = require("./gapPattern"); /** * Draws a crisp grid onto an HTML canvas appended to `container`. * * Each instance owns one container element and one canvas. The canvas is * resized and redrawn on window resize, and on demand via the setters for * `columns`, `gridType`, `color` and `lineWidth`. Containers that are not * visible at construction time are observed and initialised lazily once they * enter the viewport. */ class CanvasGridLines { constructor(container, options = {}) { /** Alternating gap pattern for horizontal lines (rows gridType only). */ this.hGaps = null; /** Alternating gap pattern for vertical lines (columns + rows gridType). */ this.vGaps = null; this.ratio = 0; this.gridHeight = 0; this.gridWidth = 0; this.canvasHeight = 0; this.canvasWidth = 0; this.lineWidthCanvas = 0; /** False until the canvas has been created — guards lazy initialisation. */ this.isInitialized = false; this.resizeHandler = () => this.scale(); this.container = container; // gridType — explicit option wins, then HTML data attribute, then default. Validated. const gridTypeRaw = options.gridType ?? container.getAttribute('data-grid-type') ?? constants_1.DEFAULT_GRID_TYPE; if (!(0, types_1.isGridType)(gridTypeRaw)) { throw new Error(`Invalid gridType "${gridTypeRaw}"`); } this._gridType = gridTypeRaw; // units — same resolution chain, validated. const unitsRaw = options.units ?? container.getAttribute('data-grid-units') ?? constants_1.DEFAULT_UNITS; if (!(0, types_1.isUnits)(unitsRaw)) { throw new Error(`Invalid units "${unitsRaw}"`); } this.units = unitsRaw; this._color = options.color ?? container.getAttribute('data-grid-color') ?? constants_1.DEFAULT_COLOR; const lineWidthAttr = container.getAttribute('data-grid-line'); this._lineWidth = options.lineWidth ?? (lineWidthAttr !== null ? parseInt(lineWidthAttr, 10) : constants_1.DEFAULT_LINE_WIDTH); const terminationRaw = options.termination ?? container.getAttribute('data-grid-termination') ?? constants_1.DEFAULT_TERMINATION; if (!(0, types_1.isTermination)(terminationRaw)) { throw new Error(`Invalid termination "${terminationRaw}"`); } this.termination = terminationRaw; const rawColumns = options.columns ?? container.getAttribute('data-grid-columns') ?? constants_1.DEFAULT_COLUMNS; this.applyColumnsInput(rawColumns); // Initialise immediately if visible, otherwise defer until the container enters the viewport. if (container.offsetWidth > 0 && container.offsetHeight > 0) { this.initialize(); } else { this.observeForVisibility(); } } /** Pure-helper wrapper that copies the result into the instance fields. */ applyColumnsInput(raw) { const result = (0, parseColumns_1.applyColumns)(raw, this._gridType); this.columnsTotal = result.columnsTotal; this.columnsRaw = result.columnsRaw; this.hGaps = result.hGaps; this.vGaps = result.vGaps; } /** * Creates the canvas, attaches it to the container and triggers the first draw. * Idempotent — repeated calls are a no-op once initialised. */ initialize() { if (this.isInitialized) return; // The canvas is absolutely positioned over the container; the container // must therefore establish a positioning context. if (window.getComputedStyle(this.container).position === 'static') { this.container.style.position = 'relative'; } this.container.setAttribute(constants_1.INIT_MARKER_ATTR, 'true'); this.canvas = document.createElement('canvas'); this.container.appendChild(this.canvas); this.context = this.canvas.getContext('2d'); this.isInitialized = true; this.scale(); window.addEventListener('resize', this.resizeHandler); } /** * Watches a not-yet-visible container and initialises it the moment it * intersects the viewport. The observer disconnects after the first hit. */ observeForVisibility() { const observer = new IntersectionObserver((entries, obs) => { entries.forEach(entry => { if (entry.isIntersecting) { this.initialize(); obs.unobserve(this.container); } }); }, { threshold: 0.01 }); // > 0 so a zero-area container does not trigger observer.observe(this.container); } get gridType() { return this._gridType; } /** * Switches the grid type live. Re-derives the per-axis gap patterns from * the existing `columns` value — will throw if the current `columns` * length does not fit the new grid type (set a compatible `columns` first). */ set gridType(value) { if (!(0, types_1.isGridType)(value)) { throw new Error(`Invalid gridType "${value}"`); } this._gridType = value; this.applyColumnsInput(this.columnsRaw); if (this.isInitialized) this.scale(); } get color() { return this._color; } /** Updates the stroke colour and redraws (no layout change). */ set color(value) { this._color = value; if (this.isInitialized) this.redraw(); } get lineWidth() { return this._lineWidth; } /** Updates the line width; rescales because edge margins depend on it. */ set lineWidth(value) { this._lineWidth = value; if (this.isInitialized) this.scale(); } get columns() { return this.columnsRaw; } /** Updates the grid columns / gap pattern and redraws. */ set columns(value) { this.applyColumnsInput(value); if (this.isInitialized) this.scale(); } /** * Resizes the canvas to match the container's current pixel dimensions * (taking devicePixelRatio into account) and triggers a redraw. * * Aborts silently when the container has zero dimensions — this happens * when a previously visible container becomes hidden. */ scale() { // SSR guard. if (typeof window === 'undefined') return; // Reset our inline min-height so we measure the container's natural height // (with any user-CSS min-height still applied), then flush layout synchronously. this.container.style.minHeight = ''; void this.container.offsetHeight; if (this.container.offsetHeight === 0 || this.container.offsetWidth === 0) { return; } this.ratio = window.devicePixelRatio || 1; // `lineWidth` is interpreted as CSS pixels (`layoutpixel`) or as physical // canvas pixels (`devicepixel`); the canvas always works in physical pixels. this.lineWidthCanvas = this.units === 'layoutpixel' ? this._lineWidth / this.ratio : this._lineWidth; // Edge lines would otherwise be clipped in half — extend the canvas by // one line width along axes that carry an edge line. Horizontal-axis // edge lines are always added (vertical lines always reach the side edges). const config = gridTypeConfig_1.GRID_TYPE_CONFIG[this._gridType]; const marginX = this.lineWidthCanvas; const marginY = config.hasHorizontalEdgeLine ? this.lineWidthCanvas : 0; this.gridWidth = this.container.offsetWidth * this.ratio; const rawHeight = this.container.offsetHeight * this.ratio; if (this.termination === 'extend' && this._gridType !== 'columns') { // Round up so a horizontal line closes the bottom edge. For `rows` // the horizontals only sit on hGaps-pattern positions, so round up // to the next pattern tickmark; otherwise to the next integer row. const gridSize = this.gridWidth / this.columnsTotal; const rawRows = rawHeight / gridSize; const targetRows = (this._gridType === 'rows' && this.hGaps) ? (0, gapPattern_1.nextGapTick)(rawRows - 1e-9, this.hGaps) : Math.ceil(rawRows - 1e-9); this.gridHeight = targetRows * gridSize; // Grow the container itself so its background/border wraps the extension. // `min-height` refers to the content area under `content-box` (default), // so we subtract padding+border for that case; with `border-box` it refers // to the whole box and we set the target directly. const cs = window.getComputedStyle(this.container); let targetMinHeight = this.gridHeight / this.ratio; if (cs.boxSizing !== 'border-box') { const paddingY = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom); const borderY = parseFloat(cs.borderTopWidth) + parseFloat(cs.borderBottomWidth); targetMinHeight -= paddingY + borderY; } this.container.style.minHeight = targetMinHeight + 'px'; } else { this.gridHeight = rawHeight; } this.canvasHeight = this.gridHeight + marginY; this.canvasWidth = this.gridWidth + marginX; // Physical canvas size (device pixels). this.canvas.height = this.canvasHeight; this.canvas.width = this.canvasWidth; // Negative margins pull the oversized canvas back so it stays centred on the container. this.canvas.style.margin = `${marginY * -0.5 / this.ratio}px ${marginX * -0.5 / this.ratio}px`; // CSS size (layout pixels) — the browser scales the device-pixel canvas back down. this.canvas.style.width = this.canvasWidth / this.ratio + 'px'; this.canvas.style.height = this.canvasHeight / this.ratio + 'px'; this.redraw(); } /** Clears the canvas and re-runs the draw cycle. Cheaper than `scale()`. */ redraw() { this.context.setTransform(1, 0, 0, 1, 0, 0); this.context.clearRect(0, 0, this.canvas.width, this.canvas.height); this.draw(); } /** Draws a horizontal line at `y`, spanning the full canvas width by default. */ horizontalLine(y, length = this.canvasWidth) { this.context.moveTo(0, y); this.context.lineTo(length, y); } /** Draws a vertical line at `x`, spanning the full canvas height by default. */ verticalLine(x, length = this.canvasHeight) { this.context.moveTo(x, 0); this.context.lineTo(x, length); } /** baseline: one horizontal line per grid unit, full width. */ drawBaseline(gridSize, offset) { // Integer counter + epsilon: avoids float-accumulation drift in `y += gridSize` // and the float wobble that makes `gridHeight / gridSize` land at e.g. 17.999… // instead of 18 after `Math.ceil(rawHeight/gridSize) * gridSize` at termination='extend'. const lastN = Math.floor(this.gridHeight / gridSize + 1e-9); for (let n = 0; n <= lastN; n++) { this.horizontalLine(Math.floor(n * gridSize + offset)); } } /** squared: baseline pattern plus one vertical line per grid unit. */ drawSquared(gridSize, offset) { this.drawBaseline(gridSize, offset); // `fill`: vertical lines run to the canvas edge; otherwise they stop at the // last horizontal line (last full grid row). Epsilon matches `drawBaseline` // so float drift doesn't drop the bottom row at termination='extend'. const lastN = Math.floor(this.gridHeight / gridSize + 1e-9); const lineLength = this.termination === 'fill' ? this.canvasHeight : lastN * gridSize + offset; this.verticalLine(offset, lineLength); for (let col = 1; col <= this.columnsTotal; col++) { this.verticalLine(Math.floor(col * gridSize + offset), lineLength); } } /** columns: vertical lines placed according to the alternating `vGaps` pattern. */ drawColumns(gridSize, offset) { if (!this.vGaps) return; for (const col of (0, gapPattern_1.gapPattern)(this.columnsTotal, this.vGaps)) { this.verticalLine(Math.floor(col * gridSize + offset)); } } /** * rows: horizontal lines from `hGaps`, vertical lines from `vGaps`. Both * patterns share the same grid unit (`gridSize = gridWidth / columnsTotal`). */ drawRows(gridSize, offset) { if (!this.hGaps || !this.vGaps) return; const verticalRange = Math.floor(this.gridHeight / gridSize + 1e-9); // Draw horizontals first, remember where the last one actually lands — // the gap pattern usually stops short of `verticalRange`. let lastRow = 0; for (const row of (0, gapPattern_1.gapPattern)(verticalRange, this.hGaps)) { lastRow = row; this.horizontalLine(Math.floor(row * gridSize + offset)); } const lineLength = this.termination === 'fill' ? this.canvasHeight : lastRow * gridSize + offset; for (const col of (0, gapPattern_1.gapPattern)(this.columnsTotal, this.vGaps)) { this.verticalLine(Math.floor(col * gridSize + offset), lineLength); } } /** * Renders the grid in a single canvas path, dispatching to the grid-type * specific helper. Stroke style and width are applied after the path is built. */ draw() { this.context.beginPath(); const gridSize = this.gridWidth / this.columnsTotal; const offset = this.lineWidthCanvas / 2; switch (this._gridType) { case 'baseline': this.drawBaseline(gridSize, offset); break; case 'squared': this.drawSquared(gridSize, offset); break; case 'columns': this.drawColumns(gridSize, offset); break; case 'rows': this.drawRows(gridSize, offset); break; default: { // Exhaustiveness check — fails the build if a new GridType is added without a handler. const _exhaustive = this._gridType; throw new Error(`Unhandled gridType: ${String(_exhaustive)}`); } } this.context.strokeStyle = this._color; this.context.lineWidth = this.lineWidthCanvas; this.context.stroke(); } } exports.CanvasGridLines = CanvasGridLines; /** * Convenience facade for bulk-managing grids. * * Use `initGrid` to construct one `CanvasGridLines` per matched element, * `setColumns` to update them all at once, and `getGrid` to look one up by * its container element. */ exports.canvasGridLines = { grids: [], /** * Creates a `CanvasGridLines` for each element matched by `targets` * (CSS selector, single HTMLElement or NodeList) and stores them in `grids`. * Per-element configuration via `data-grid-*` attributes wins unless the * caller passes an explicit option. Always returns an array (possibly empty). */ initGrid(options) { const { targets, ...gridOptions } = options; if (!targets) { throw new Error('No selector for elements given'); } const elements = []; if (typeof targets === 'string') { let elementsNodeList; try { elementsNodeList = document.querySelectorAll(targets); } catch (error) { throw new Error(`Invalid selector: ${targets}`); } elements.push(...Array.from(elementsNodeList)); } else if (targets instanceof NodeList) { elements.push(...Array.from(targets)); } else { elements.push(targets); } const newGrids = elements.map(element => new CanvasGridLines(element, gridOptions)); this.grids.push(...newGrids); return newGrids; }, /** * Re-applies the given `columns` value to every tracked grid. The value * must satisfy each grid's `gridType` constraints — passing e.g. a single * number to a mixed set including a `rows`-type grid will throw. */ setColumns(columns) { this.grids.forEach(grid => { grid.columns = columns; }); }, getGrid(element) { return this.grids.find(grid => grid.container === element); }, }; //# sourceMappingURL=index.js.map