Source: Plot.js

  1. import { unseededRandomHex } from "./Random.js";
  2. import { hexToDec } from "./utils.js";
  3. import { decToHex } from "./utils.js";
  4. import { Line } from "./Line.js";
  5. import { Circle } from "./Circle.js";
  6. import { Path } from "./Path.js";
  7. import { SVGBuilder } from "./SVGBuilder.js";
  8. import { Point } from "./Point.js";
  9. let plotContext = null;
  10. /**
  11. * @class
  12. * Plot is an object that is able to create, display and download SVG documents.
  13. */
  14. export class Plot {
  15. /**
  16. * @param {object} [options] - An object containing configuration for Plot.
  17. * @param {object} [options.size] - An object with width and height properties to be used as dimensions of the Plot.
  18. * @param {number} [options.size.width=100] - The width of the Plot.
  19. * @param {number} [options.size.height=100] - The height of the Plot.
  20. * @param {string} [options.title = "Untitled"] - The title of the Plot.
  21. * @param {string} [options.units = "mm"] - The units of measurement to be used (i.e. "mm" or "in").
  22. * @param {string} [options.backgroundColor = "transparent"] - The background colour of the plot, as a hex value or HTML color name.
  23. * @param {number} [options.seed] - The seed to be used for the Plot. Defaults to an 8 digit hexadecimal integer.
  24. * @param {string} [options.stroke = "black"] - The foreground colour of the plot, as a hex value or HTML color name.
  25. * @param {number} [options.strokeWidth = 1] - The line width of the Plot. Defaults to 1 unit. (1mm)
  26. * @param {number} [options.minimumLineLength = 0.1] - Lines shorter than this length are not drawn.
  27. */
  28. constructor({
  29. units = "mm",
  30. backgroundColor = "transparent",
  31. title = "Untitled",
  32. seed = unseededRandomHex(8),
  33. size = {
  34. width: 100,
  35. height: 100,
  36. },
  37. stroke = "black",
  38. strokeWidth = 1,
  39. minimumLineLength = 0.1,
  40. } = {}) {
  41. this.title = title;
  42. this.size = size;
  43. this.strokeWidth = strokeWidth;
  44. this.units = units;
  45. this.stroke = stroke;
  46. this.backgroundColor = backgroundColor;
  47. this.minimumLineLength = minimumLineLength;
  48. plotContext = this;
  49. this.lines = [];
  50. this.paths = [];
  51. this.circles = [];
  52. this.points = [];
  53. this.seedHistory = [];
  54. this.svgBuilder = new SVGBuilder();
  55. this.svgBuilder
  56. .setWidth(`${this.size.width}${this.units}`)
  57. .setHeight(`${this.size.height}${this.units}`)
  58. .setViewBox(`0 0 ${this.size.width} ${this.size.height}`)
  59. .setBackgroundColor(this.backgroundColor);
  60. this.generate = () => {};
  61. this.seed = seed;
  62. if (typeof seed === "number") {
  63. this.seed = {
  64. hex: decToHex(seed, 8),
  65. decimal: seed,
  66. };
  67. }
  68. this.handleKeydown = this.handleKeydown.bind(this);
  69. document.addEventListener("keydown", this.handleKeydown);
  70. }
  71. static getContext() {
  72. return plotContext;
  73. }
  74. /**
  75. *
  76. * @returns {string} - The file name to be used for the Plot's SVG file,
  77. * as a string in the format `Title_ffffffff_210x297mm.svg`
  78. */
  79. filename() {
  80. return `${this.title}_${this.seed.hex}_${this.size.width}x${this.size.height}${this.units}.svg`;
  81. }
  82. /**
  83. * @summary Adds shapes to the plot.
  84. * @param {Line|Circle|Path|Point|Array} shape - An object or array of objects to be added to the plot.
  85. * @example
  86. * import { Plot, Line, Circle } from "@jakebeamish/penplotting";
  87. const plot = new Plot({
  88. backgroundColor: "#ffffff"
  89. });
  90. plot.generate = () => {
  91. const line = Line.fromArray([0, 0, 100, 100]);
  92. const circlesArray = [
  93. new Circle(10, 10, 10),
  94. new Circle(40, 40, 15),
  95. new Circle(80, 80, 20)
  96. ];
  97. plot.add([line, circlesArray]);
  98. }
  99. plot.draw();
  100. */
  101. add(shape) {
  102. const shapes = Array.isArray(shape) ? shape.flat(Infinity) : [shape];
  103. shapes.forEach((item) => this.addSingleShape(item));
  104. }
  105. /**
  106. * Adds a single shape to the appropriate array. Used by {@link Plot#add}.
  107. * @private
  108. * @param {Line|Path|Circle|Point} shape
  109. */
  110. addSingleShape(shape) {
  111. if (shape instanceof Line) {
  112. this.lines.push(shape);
  113. } else if (shape instanceof Path) {
  114. this.paths.push(shape);
  115. } else if (shape instanceof Circle) {
  116. this.circles.push(shape);
  117. } else if (shape instanceof Point) {
  118. this.points.push(shape);
  119. } else {
  120. throw new TypeError(
  121. "Unsupported shape type. Shape must be a Line, Path, Point or Circle.",
  122. );
  123. }
  124. }
  125. /**
  126. * Generates the SVG and UI and appends them to the document body.
  127. * Must be called after defining a Plot.generate() function.
  128. */
  129. draw() {
  130. let startTime = Date.now();
  131. this.generate();
  132. this.deduplicateLines();
  133. this.removeOverlappingLines();
  134. this.removeShortLines(this.minimumLineLength);
  135. this.addLinesToSVG();
  136. this.addPathsToSVG();
  137. this.addCirclesToSVG();
  138. this.addPointsToSVG();
  139. this.svg = this.svgBuilder.build();
  140. const timeTaken = +(
  141. Math.round((Date.now() - startTime) / 1000 + "e+2") + "e-2"
  142. );
  143. this.createUI(timeTaken);
  144. this.appendSVG();
  145. if (!this.seedHistory.includes(this.seed.hex)) {
  146. this.seedHistory.unshift(this.seed.hex);
  147. }
  148. }
  149. handleKeydown(e) {
  150. const focusedElement = document.activeElement;
  151. // Check if the focused element is an input, textarea, or select
  152. if (
  153. focusedElement.tagName === "INPUT" ||
  154. focusedElement.tagName === "TEXTAREA" ||
  155. focusedElement.tagName === "SELECT" ||
  156. focusedElement.isContentEditable
  157. ) {
  158. // If an input is focused, do nothing
  159. return;
  160. }
  161. if (e.key === "d") {
  162. this.downloadSVG();
  163. } else if (e.key === "r") {
  164. this.randomiseSeed();
  165. }
  166. }
  167. /**
  168. * Empty out any existing HTML UI and SVG document elements on the page, in order to regenerate a Plot.
  169. */
  170. clear() {
  171. this.lines = [];
  172. this.paths = [];
  173. this.circles = [];
  174. this.points = [];
  175. document.body.innerHTML = "";
  176. this.svgBuilder.clear();
  177. }
  178. /**
  179. * Download the {@link Plot} as an SVG file.
  180. */
  181. downloadSVG() {
  182. const serializer = new XMLSerializer();
  183. const source = serializer.serializeToString(this.svgBuilder.build());
  184. const svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
  185. const url = URL.createObjectURL(svgBlob);
  186. const a = document.createElement("a");
  187. a.href = url;
  188. a.download = this.filename();
  189. document.body.appendChild(a);
  190. a.click();
  191. document.body.removeChild(a);
  192. URL.revokeObjectURL(url);
  193. }
  194. randomiseSeed() {
  195. this.seed = unseededRandomHex(8);
  196. this.clear();
  197. this.draw();
  198. }
  199. setSeed(input) {
  200. this.seed = {
  201. hex: input,
  202. decimal: hexToDec(input),
  203. };
  204. this.clear();
  205. this.draw();
  206. }
  207. /**
  208. * Removes duplicated [Lines]{@link Line} from this Plot's lines array.
  209. */
  210. deduplicateLines() {
  211. const uniqueLines = [];
  212. for (const line of this.lines) {
  213. let isDuplicate = false;
  214. for (const uniqueLine of uniqueLines) {
  215. if (line.isDuplicate(uniqueLine)) {
  216. isDuplicate = true;
  217. break;
  218. }
  219. }
  220. if (!isDuplicate) {
  221. uniqueLines.push(line);
  222. }
  223. }
  224. this.lines = uniqueLines;
  225. }
  226. /**
  227. * Removes overlapping [Lines]{@link Line} from this Plot's lines array.
  228. */
  229. removeOverlappingLines() {
  230. const uniqueLines = [];
  231. let sortedLines = this.lines.toSorted(
  232. (j, k) => k.a.distance(k.b) - j.a.distance(j.b),
  233. );
  234. for (const line of sortedLines) {
  235. let isOverlapped = false;
  236. for (const uniqueLine of uniqueLines) {
  237. if (line.isContainedBy(uniqueLine)) {
  238. isOverlapped = true;
  239. break;
  240. }
  241. }
  242. if (!isOverlapped) {
  243. uniqueLines.push(line);
  244. }
  245. }
  246. this.lines = uniqueLines;
  247. }
  248. /**
  249. * Removes [Lines]{@link Line} in this Plot's lines array that are shorter than a specified minimum length.
  250. * @param {number} minimumLength
  251. */
  252. removeShortLines(minimumLength) {
  253. const validLines = [];
  254. for (const line of this.lines) {
  255. if (line.length() > minimumLength) {
  256. validLines.push(line);
  257. }
  258. }
  259. this.lines = validLines;
  260. }
  261. /**
  262. * Adds the [Lines]{@link Line} in this Plot's lines array to it's SVG element.
  263. */
  264. addLinesToSVG() {
  265. for (const line of this.lines) {
  266. this.svgBuilder.addShape(line.toSVGElement());
  267. }
  268. }
  269. /**
  270. * Adds the [Paths]{@link Path} in this Plot's paths array to it's SVG element.
  271. */
  272. addPathsToSVG() {
  273. for (const path of this.paths) {
  274. this.svgBuilder.addShape(path.toSVGElement());
  275. }
  276. }
  277. /**
  278. * Adds the [Circles]{@link Circle} in this Plot's circles array to it's SVG element.
  279. */
  280. addCirclesToSVG() {
  281. for (const circle of this.circles) {
  282. this.svgBuilder.addShape(circle.toSVGElement());
  283. }
  284. }
  285. addPointsToSVG() {
  286. for (const point of this.points) {
  287. this.svgBuilder.addShape(point.toSVGElement());
  288. }
  289. }
  290. /**
  291. * Appends this plot's SVG element to the document body.
  292. */
  293. appendSVG() {
  294. document.body.appendChild(this.svg);
  295. }
  296. updateDocumentTitle() {
  297. document.title = `${this.title} ${this.seed.hex}`;
  298. }
  299. createElement(tag, parent, textContent = "", attributes = {}) {
  300. const element = document.createElement(tag);
  301. if (textContent) element.textContent = textContent;
  302. Object.keys(attributes).forEach((key) =>
  303. element.setAttribute(key, attributes[key]),
  304. );
  305. parent.appendChild(element);
  306. return element;
  307. }
  308. createSeedInput(parent) {
  309. const seedInput = this.createElement("input", parent, "", {
  310. value: this.seed.hex,
  311. });
  312. seedInput.addEventListener("change", () => this.setSeed(seedInput.value));
  313. seedInput.addEventListener("focus", () => seedInput.select());
  314. }
  315. createHistoryForm(parent) {
  316. const historyForm = this.createElement("form", parent);
  317. const historyLabel = this.createElement("label", historyForm, "History: ", {
  318. for: "history",
  319. });
  320. const historySelect = this.createElement("select", historyLabel, "", {
  321. name: "history",
  322. id: "history",
  323. });
  324. this.createElement("option", historySelect, "--------", { value: "" });
  325. this.seedHistory.forEach((seed) => {
  326. const option = this.createElement("option", historySelect, seed, {
  327. value: seed,
  328. });
  329. option.addEventListener("click", () => this.setSeed(seed));
  330. });
  331. }
  332. createNavigation(parent) {
  333. const nav = this.createElement("nav", parent);
  334. const ul = this.createElement("ul", nav);
  335. this.createNavItem(ul, "⬇️", () => this.downloadSVG());
  336. this.createNavItem(ul, "🔄", () => this.randomiseSeed());
  337. }
  338. createNavItem(parent, text, clickHandler) {
  339. const listItem = this.createElement("li", parent);
  340. const button = this.createElement("a", listItem, text);
  341. button.addEventListener("click", clickHandler);
  342. }
  343. getNumOfShapes() {
  344. return this.lines.length + this.paths.length + this.circles.length + this.points.length;
  345. }
  346. addPlotInfo(parent, timeTaken) {
  347. const plotInfo = this.createElement("div", parent);
  348. const timeText = timeTaken < 0.05 ? "<0.05s" : `~${timeTaken}s`;
  349. this.createElement(
  350. "p",
  351. plotInfo,
  352. `Generated ${this.getNumOfShapes()} shapes in ${timeText}`,
  353. );
  354. }
  355. /**
  356. * Creates HTML UI and adds it to the document body.
  357. * @param {number} timeTaken
  358. */
  359. createUI(timeTaken) {
  360. this.updateDocumentTitle();
  361. // Create a HTML header element and append to body.
  362. const header = this.createElement("header", document.body);
  363. // Create a h1 title and append to header.
  364. this.createElement("h1", header, this.title);
  365. this.createSeedInput(header);
  366. this.createHistoryForm(header);
  367. this.createNavigation(header);
  368. this.addPlotInfo(header, timeTaken);
  369. }
  370. }