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);
}
}