UNPKG

@jakebeamish/penplotting

Version:

A JavaScript framework for making SVG files for penplotters.

439 lines (370 loc) 11.8 kB
import { unseededRandomHex } from "./Random.js"; import { hexToDec } from "./utils.js"; import { decToHex } from "./utils.js"; import { Line } from "./Line.js"; import { Circle } from "./Circle.js"; import { Path } from "./Path.js"; import { SVGBuilder } from "./SVGBuilder.js"; import { Point } from "./Point.js"; let plotContext = null; /** * @class * Plot is an object that is able to create, display and download SVG documents. */ export class Plot { /** * @param {object} [options] - An object containing configuration for Plot. * @param {object} [options.size] - An object with width and height properties to be used as dimensions of the Plot. * @param {number} [options.size.width=100] - The width of the Plot. * @param {number} [options.size.height=100] - The height of the Plot. * @param {string} [options.title = "Untitled"] - The title of the Plot. * @param {string} [options.units = "mm"] - The units of measurement to be used (i.e. "mm" or "in"). * @param {string} [options.backgroundColor = "transparent"] - The background colour of the plot, as a hex value or HTML color name. * @param {number} [options.seed] - The seed to be used for the Plot. Defaults to an 8 digit hexadecimal integer. * @param {string} [options.stroke = "black"] - The foreground colour of the plot, as a hex value or HTML color name. * @param {number} [options.strokeWidth = 1] - The line width of the Plot. Defaults to 1 unit. (1mm) * @param {number} [options.minimumLineLength = 0.1] - Lines shorter than this length are not drawn. */ constructor({ units = "mm", backgroundColor = "transparent", title = "Untitled", seed = unseededRandomHex(8), size = { width: 100, height: 100, }, stroke = "black", strokeWidth = 1, minimumLineLength = 0.1, } = {}) { this.title = title; this.size = size; this.strokeWidth = strokeWidth; this.units = units; this.stroke = stroke; this.backgroundColor = backgroundColor; this.minimumLineLength = minimumLineLength; plotContext = this; this.lines = []; this.paths = []; this.circles = []; this.points = []; this.seedHistory = []; this.svgBuilder = new SVGBuilder(); this.svgBuilder .setWidth(`${this.size.width}${this.units}`) .setHeight(`${this.size.height}${this.units}`) .setViewBox(`0 0 ${this.size.width} ${this.size.height}`) .setBackgroundColor(this.backgroundColor); this.generate = () => {}; this.seed = seed; if (typeof seed === "number") { this.seed = { hex: decToHex(seed, 8), decimal: seed, }; } this.handleKeydown = this.handleKeydown.bind(this); document.addEventListener("keydown", this.handleKeydown); } static getContext() { return plotContext; } /** * * @returns {string} - The file name to be used for the Plot's SVG file, * as a string in the format `Title_ffffffff_210x297mm.svg` */ filename() { return `${this.title}_${this.seed.hex}_${this.size.width}x${this.size.height}${this.units}.svg`; } /** * @summary Adds shapes to the plot. * @param {Line|Circle|Path|Point|Array} shape - An object or array of objects to be added to the plot. * @example * import { Plot, Line, Circle } from "@jakebeamish/penplotting"; const plot = new Plot({ backgroundColor: "#ffffff" }); plot.generate = () => { const line = Line.fromArray([0, 0, 100, 100]); const circlesArray = [ new Circle(10, 10, 10), new Circle(40, 40, 15), new Circle(80, 80, 20) ]; plot.add([line, circlesArray]); } plot.draw(); */ add(shape) { const shapes = Array.isArray(shape) ? shape.flat(Infinity) : [shape]; shapes.forEach((item) => this.addSingleShape(item)); } /** * Adds a single shape to the appropriate array. Used by {@link Plot#add}. * @private * @param {Line|Path|Circle|Point} shape */ addSingleShape(shape) { if (shape instanceof Line) { this.lines.push(shape); } else if (shape instanceof Path) { this.paths.push(shape); } else if (shape instanceof Circle) { this.circles.push(shape); } else if (shape instanceof Point) { this.points.push(shape); } else { throw new TypeError( "Unsupported shape type. Shape must be a Line, Path, Point or Circle.", ); } } /** * Generates the SVG and UI and appends them to the document body. * Must be called after defining a Plot.generate() function. */ draw() { let startTime = Date.now(); this.generate(); this.deduplicateLines(); this.removeOverlappingLines(); this.removeShortLines(this.minimumLineLength); this.addLinesToSVG(); this.addPathsToSVG(); this.addCirclesToSVG(); this.addPointsToSVG(); this.svg = this.svgBuilder.build(); const timeTaken = +( Math.round((Date.now() - startTime) / 1000 + "e+2") + "e-2" ); this.createUI(timeTaken); this.appendSVG(); if (!this.seedHistory.includes(this.seed.hex)) { this.seedHistory.unshift(this.seed.hex); } } handleKeydown(e) { const focusedElement = document.activeElement; // Check if the focused element is an input, textarea, or select if ( focusedElement.tagName === "INPUT" || focusedElement.tagName === "TEXTAREA" || focusedElement.tagName === "SELECT" || focusedElement.isContentEditable ) { // If an input is focused, do nothing return; } if (e.key === "d") { this.downloadSVG(); } else if (e.key === "r") { this.randomiseSeed(); } } /** * Empty out any existing HTML UI and SVG document elements on the page, in order to regenerate a Plot. */ clear() { this.lines = []; this.paths = []; this.circles = []; this.points = []; document.body.innerHTML = ""; this.svgBuilder.clear(); } /** * Download the {@link Plot} as an SVG file. */ downloadSVG() { const serializer = new XMLSerializer(); const source = serializer.serializeToString(this.svgBuilder.build()); const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(svgBlob); const a = document.createElement("a"); a.href = url; a.download = this.filename(); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } randomiseSeed() { this.seed = unseededRandomHex(8); this.clear(); this.draw(); } setSeed(input) { this.seed = { hex: input, decimal: hexToDec(input), }; this.clear(); this.draw(); } /** * Removes duplicated [Lines]{@link Line} from this Plot's lines array. */ deduplicateLines() { const uniqueLines = []; for (const line of this.lines) { let isDuplicate = false; for (const uniqueLine of uniqueLines) { if (line.isDuplicate(uniqueLine)) { isDuplicate = true; break; } } if (!isDuplicate) { uniqueLines.push(line); } } this.lines = uniqueLines; } /** * Removes overlapping [Lines]{@link Line} from this Plot's lines array. */ removeOverlappingLines() { const uniqueLines = []; let sortedLines = this.lines.toSorted( (j, k) => k.a.distance(k.b) - j.a.distance(j.b), ); for (const line of sortedLines) { let isOverlapped = false; for (const uniqueLine of uniqueLines) { if (line.isContainedBy(uniqueLine)) { isOverlapped = true; break; } } if (!isOverlapped) { uniqueLines.push(line); } } this.lines = uniqueLines; } /** * Removes [Lines]{@link Line} in this Plot's lines array that are shorter than a specified minimum length. * @param {number} minimumLength */ removeShortLines(minimumLength) { const validLines = []; for (const line of this.lines) { if (line.length() > minimumLength) { validLines.push(line); } } this.lines = validLines; } /** * Adds the [Lines]{@link Line} in this Plot's lines array to it's SVG element. */ addLinesToSVG() { for (const line of this.lines) { this.svgBuilder.addShape(line.toSVGElement()); } } /** * Adds the [Paths]{@link Path} in this Plot's paths array to it's SVG element. */ addPathsToSVG() { for (const path of this.paths) { this.svgBuilder.addShape(path.toSVGElement()); } } /** * Adds the [Circles]{@link Circle} in this Plot's circles array to it's SVG element. */ addCirclesToSVG() { for (const circle of this.circles) { this.svgBuilder.addShape(circle.toSVGElement()); } } addPointsToSVG() { for (const point of this.points) { this.svgBuilder.addShape(point.toSVGElement()); } } /** * Appends this plot's SVG element to the document body. */ appendSVG() { document.body.appendChild(this.svg); } updateDocumentTitle() { document.title = `${this.title} ${this.seed.hex}`; } createElement(tag, parent, textContent = "", attributes = {}) { const element = document.createElement(tag); if (textContent) element.textContent = textContent; Object.keys(attributes).forEach((key) => element.setAttribute(key, attributes[key]), ); parent.appendChild(element); return element; } createSeedInput(parent) { const seedInput = this.createElement("input", parent, "", { value: this.seed.hex, }); seedInput.addEventListener("change", () => this.setSeed(seedInput.value)); seedInput.addEventListener("focus", () => seedInput.select()); } createHistoryForm(parent) { const historyForm = this.createElement("form", parent); const historyLabel = this.createElement("label", historyForm, "History: ", { for: "history", }); const historySelect = this.createElement("select", historyLabel, "", { name: "history", id: "history", }); this.createElement("option", historySelect, "--------", { value: "" }); this.seedHistory.forEach((seed) => { const option = this.createElement("option", historySelect, seed, { value: seed, }); option.addEventListener("click", () => this.setSeed(seed)); }); } createNavigation(parent) { const nav = this.createElement("nav", parent); const ul = this.createElement("ul", nav); this.createNavItem(ul, "⬇️", () => this.downloadSVG()); this.createNavItem(ul, "🔄", () => this.randomiseSeed()); } createNavItem(parent, text, clickHandler) { const listItem = this.createElement("li", parent); const button = this.createElement("a", listItem, text); button.addEventListener("click", clickHandler); } getNumOfShapes() { return this.lines.length + this.paths.length + this.circles.length + this.points.length; } addPlotInfo(parent, timeTaken) { const plotInfo = this.createElement("div", parent); const timeText = timeTaken < 0.05 ? "<0.05s" : `~${timeTaken}s`; this.createElement( "p", plotInfo, `Generated ${this.getNumOfShapes()} shapes in ${timeText}`, ); } /** * Creates HTML UI and adds it to the document body. * @param {number} timeTaken */ createUI(timeTaken) { this.updateDocumentTitle(); // Create a HTML header element and append to body. const header = this.createElement("header", document.body); // Create a h1 title and append to header. this.createElement("h1", header, this.title); this.createSeedInput(header); this.createHistoryForm(header); this.createNavigation(header); this.addPlotInfo(header, timeTaken); } }