Strilanc/Quirk

View on GitHub
src/draw/Painter.js

Summary

Maintainability
C
1 day
Test Coverage
/**
 * Copyright 2017 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {Config} from "../Config.js"
import {Point} from "../math/Point.js"
import {Rect} from "../math/Rect.js"
import {RestartableRng} from "../base/RestartableRng.js"
import {seq, Seq} from "../base/Seq.js"
import {Util} from "../base/Util.js"

class Painter {
    /**
     * @param {!HTMLCanvasElement} canvas
     * @param {!RestartableRng=} rng
     */
    constructor(canvas, rng = new RestartableRng()) {
        /** @type {!HTMLCanvasElement} */
        this.canvas = canvas;
        /** @type {!CanvasRenderingContext2D} */
        this.ctx = canvas.getContext("2d");
        /**
         * @type {!Array.<!function()>}
         * @private
         */
        this._deferredPaintActions = [];
        /**
         * @type {!TraceAction}
         * @private
         */
        this._traceAction = new TraceAction(this.ctx);
        /**
         * @type {!Tracer}
         * @private
         */
        this._tracer = new Tracer(this.ctx);
        /**
         * @type {undefined|!string}
         */
        this.desiredCursorStyle = undefined;
        /**
         * @type {!Array.<!{rect: !Rect, cursor: undefined|!string}>}
         */
        this.touchBlockers = [];
        /**
         * @type {!RestartableRng}
         */
        this.rng = rng;

        this._ignoringTouchBlockers = 0;
    }

    startIgnoringIncomingTouchBlockers() {
        this._ignoringTouchBlockers += 1
    }

    stopIgnoringIncomingTouchBlockers() {
        this._ignoringTouchBlockers -= 1
    }

    /**
     * @param {!{rect: !Rect, cursor: undefined|!string}} blocker
     */
    noteTouchBlocker(blocker) {
        if (this._ignoringTouchBlockers === 0) {
            this.touchBlockers.push(blocker);
        }
    }

    /**
     * @param {!string} cursorStyle auto|pointer|move|ns-resize|...
     */
    setDesiredCursor(cursorStyle) {
        this.desiredCursorStyle = cursorStyle;
    }

    /**
     * @param {!function()} tooltipPainter
     */
    defer(tooltipPainter) {
        this._deferredPaintActions.push(tooltipPainter);
    }

    paintDeferred() {
        for (let e of this._deferredPaintActions) {
            e();
        }
        this._deferredPaintActions = [];
    }

    /**
     * @returns {!Rect}
     */
    paintableArea() {
        return new Rect(0, 0, this.canvas.width, this.canvas.height);
    }

    /**
     * @param {!string=} color
     */
    clear(color = Config.DEFAULT_FILL_COLOR) {
        this.ctx.fillStyle = color;
        this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
    }

    /**
     * Draws a line segment between the two points.
     *
     * @param {!Point} p1
     * @param {!Point} p2
     * @param {=string} color The color of the drawn line.
     * @param {=number} thickness The thickness of the drawn line.
     */
    strokeLine(p1, p2, color = Config.DEFAULT_STROKE_COLOR, thickness = 1) {
        this.ctx.beginPath();
        this.ctx.moveTo(p1.x, p1.y);
        this.ctx.lineTo(p2.x, p2.y);
        this.ctx.strokeStyle = color;
        this.ctx.lineWidth = thickness;
        this.ctx.stroke();
    }

    /**
     * Draws the outside of a rectangle.
     * @param {!Rect} rect The rectangular perimeter to stroke.
     * @param {!string=} color The stroke color.
     * @param {!number=} thickness The stroke thickness.
     */
    strokeRect(rect, color = "black", thickness = 1) {
        this.ctx.strokeStyle = color;
        this.ctx.lineWidth = thickness;
        this.ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
    }

    /**
     * Draws the inside of a rectangle.
     * @param {!Rect} rect The rectangular area to fill.
     * @param {!string=} color The fill color.
     */
    fillRect(rect, color = Config.DEFAULT_FILL_COLOR) {
        this.ctx.fillStyle = color;
        this.ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
    }

    /**
     * Draws the outside of a circle.
     * @param {!Point} center The center of the circle.
     * @param {!number} radius The distance from the center of the circle to its side.
     * @param {!string=} color The stroke color.
     * @param {!number=} thickness The stroke thickness.
     */
    strokeCircle(center, radius, color = Config.DEFAULT_STROKE_COLOR, thickness = Config.DEFAULT_STROKE_THICKNESS) {
        this.ctx.beginPath();
        this.ctx.strokeStyle = color;
        this.ctx.lineWidth = thickness;
        this.ctx.arc(center.x, center.y, Math.max(radius - 0.5, 0), 0, 2 * Math.PI);
        this.ctx.stroke();
    }

    /**
     * @param {!function(!Tracer) : void} tracerFunc
     * @returns {!TraceAction}
     */
    trace(tracerFunc) {
        this.ctx.beginPath();
        tracerFunc(this._tracer);
        return this._traceAction;
    }

    /**
     * Draws the inside of a circle.
     * @param {!Point} center The center of the circle.
     * @param {!number} radius The distance from the center of the circle to its side.
     * @param {!string=} color The fill color. Defaults to white.
     */
    fillCircle(center, radius, color = Config.DEFAULT_FILL_COLOR) {
        this.ctx.beginPath();
        this.ctx.arc(center.x, center.y, Math.max(radius - 0.5, 0), 0, 2 * Math.PI);
        this.ctx.fillStyle = color;
        this.ctx.fill();
    }

    /**
     * Draws some text in a bounded area.
     * @param {!string} text The text to print.
     * @param {!number} x
     * @param {!number} y
     * @param {!number} boundingWidth The text will be scaled down so it doesn't exceed this width.
     * @param {!number} boundingHeight The text will be scaled down so it doesn't exceed this width.
     * @param {!string} textAlign Horizontal alignment. Options: start, end, left, right, center.
     * @param {!string} textBaseline Vertical alignment. Options: top, hanging, middle, alphabetic, ideographic, bottom.
     * @param {!string} fillStyle Text color.
     * @param {!string} font
     * @param {!function(!number, !number) : void} afterMeasureBeforeDraw
     * @param {!boolean} alsoStroke
     */
    print(text,
          x,
          y,
          textAlign,
          textBaseline,
          fillStyle,
          font,
          boundingWidth,
          boundingHeight,
          afterMeasureBeforeDraw = undefined,
          alsoStroke = false) {

        this.ctx.font = font;
        let naiveWidth = this.ctx.measureText(text).width;
        //noinspection JSSuspiciousNameCombination
        let naiveHeight = this.ctx.measureText("0").width * 2.5;
        let scale = Math.min(Math.min(boundingWidth / naiveWidth, boundingHeight / naiveHeight), 1);

        if (afterMeasureBeforeDraw !== undefined) {
            afterMeasureBeforeDraw(naiveWidth * scale, naiveHeight * scale);
        }
        this.ctx.save();
        this.ctx.textAlign = textAlign;
        this.ctx.textBaseline = textBaseline;
        this.ctx.font = font; // Re-set the font, because the 'afterMeasureBeforeDraw' callback may have changed it.
        this.ctx.fillStyle = fillStyle;
        this.ctx.translate(x, y);
        this.ctx.scale(scale, scale);
        if (alsoStroke) {
            this.ctx.strokeText(text, 0, 0);
        }
        this.ctx.fillText(text, 0, 0);
        this.ctx.restore();
    }

    /**
     * Draws some text within the given rectangular area, aligned based on the given proportional center, with
     * line breaking and (if line breaking isn't enough) font size reduction to make things fit.
     *
     * @param {!string} text
     * @param {!Rect} area
     * @param {!Point} proportionalCenterOfAlignment
     * @param {!string} fontColor
     * @param {!int} maxFontSize
     * @param {!string} fontFamily
     * @returns {!Rect} A minimal bounding rectangle containing the pixels affected by the text printing.
     */
    printParagraph(text,
                   area,
                   proportionalCenterOfAlignment = new Point(0, 0),
                   fontColor = Config.DEFAULT_TEXT_COLOR,
                   maxFontSize = Config.DEFAULT_FONT_SIZE,
                   fontFamily = Config.DEFAULT_FONT_FAMILY) {

        let fontSize;
        let ascendingHeightOf = metric => {
            let d = metric.fontBoundingBoxAscent;
            return d === undefined ? fontSize * 0.75 : d;
        };
        let descendingHeightOf = metric => {
            let d = metric.fontBoundingBoxDescent;
            return d === undefined ? fontSize * 0.25 : d;
        };
        let heightOf = metric => ascendingHeightOf(metric) + descendingHeightOf(metric);

        let lines;
        let measures;
        let height;
        let forcedLines = seq(text.split("\n"));
        for (let df = 0; ; df++) { // Note: potential for quadratic behavior.
            fontSize = maxFontSize - df;
            this.ctx.font = fontSize + "px " + fontFamily;
            lines = forcedLines.
                flatMap(line => Util.breakLine(line, area.w, s => this.ctx.measureText(s).width)).
                toArray();
            measures = lines.map(e => this.ctx.measureText(e));
            height = seq(measures.map(heightOf)).sum();
            if (height <= area.h || fontSize <= 4) {
                break;
            }
        }

        let f = (offset, full, used, proportion) => offset + (full - used) * proportion;
        let fx = w => f(area.x, area.w, w, proportionalCenterOfAlignment.x);
        let fy = h => f(area.y, area.h, h, proportionalCenterOfAlignment.y);
        let y = fy(height);

        this.ctx.fillStyle = fontColor;

        let dy = 0;
        for (let i = 0; i < lines.length; i++) {
            dy += ascendingHeightOf(measures[i]);
            this.ctx.fillText(lines[i], fx(measures[i].width), y + dy);
            dy += descendingHeightOf(measures[i]);
        }

        let maxWidth = new Seq(measures).map(e => e.width).max(0);
        return new Rect(fx(maxWidth), y, maxWidth, height);
    }

    /**
     * Draws a single line of text, without line breaks, using font size reduction to make things fit.
     *
     * @param {!string} text
     * @param {!Rect} area
     * @param {!number|undefined=} proportionalCenterOfHorizontalAlignment
     * @param {!string|undefined=} fontColor
     * @param {!int|undefined=} maxFontSize
     * @param {!string|undefined=} fontFamily
     * @param {!number|undefined=} proportionalCenterOfVerticalAlignment
     * @returns {!Rect} A minimal bounding rectangle containing the pixels affected by the text printing.
     */
    printLine(text,
              area,
              proportionalCenterOfHorizontalAlignment = 0,
              fontColor = Config.DEFAULT_TEXT_COLOR,
              maxFontSize = Config.DEFAULT_FONT_SIZE,
              fontFamily = Config.DEFAULT_FONT_FAMILY,
              proportionalCenterOfVerticalAlignment = undefined) {

        let fontSize;
        let ascendingHeightOf = metric => {
            let d = metric.fontBoundingBoxAscent;
            return d === undefined ? fontSize * 0.75 : d;
        };
        let descendingHeightOf = metric => {
            let d = metric.fontBoundingBoxDescent;
            return d === undefined ? fontSize * 0.25 : d;
        };
        let heightOf = metric => ascendingHeightOf(metric) + descendingHeightOf(metric);

        let measure;
        for (let df = 0; ; df++) { // Note: potential for quadratic behavior.
            fontSize = maxFontSize - df;
            this.ctx.font = fontSize + "px " + fontFamily;
            measure = this.ctx.measureText(text);
            if ((measure.width <= area.w && heightOf(measure) <= area.h) || fontSize <= 4) {
                break;
            }
        }

        let h = heightOf(measure);
        let py = proportionalCenterOfVerticalAlignment === undefined ?
            ascendingHeightOf(measure) / h :
            proportionalCenterOfVerticalAlignment;
        let f = (offset, full, used, proportion) => offset + (full - used) * proportion;
        let x = f(area.x, area.w, measure.width, proportionalCenterOfHorizontalAlignment);
        let y = f(area.y, area.h, h, py);

        this.ctx.fillStyle = fontColor;
        this.ctx.fillText(text, x, y + ascendingHeightOf(measure));

        return new Rect(x, y, measure.width, h);
    }

    /**
     * Draws the outside of a polygon.
     * @param {!(!Point[])} vertices
     * @param {!string=} strokeColor The stroke color.
     * @param {!number=} strokeThickness The stroke thickness.
     */
    strokePolygon(vertices,
                  strokeColor = Config.DEFAULT_STROKE_COLOR,
                  strokeThickness = Config.DEFAULT_STROKE_THICKNESS) {
        if (vertices.length === 0) {
            return;
        }
        let last = vertices[vertices.length - 1];

        this.ctx.beginPath();
        this.ctx.moveTo(last.x, last.y);
        for (let p of vertices) {
            this.ctx.lineTo(p.x, p.y);
        }

        this.ctx.strokeStyle = strokeColor;
        this.ctx.lineWidth = strokeThickness;
        this.ctx.stroke();
    }

    /**
     * Draws a path.
     * @param {!(!Point[])} vertices
     * @param {!string=} strokeColor The stroke color.
     * @param {!number=} strokeThickness The stroke thickness.
     */
    strokePath(vertices,
                  strokeColor = Config.DEFAULT_STROKE_COLOR,
                  strokeThickness = Config.DEFAULT_STROKE_THICKNESS) {
        if (vertices.length === 0) {
            return;
        }

        this.ctx.beginPath();
        this.ctx.moveTo(vertices[0].x, vertices[0].y);
        for (let p of vertices.slice(1)) {
            this.ctx.lineTo(p.x, p.y);
        }

        this.ctx.strokeStyle = strokeColor;
        this.ctx.lineWidth = strokeThickness;
        this.ctx.stroke();
    }

    /**
     * Draws the inside of a polygon.
     * @param {!(!Point[])} vertices
     * @param {!string} fillColor
     */
    fillPolygon(vertices, fillColor) {
        let last = vertices[vertices.length - 1];

        this.ctx.beginPath();
        this.ctx.moveTo(last.x, last.y);
        for (let p of vertices) {
            this.ctx.lineTo(p.x, p.y);
        }

        this.ctx.fillStyle = fillColor;
        this.ctx.fill();
    }
}

