/**
* LogarithmPlotter - 2D plotter software to make BODE plots, sequences and distribution functions.
* Copyright (C) 2021-2024 Ad5001
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see .
*/
import { Module } from "./common.mjs"
import { CanvasInterface, DialogInterface } from "./interface.mjs"
import { textsup } from "../utils.mjs"
import { Expression } from "../math/index.mjs"
import Latex from "./latex.mjs"
import Objects from "./objects.mjs"
import History from "./history.mjs"
import Settings from "./settings.mjs"
class CanvasAPI extends Module {
/** @type {CanvasInterface} */
#canvas = null
/** @type {CanvasRenderingContext2D} */
#ctx = null
/** @type {{show(string, string, string)}} */
#drawingErrorDialog = null
constructor() {
super("Canvas", {
canvas: CanvasInterface,
drawingErrorDialog: DialogInterface
})
/**
*
* @type {Object.}
*/
this.axesSteps = {
x: {
expression: null,
value: -1,
maxDraw: -1
},
y: {
expression: null,
value: -1,
maxDraw: -1
}
}
}
/**
* Initialize the module.
* @param {CanvasInterface} canvas
* @param {{show(string, string, string)}} drawingErrorDialog
*/
initialize({ canvas, drawingErrorDialog }) {
super.initialize({ canvas, drawingErrorDialog })
this.#canvas = canvas
this.#drawingErrorDialog = drawingErrorDialog
}
get width() {
if(!this.initialized) throw new Error("Attempting width before initialize!")
return this.#canvas.width
}
get height() {
if(!this.initialized) throw new Error("Attempting height before initialize!")
return this.#canvas.height
}
/**
* Minimum x of the diagram, provided from settings.
* @returns {number}
*/
get xmin() {
if(!this.initialized) throw new Error("Attempting xmin before initialize!")
return Settings.xmin
}
/**
* Zoom on the x-axis of the diagram, provided from settings.
* @returns {number}
*/
get xzoom() {
if(!this.initialized) throw new Error("Attempting xzoom before initialize!")
return Settings.xzoom
}
/**
* Maximum y of the diagram, provided from settings.
* @returns {number}
*/
get ymax() {
if(!this.initialized) throw new Error("Attempting ymax before initialize!")
return Settings.ymax
}
/**
* Zoom on the y-axis of the diagram, provided from settings.
* @returns {number}
*/
get yzoom() {
if(!this.initialized) throw new Error("Attempting yzoom before initialize!")
return Settings.yzoom
}
/**
* Label used on the x-axis, provided from settings.
* @returns {string}
*/
get xlabel() {
if(!this.initialized) throw new Error("Attempting xlabel before initialize!")
return Settings.xlabel
}
/**
* Label used on the y-axis, provided from settings.
* @returns {string}
*/
get ylabel() {
if(!this.initialized) throw new Error("Attempting ylabel before initialize!")
return Settings.ylabel
}
/**
* Width of lines that will be drawn into the canvas, provided from settings.
* @returns {number}
*/
get linewidth() {
if(!this.initialized) throw new Error("Attempting linewidth before initialize!")
return Settings.linewidth
}
/**
* Font size of the text that will be drawn into the canvas, provided from settings.
* @returns {number}
*/
get textsize() {
if(!this.initialized) throw new Error("Attempting textsize before initialize!")
return Settings.textsize
}
/**
* True if the canvas should be in logarithmic mode, false otherwise.
* @returns {boolean}
*/
get logscalex() {
if(!this.initialized) throw new Error("Attempting logscalex before initialize!")
return Settings.logscalex
}
/**
* True if the x graduation should be shown, false otherwise.
* @returns {boolean}
*/
get showxgrad() {
if(!this.initialized) throw new Error("Attempting showxgrad before initialize!")
return Settings.showxgrad
}
/**
* True if the y graduation should be shown, false otherwise.
* @returns {boolean}
*/
get showygrad() {
if(!this.initialized) throw new Error("Attempting showygrad before initialize!")
return Settings.showygrad
}
/**
* Max power of the logarithmic scaled on the x axis in logarithmic mode.
* @returns {number}
*/
get maxgradx() {
if(!this.initialized) throw new Error("Attempting maxgradx before initialize!")
return Math.min(
309, // 10e309 = Infinity (beyond this land be dragons)
Math.max(
Math.ceil(Math.abs(Math.log10(this.xmin))),
Math.ceil(Math.abs(Math.log10(this.px2x(this.width))))
)
)
}
//
// Methods to draw the canvas
//
requestPaint() {
if(!this.initialized) throw new Error("Attempting requestPaint before initialize!")
this.#canvas.requestPaint()
}
/**
* Redraws the entire canvas
*/
redraw() {
if(!this.initialized) throw new Error("Attempting redraw before initialize!")
this.#ctx = this.#canvas.getContext("2d")
this._computeAxes()
this._reset()
this._drawGrid()
this._drawAxes()
this._drawLabels()
this.#ctx.lineWidth = this.linewidth
for(let objType in Objects.currentObjects) {
for(let obj of Objects.currentObjects[objType]) {
this.#ctx.strokeStyle = obj.color
this.#ctx.fillStyle = obj.color
if(obj.visible)
try {
obj.draw(this)
} catch(e) {
// Drawing throws an error. Generally, it's due to a new modification (or the opening of a file)
console.error(e)
console.log(e.stack)
this.#drawingErrorDialog.show(objType, obj.name, e.message)
History.undo()
}
}
}
this.#ctx.lineWidth = 1
}
/**
* Calculates information for drawing gradations for axes.
* @private
*/
_computeAxes() {
let exprY = new Expression(`x*(${Settings.yaxisstep})`)
let y1 = exprY.execute(1)
let exprX = new Expression(`x*(${Settings.xaxisstep})`)
let x1 = exprX.execute(1)
this.axesSteps = {
x: {
expression: exprX,
value: x1,
maxDraw: Math.ceil(Math.max(Math.abs(this.xmin), Math.abs(this.px2x(this.width))) / x1)
},
y: {
expression: exprY,
value: y1,
maxDraw: Math.ceil(Math.max(Math.abs(this.ymax), Math.abs(this.px2y(this.height))) / y1)
}
}
}
/**
* Resets the canvas to a blank one with default setting.
* @private
*/
_reset() {
// Reset
this.#ctx.fillStyle = "#FFFFFF"
this.#ctx.strokeStyle = "#000000"
this.#ctx.font = `${this.textsize}px sans-serif`
this.#ctx.fillRect(0, 0, this.width, this.height)
}
/**
* Draws the grid.
* @private
*/
_drawGrid() {
this.#ctx.strokeStyle = "#C0C0C0"
if(this.logscalex) {
for(let xpow = -this.maxgradx; xpow <= this.maxgradx; xpow++) {
for(let xmulti = 1; xmulti < 10; xmulti++) {
this.drawXLine(Math.pow(10, xpow) * xmulti)
}
}
} else {
for(let x = 0; x < this.axesSteps.x.maxDraw; x += 1) {
this.drawXLine(x * this.axesSteps.x.value)
this.drawXLine(-x * this.axesSteps.x.value)
}
}
for(let y = 0; y < this.axesSteps.y.maxDraw; y += 1) {
this.drawYLine(y * this.axesSteps.y.value)
this.drawYLine(-y * this.axesSteps.y.value)
}
}
/**
* Draws the graph axes.
* @private
*/
_drawAxes() {
this.#ctx.strokeStyle = "#000000"
let axisypos = this.logscalex ? 1 : 0
this.drawXLine(axisypos)
this.drawYLine(0)
let axisypx = this.x2px(axisypos) // X coordinate of Y axis
let axisxpx = this.y2px(0) // Y coordinate of X axis
// Drawing arrows
this.drawLine(axisypx, 0, axisypx - 10, 10)
this.drawLine(axisypx, 0, axisypx + 10, 10)
this.drawLine(this.width, axisxpx, this.width - 10, axisxpx - 10)
this.drawLine(this.width, axisxpx, this.width - 10, axisxpx + 10)
}
/**
* Resets the canvas to a blank one with default setting.
* @private
*/
_drawLabels() {
let axisypx = this.x2px(this.logscalex ? 1 : 0) // X coordinate of Y axis
let axisxpx = this.y2px(0) // Y coordinate of X axis
// Labels
this.#ctx.fillStyle = "#000000"
this.#ctx.font = `${this.textsize}px sans-serif`
this.#ctx.fillText(this.ylabel, axisypx + 10, 24)
let textWidth = this.#ctx.measureText(this.xlabel).width
this.#ctx.fillText(this.xlabel, this.width - 14 - textWidth, axisxpx - 5)
// Axis graduation labels
this.#ctx.font = `${this.textsize - 4}px sans-serif`
let txtMinus = this.#ctx.measureText("-").width
if(this.showxgrad) {
if(this.logscalex) {
for(let xpow = -this.maxgradx; xpow <= this.maxgradx; xpow += 1) {
textWidth = this.#ctx.measureText("10" + textsup(xpow)).width
if(xpow !== 0)
this.drawVisibleText("10" + textsup(xpow), this.x2px(Math.pow(10, xpow)) - textWidth / 2, axisxpx + 16 + (6 * (xpow === 1)))
}
} else {
for(let x = 1; x < this.axesSteps.x.maxDraw; x += 1) {
let drawX = x * this.axesSteps.x.value
let txtX = this.axesSteps.x.expression.simplify(x).toString().replace(/^\((.+)\)$/, "$1")
let textHeight = this.measureText(txtX).height
this.drawVisibleText(txtX, this.x2px(drawX) - 4, axisxpx + this.textsize / 2 + textHeight)
this.drawVisibleText("-" + txtX, this.x2px(-drawX) - 4, axisxpx + this.textsize / 2 + textHeight)
}
}
}
if(this.showygrad) {
for(let y = 0; y < this.axesSteps.y.maxDraw; y += 1) {
let drawY = y * this.axesSteps.y.value
let txtY = this.axesSteps.y.expression.simplify(y).toString().replace(/^\((.+)\)$/, "$1")
textWidth = this.#ctx.measureText(txtY).width
this.drawVisibleText(txtY, axisypx - 6 - textWidth, this.y2px(drawY) + 4 + (10 * (y === 0)))
if(y !== 0)
this.drawVisibleText("-" + txtY, axisypx - 6 - textWidth - txtMinus, this.y2px(-drawY) + 4)
}
}
this.#ctx.fillStyle = "#FFFFFF"
}
//
// Public functions
//
/**
* Draws an horizontal line at x plot coordinate.
* @param {number} x
*/
drawXLine(x) {
if(this.isVisible(x, this.ymax)) {
this.drawLine(this.x2px(x), 0, this.x2px(x), this.height)
}
}
/**
* Draws an vertical line at y plot coordinate
* @param {number} y
* @private
*/
drawYLine(y) {
if(this.isVisible(this.xmin, y)) {
this.drawLine(0, this.y2px(y), this.width, this.y2px(y))
}
}
/**
* Writes multiline text onto the canvas.
* NOTE: The x and y properties here are relative to the canvas, not the plot.
* @param {string} text
* @param {number} x
* @param {number} y
*/
drawVisibleText(text, x, y) {
if(x > 0 && x < this.width && y > 0 && y < this.height) {
text.toString().split("\n").forEach((txt, i) => {
this.#ctx.fillText(txt, x, y + (this.textsize * i))
})
}
}
/**
* Draws an image onto the canvas.
* NOTE: The x, y width and height properties here are relative to the canvas, not the plot.
* @param {CanvasImageSource} image
* @param {number} x
* @param {number} y
* @param {number} width
* @param {number} height
*/
drawVisibleImage(image, x, y, width, height) {
this.#canvas.markDirty(Qt.rect(x, y, width, height))
this.#ctx.drawImage(image, x, y, width, height)
}
/**
* Measures the width and height of a multiline text that would be drawn onto the canvas.
* @param {string} text
* @returns {{width: number, height: number}}
*/
measureText(text) {
let theight = 0
let twidth = 0
let defaultHeight = this.textsize * 1.2 // Approximate but good enough!
for(let txt of text.split("\n")) {
theight += defaultHeight
if(this.#ctx.measureText(txt).width > twidth) twidth = this.#ctx.measureText(txt).width
}
return { "width": twidth, "height": theight }
}
/**
* Converts an x coordinate to its relative position on the canvas.
* It supports both logarithmic and non-logarithmic scale depending on the currently selected mode.
* @param {number} x
* @returns {number}
*/
x2px(x) {
if(this.logscalex) {
const logxmin = Math.log(this.xmin)
return (Math.log(x) - logxmin) * this.xzoom
} else
return (x - this.xmin) * this.xzoom
}
/**
* Converts an y coordinate to it's relative position on the canvas.
* The y-axis not supporting logarithmic scale, it only supports linear conversion.
* @param {number} y
* @returns {number}
*/
y2px(y) {
return (this.ymax - y) * this.yzoom
}
/**
* Converts an x px position on the canvas to it's corresponding coordinate on the plot.
* It supports both logarithmic and non-logarithmic scale depending on the currently selected mode.
* @param {number} px
* @returns {number}
*/
px2x(px) {
if(this.logscalex) {
return Math.exp(px / this.xzoom + Math.log(this.xmin))
} else
return (px / this.xzoom + this.xmin)
}
/**
* Converts an x px position on the canvas to it's corresponding coordinate on the plot.
* It supports both logarithmic and non logarithmic scale depending on the currently selected mode.
* @param {number} px
* @returns {number}
*/
px2y(px) {
return -(px / this.yzoom - this.ymax)
}
/**
* Checks whether a plot point (x, y) is visible or not on the canvas.
* @param {number} x
* @param {number} y
* @returns {boolean}
*/
isVisible(x, y) {
return (this.x2px(x) >= 0 && this.x2px(x) <= this.width) && (this.y2px(y) >= 0 && this.y2px(y) <= this.height)
}
/**
* Draws a line from plot point (x1, y1) to plot point (x2, y2).
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
*/
drawLine(x1, y1, x2, y2) {
this.#ctx.beginPath()
this.#ctx.moveTo(x1, y1)
this.#ctx.lineTo(x2, y2)
this.#ctx.stroke()
}
/**
* Draws a dashed line from plot point (x1, y1) to plot point (x2, y2).
* @param {number} x1
* @param {number} y1
* @param {number} x2
* @param {number} y2
* @param {number} dashPxSize
*/
drawDashedLine(x1, y1, x2, y2, dashPxSize = 6) {
this.#ctx.setLineDash([dashPxSize / 2, dashPxSize])
this.drawLine(x1, y1, x2, y2)
this.#ctx.setLineDash([])
}
/**
* Renders latex markup ltxText to an image and loads it. Returns a dictionary with three values: source, width and height.
* @param {string} ltxText
* @param {string} color
* @param {function(LatexRenderResult|{width: number, height: number, source: string})} callback
*/
renderLatexImage(ltxText, color, callback) {
const onRendered = (imgData) => {
if(!this.#canvas.isImageLoaded(imgData.source) && !this.#canvas.isImageLoading(imgData.source)) {
// Wait until the image is loaded to callback.
this.#canvas.loadImage(imgData.source)
this.#canvas.imageLoaders[imgData.source] = [callback, imgData]
} else {
// Callback directly
callback(imgData)
}
}
const prerendered = Latex.findPrerendered(ltxText, this.textsize, color)
if(prerendered !== null)
onRendered(prerendered)
else
Latex.requestAsyncRender(ltxText, this.textsize, color).then(onRendered)
}
//
// Context methods
//
get font() {
return this.#ctx.font
}
set font(value) {
return this.#ctx.font = value
}
/**
* Draws an act on the canvas centered on a point.
* @param {number} x
* @param {number} y
* @param {number} radius
* @param {number} startAngle
* @param {number} endAngle
* @param {boolean} counterclockwise
*/
arc(x, y, radius, startAngle, endAngle, counterclockwise = false) {
this.#ctx.beginPath()
this.#ctx.arc(x, y, radius, startAngle, endAngle, counterclockwise)
this.#ctx.stroke()
}
/**
* Draws a filled circle centered on a point.
* @param {number} x
* @param {number} y
* @param {number} radius
*/
disc(x, y, radius) {
this.#ctx.beginPath()
this.#ctx.arc(x, y, radius, 0, 2 * Math.PI)
this.#ctx.fill()
}
/**
* Draws a filled rectangle onto the canvas.
* @param {number} x
* @param {number} y
* @param {number} w
* @param {number} h
*/
fillRect(x, y, w, h) {
this.#ctx.fillRect(x, y, w, h)
}
}
/** @type {CanvasAPI} */
Modules.Canvas = Modules.Canvas || new CanvasAPI()
export default Modules.Canvas