/**
 * Has various helper methods for tracing shapes and paths in a CanvasRenderingContext2D.
 */
class Tracer {
    /**
     * @param {!CanvasRenderingContext2D} ctx
     */
    constructor(ctx) {
        /** @type {!CanvasRenderingContext2D} */
        this.ctx = ctx;
    }

    /**
     * @param {!number} x1
     * @param {!number} y1
     * @param {!number} x2
     * @param {!number} y2
     */
    line(x1, y1, x2, y2) {
        this.ctx.moveTo(x1, y1);
        this.ctx.lineTo(x2, y2);
    }

    /**
     * @param {!number} x
     * @param {!number} y
     * @param {!number} w
     * @param {!number} h
     */
    rect(x, y, w, h) {
        this.ctx.moveTo(x, y);
        this.ctx.lineTo(x + w, y);
        this.ctx.lineTo(x + w, y + h);
        this.ctx.lineTo(x, y + h);
        this.ctx.lineTo(x, y);
    }

    /**
     * @param {!number} x The x-coordinate of the center of the circle.
     * @param {!number} y The y-coordinate of the center of the circle.
     * @param {!number} radius The distance from the center of the circle to its side.
     */
    circle(x, y, radius) {
        this.ctx.moveTo(x + radius, y);
        this.ctx.arc(x, y, radius, 0, 2 * Math.PI, false);
    }

    /**
     * @param {!number} x The x-coordinate of the center of the ellipse.
     * @param {!number} y The y-coordinate of the center of the ellipse.
     * @param {!number} horizontal_radius The horizontal distance from the center of the ellipse to its side.
     * @param {!number} vertical_radius The vertical distance from the center of the ellipse to its side.
     */
    ellipse(x, y, horizontal_radius, vertical_radius) {
        this.ctx.save();

        this.ctx.translate(x - horizontal_radius, y - vertical_radius);
        this.ctx.scale(horizontal_radius, vertical_radius);
        this.ctx.moveTo(2, 1);
        this.ctx.arc(1, 1, 1, 0, 2 * Math.PI, false);

        this.ctx.restore();
    }

    /**
     * @param {!number} x
     * @param {!number} y
     * @param {!number} w
     * @param {!number} h
     * @param {!int} numCols
     * @param {!int} numRows
     */
    grid(x, y, w, h, numCols, numRows) {
        let dw = w / numCols;
        let dh = h / numRows;
        let x2 = x + numCols * dw;
        let y2 = y + numRows * dh;
        for (let c = 0; c <= numCols; c++) {
            this.ctx.moveTo(x + c * dw, y);
            this.ctx.lineTo(x + c * dw, y2);
        }
        for (let r = 0; r <= numRows; r++) {
            this.ctx.moveTo(x, y + r * dh);
            this.ctx.lineTo(x2, y + r * dh);
        }
    }

    /**
     * @param {!Array.<!number>|!Float32Array} interleavedCoordinates
     */
    polygon(interleavedCoordinates) {
        if (interleavedCoordinates.length === 0) {
            return;
        }

        let n = interleavedCoordinates.length;
        this.ctx.moveTo(interleavedCoordinates[n-2], interleavedCoordinates[n-1]);
        for (let i = 0; i < n; i += 2) {
            this.ctx.lineTo(interleavedCoordinates[i], interleavedCoordinates[i+1]);
        }
    }

    /**
     * @param {!number} x The x-position of the center of the arrow head.
     * @param {number} y The y-position of the center of the arrow head.
     * @param {number} radius The radius of the circle the arrow head is inscribed inside.
     * @param {number} facingAngle The direction the arrow head is pointing towards.
     * @param {number} sweptAngle The angle swept out by the back of the arrow head, relative to its center (not the
     * point at the front).
     */
    arrowHead(x, y, radius, facingAngle, sweptAngle) {
        let a1 = facingAngle + sweptAngle/2 + Math.PI;
        let a2 = facingAngle - sweptAngle/2 + Math.PI;
        this.polygon([
            x + Math.cos(facingAngle)*radius, y + Math.sin(facingAngle)*radius,
            x + Math.cos(a1)*radius, y + Math.sin(a1)*radius,
            x + Math.cos(a2)*radius, y + Math.sin(a2)*radius
        ]);
    }

}

/**
 * Strokes/fills a traced path.
 */
class TraceAction {
    constructor(ctx) {
        this.ctx = ctx;
    }

    /**
     * @param {!string} fillStyle
     * @returns {!TraceAction}
     */
    thenFill(fillStyle) {
        this.ctx.fillStyle = fillStyle;
        this.ctx.fill();
        return this;
    }

    /**
     * @param {!string} strokeStyle
     * @param {!number=} lineWidth
     * @returns {!TraceAction}
     */
    thenStroke(strokeStyle, lineWidth = 1) {
        this.ctx.strokeStyle = strokeStyle;
        this.ctx.lineWidth = lineWidth;
        this.ctx.stroke();
        return this;
    }
}

export {Painter}